yggdrasil/libs/tiptap-editor/src/upload-image.ts
xfy b1b1c88a74 chore(biome): 引入 Biome v2.5 monorepo 配置并格式化全量源码
新增根 biome.json(v2.5,v2 起原生支持 monorepo,子包自动向上查找):

- formatter: 2 空格缩进、单引号、行宽 100、LF、尾逗号、箭头函数括号
  (匹配现有代码风格)
- linter: recommended preset + 项目化裁剪
  - useTemplate/useTemplate off(geometry.ts 故意保留字符串拼接,
    且测试用精确浮点断言锁定行为)
  - noNonNullAssertion off(测试与 DOM 访问里的 ! 是惯用写法)
  - noConsole off(4 个 lib 用 console.error/warn 做错误上报)
  - CSS 关闭 noDescendingSpecificity/noDuplicateProperties
    (手写 CSS 的 fallback 模式)
  - useImportType/useNodejsImportProtocol/noUnusedVariables warn
- vcs.useIgnoreFile 自动读 .gitignore;排除 public/target/node_modules/
  dist/.dioxus/static + Tailwind 入口 input.css(含 @theme 等 Biome 无法
  解析的 at-rules)

首次格式化覆盖 29 个源文件:import 排序、node: 协议、引号/缩进统一,
并修复 index.ts 两处 forEach 回调隐式返回值(useIterableCallbackReturn)。
90 个 vitest 用例全绿,确认无语义改动。
2026-07-02 17:54:46 +08:00

200 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Image } from '@tiptap/extension-image';
import type { Node as PMNode } from '@tiptap/pm/model';
import { UPLOAD_COORDINATOR_STORAGE_KEY, type UploadCoordinator } from './upload-coordinator';
/** NodeView 按钮点击 / destroy 回调注入接口。 */
export interface UploadNodeViewCallbacks {
onRetry: (uploadId: string) => void;
onRemove: (uploadId: string) => void;
/** NodeView.destroy 兜底:节点被 PM 删除时清理 pending + revoke blob。 */
onDestroyed: (uploadId: string) => void;
}
/**
* 上传图片占位符 NodeViewplain class实现 ProseMirror NodeView 接口。
*
* 根据 data-upload-state 渲染三种态:
* - null普通 img
* - uploadingimg本地 blob 预览)+ 遮罩spinner + "上传中…"
* - errorimg灰化+ 遮罩(⚠ + 错误文案 + 重试/移除按钮)
*
* NodeView 纯渲染,不发起上传——按钮点击转发给注入的 callbacks实际是 coordinator
* 属性变化时 ProseMirror 调 update(node)NodeView 比较新旧 data-upload-state 重渲染遮罩。
*/
export class UploadImageNodeView {
private node: PMNode;
private callbacks: UploadNodeViewCallbacks;
private uploadId: string | null;
private container: HTMLDivElement;
private img: HTMLImageElement;
private overlay: HTMLDivElement | null = null;
constructor(opts: {
node: PMNode;
HTMLAttributes: Record<string, unknown>;
callbacks: UploadNodeViewCallbacks;
}) {
this.node = opts.node;
this.callbacks = opts.callbacks;
this.uploadId = (this.node.attrs['data-upload-id'] as string | null) ?? null;
this.container = document.createElement('div');
this.container.classList.add('upload-image-container');
this.img = document.createElement('img');
this.img.draggable = false;
Object.entries(opts.HTMLAttributes).forEach(([k, v]) => {
if (v != null) this.img.setAttribute(k, String(v));
});
const src = this.node.attrs.src;
if (src != null) this.img.src = src;
this.container.appendChild(this.img);
this.renderOverlay();
}
get dom(): HTMLElement {
return this.container;
}
get contentDOM(): HTMLElement | null {
return null;
}
/** ProseMirror 调用:节点属性变化时重渲染遮罩。返回 false 拒绝非同类节点。 */
update(node: PMNode): boolean {
if (node.type !== this.node.type) return false;
const oldState = this.node.attrs['data-upload-state'];
const newState = node.attrs['data-upload-state'];
const oldSrc = this.node.attrs.src;
const newSrc = node.attrs.src;
this.node = node;
this.uploadId = (node.attrs['data-upload-id'] as string | null) ?? null;
if (oldSrc !== newSrc && newSrc != null) {
this.img.src = newSrc;
}
if (oldState !== newState || this.overlay === null) {
this.renderOverlay();
}
return true;
}
/** 遮罩内按钮点击不被 ProseMirror 当编辑,避免误触发事务。 */
ignoreMutation(): boolean {
return true;
}
/** 事件不被编辑器 stopEvent 拦截(按钮点击要响应)。 */
stopEvent(_event: Event): boolean {
return false;
}
/** 根据 data-upload-state 渲染遮罩。null 时移除遮罩。 */
private renderOverlay(): void {
if (this.overlay) {
this.overlay.remove();
this.overlay = null;
}
const state = this.node.attrs['data-upload-state'];
if (state == null) {
this.container.classList.remove('is-uploading', 'is-error');
return;
}
this.overlay = document.createElement('div');
this.overlay.classList.add('upload-image-overlay');
if (state === 'uploading') {
this.container.classList.add('is-uploading');
this.container.classList.remove('is-error');
this.overlay.innerHTML =
'<div class="upload-spinner"></div><div class="upload-overlay-text">上传中…</div>';
} else if (state === 'error') {
this.container.classList.add('is-error');
this.container.classList.remove('is-uploading');
const msg = this.node.attrs['data-error-msg'] || '上传失败';
this.overlay.innerHTML =
'<div class="upload-error-icon">⚠</div>' +
'<div class="upload-error-msg"></div>' +
'<div class="upload-error-actions">' +
'<button type="button" class="upload-btn upload-btn-retry">重试</button>' +
'<button type="button" class="upload-btn upload-btn-remove">移除</button>' +
'</div>';
const msgEl = this.overlay.querySelector('.upload-error-msg') as HTMLElement;
msgEl.textContent = msg;
const uploadId = this.node.attrs['data-upload-id'] as string | null;
this.overlay.querySelector('.upload-btn-retry')?.addEventListener('click', (e) => {
e.preventDefault();
if (uploadId) this.callbacks.onRetry(uploadId);
});
this.overlay.querySelector('.upload-btn-remove')?.addEventListener('click', (e) => {
e.preventDefault();
if (uploadId) this.callbacks.onRemove(uploadId);
});
}
this.container.appendChild(this.overlay);
}
destroy(): void {
// 节点被 ProseMirror 删除(退格/剪切等):兜底清理 pending + revoke blob
// 避免 upload-coordinator 的 pending Map 和 blob URL 泄漏(尤其 error 态节点)。
if (this.uploadId) {
this.callbacks.onDestroyed(this.uploadId);
}
this.overlay?.remove();
this.overlay = null;
this.container.remove();
}
}
/**
* 自定义 Image 扩展:继承父类属性,加三个上传状态属性,用自定义 NodeView。
*/
export const UploadImage = Image.configure({ allowBase64: true }).extend({
addAttributes() {
return {
...this.parent?.(),
'data-upload-state': {
default: null,
parseHTML: (el) => (el as HTMLElement).getAttribute('data-upload-state'),
renderHTML: (attrs) => {
const v = attrs['data-upload-state'];
return v == null ? {} : { 'data-upload-state': v };
},
},
'data-upload-id': {
default: null,
parseHTML: (el) => (el as HTMLElement).getAttribute('data-upload-id'),
renderHTML: (attrs) => {
const v = attrs['data-upload-id'];
return v == null ? {} : { 'data-upload-id': v };
},
},
'data-error-msg': {
default: null,
parseHTML: (el) => (el as HTMLElement).getAttribute('data-error-msg'),
renderHTML: (attrs) => {
const v = attrs['data-error-msg'];
return v == null ? {} : { 'data-error-msg': v };
},
},
};
},
addNodeView() {
return ({ node, HTMLAttributes, editor }) => {
const coordinator = (editor.storage as unknown as Record<string, unknown>)[
UPLOAD_COORDINATOR_STORAGE_KEY
] as UploadCoordinator | undefined;
return new UploadImageNodeView({
node,
HTMLAttributes,
callbacks: {
onRetry: (id) => coordinator?.retryUpload(id),
onRemove: (id) => coordinator?.removeUpload(id),
onDestroyed: (id) => coordinator?.handleNodeDestroyed(id),
},
});
};
},
});