feat(editor): add UploadCoordinator skeleton with pending map and node removal

This commit is contained in:
xfy 2026-06-22 15:22:42 +08:00
parent c92ba6d648
commit e06667f792

View File

@ -0,0 +1,117 @@
import type { Editor } from '@tiptap/core'
/** pending 上传条目:保留 File 供重试blobUrl 供本地预览。 */
interface UploadEntry {
file: File
blobUrl: string
fileName: string
}
/** coordinator 推给 Rust 的事件(通过 window.__tiptap_uploads.events。 */
export interface UploadEvent {
kind: 'error' | 'success' | 'removed'
uploadId: string
fileName: string
errorMsg?: string
ts: number
}
/** 生成 uploadId兼容非安全上下文无 crypto.randomUUID 时)。 */
function genUploadId(): string {
return crypto.randomUUID?.() ?? Math.random().toString(36).slice(2)
}
/**
*
*
*
* - uploadId blob URL
* - src error
* - upload-id /
* - window.__tiptap_uploads Rust
*
* pending Map File
*/
export class UploadCoordinator {
private pending = new Map<string, UploadEntry>()
constructor(
private editor: Editor,
private onImageUpload: (file: File) => Promise<string>,
) {}
/**
*
* pos
* runUpload
*/
insertUploading(file: File, pos?: number): void {
const uploadId = genUploadId()
const blobUrl = URL.createObjectURL(file)
this.pending.set(uploadId, { file, blobUrl, fileName: file.name })
this.editor.chain().focus().insertContentAt(pos ?? this.editor.state.selection.head, {
type: 'image',
attrs: {
src: blobUrl,
'data-upload-state': 'uploading',
'data-upload-id': uploadId,
},
}).run()
}
/** 按 uploadId 删除节点revoke blob、清 pending。NodeView 移除按钮 / Rust ×关闭 共用。 */
removeUpload(uploadId: string): boolean {
const entry = this.pending.get(uploadId)
if (!entry) return false
this.removeNodeByUploadId(uploadId)
URL.revokeObjectURL(entry.blobUrl)
this.pending.delete(uploadId)
return true
}
/** 按 uploadId 在文档中定位节点并删除。 */
private removeNodeByUploadId(uploadId: string): void {
let targetPos: number | null = null
let nodeSize = 0
this.editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'image' && node.attrs['data-upload-id'] === uploadId) {
targetPos = pos
nodeSize = node.nodeSize
return false
}
return true
})
if (targetPos !== null) {
const tr = this.editor.state.tr.delete(targetPos, targetPos + nodeSize)
this.editor.view.dispatch(tr)
}
}
/** 按 uploadId 定位节点并合并更新属性。 */
private updateNodeAttrs(uploadId: string, attrs: Record<string, unknown>): void {
let targetPos: number | null = null
let oldAttrs: Record<string, unknown> | null = null
this.editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'image' && node.attrs['data-upload-id'] === uploadId) {
targetPos = pos
oldAttrs = node.attrs
return false
}
return true
})
if (targetPos !== null && oldAttrs) {
const tr = this.editor.state.tr.setNodeMarkup(
targetPos,
undefined,
{ ...oldAttrs, ...attrs },
)
this.editor.view.dispatch(tr)
}
}
/** pending Map 查询(供内部/测试)。 */
hasPending(uploadId: string): boolean {
return this.pending.has(uploadId)
}
}