fix(tiptap): revoke blob on node destroy + maintain upload counts internally

This commit is contained in:
xfy 2026-06-23 18:36:07 +08:00
parent 71df6abe5d
commit 2e239908a4

View File

@ -1,10 +1,12 @@
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
/** pending 上传条目:保留 File 供重试blobUrl 供本地预览。 */ /** pending 上传条目:保留 File 供重试blobUrl 供本地预览state 跟踪当前态。 */
interface UploadEntry { interface UploadEntry {
file: File file: File
blobUrl: string blobUrl: string
fileName: string fileName: string
/** 当前态uploading进行中或 error失败待重试。成功后整个 entry 删除。 */
state: 'uploading' | 'error'
} }
/** counts 快照:随事件一起传给宿主,替代 window 全局。 */ /** counts 快照:随事件一起传给宿主,替代 window 全局。 */
@ -42,6 +44,9 @@ function genUploadId(): string {
*/ */
export class UploadCoordinator { export class UploadCoordinator {
private pending = new Map<string, UploadEntry>() private pending = new Map<string, UploadEntry>()
/** 内部计数:替代每次事件全量遍历文档。 */
private uploadingCount = 0
private errorCount = 0
constructor( constructor(
private editor: Editor, private editor: Editor,
@ -57,7 +62,8 @@ export class UploadCoordinator {
insertUploading(file: File, pos?: number): void { insertUploading(file: File, pos?: number): void {
const uploadId = genUploadId() const uploadId = genUploadId()
const blobUrl = URL.createObjectURL(file) const blobUrl = URL.createObjectURL(file)
this.pending.set(uploadId, { file, blobUrl, fileName: file.name }) this.pending.set(uploadId, { file, blobUrl, fileName: file.name, state: 'uploading' })
this.uploadingCount++
this.editor.chain().focus().insertContentAt(pos ?? this.editor.state.selection.head, { this.editor.chain().focus().insertContentAt(pos ?? this.editor.state.selection.head, {
type: 'image', type: 'image',
@ -77,11 +83,32 @@ export class UploadCoordinator {
if (!entry) return false if (!entry) return false
this.removeNodeByUploadId(uploadId) this.removeNodeByUploadId(uploadId)
URL.revokeObjectURL(entry.blobUrl) URL.revokeObjectURL(entry.blobUrl)
this.notifyRust({ kind: 'removed', uploadId, fileName: entry.fileName }) // 按当前态减对应计数
if (entry.state === 'uploading') this.uploadingCount--
else this.errorCount--
this.pending.delete(uploadId) this.pending.delete(uploadId)
this.notifyRust({ kind: 'removed', uploadId, fileName: entry.fileName })
return true return true
} }
/**
* NodeView.destroy ProseMirror 退
* removeUpload PM pending + revoke +
* removeNodeByUploadId emit 'removed'counts
*
* entry pending delete no-op
*/
handleNodeDestroyed(uploadId: string): void {
const entry = this.pending.get(uploadId)
if (!entry) return
URL.revokeObjectURL(entry.blobUrl)
if (entry.state === 'uploading') this.uploadingCount--
else this.errorCount--
this.pending.delete(uploadId)
// emit removed 让宿主顶部提示同步移除该 id
this.notifyRust({ kind: 'removed', uploadId, fileName: entry.fileName })
}
/** 核心上传逻辑:成功更新 src + 清上传属性,失败转 error 态。 */ /** 核心上传逻辑:成功更新 src + 清上传属性,失败转 error 态。 */
private async runUpload(uploadId: string): Promise<void> { private async runUpload(uploadId: string): Promise<void> {
const entry = this.pending.get(uploadId) const entry = this.pending.get(uploadId)
@ -97,6 +124,7 @@ export class UploadCoordinator {
}) })
URL.revokeObjectURL(entry.blobUrl) URL.revokeObjectURL(entry.blobUrl)
this.pending.delete(uploadId) this.pending.delete(uploadId)
this.uploadingCount--
this.notifyRust({ kind: 'success', uploadId, fileName: entry.fileName }) this.notifyRust({ kind: 'success', uploadId, fileName: entry.fileName })
} catch (err) { } catch (err) {
const msg = this.extractErrorMessage(err) const msg = this.extractErrorMessage(err)
@ -104,6 +132,10 @@ export class UploadCoordinator {
'data-upload-state': 'error', 'data-upload-state': 'error',
'data-error-msg': msg, 'data-error-msg': msg,
}) })
// 失败:从 uploading 转 error 态
entry.state = 'error'
this.uploadingCount--
this.errorCount++
this.notifyRust({ kind: 'error', uploadId, fileName: entry.fileName, errorMsg: msg }) this.notifyRust({ kind: 'error', uploadId, fileName: entry.fileName, errorMsg: msg })
} }
} }
@ -112,6 +144,9 @@ export class UploadCoordinator {
retryUpload(uploadId: string): void { retryUpload(uploadId: string): void {
const entry = this.pending.get(uploadId) const entry = this.pending.get(uploadId)
if (!entry) return if (!entry) return
// 重试前节点处于 error 态,转回 uploading
this.errorCount--
this.uploadingCount++
this.updateNodeAttrs(uploadId, { this.updateNodeAttrs(uploadId, {
'data-upload-state': 'uploading', 'data-upload-state': 'uploading',
'data-error-msg': null, 'data-error-msg': null,
@ -167,18 +202,14 @@ export class UploadCoordinator {
/** /**
* emit counts 宿 * emit counts 宿
* window.__tiptap_uploads Rust * counts
*/ */
private notifyRust(event: Omit<UploadEvent, 'ts' | 'counts'>): void { private notifyRust(event: Omit<UploadEvent, 'ts' | 'counts'>): void {
let uploading = 0 this.emit({
let error = 0 ...event,
this.editor.state.doc.descendants((node) => { ts: Date.now(),
const state = node.attrs['data-upload-state'] counts: { uploading: this.uploadingCount, error: this.errorCount },
if (state === 'uploading') uploading++
else if (state === 'error') error++
return true
}) })
this.emit({ ...event, ts: Date.now(), counts: { uploading, error } })
} }
/** pending Map 查询(供内部/测试)。 */ /** pending Map 查询(供内部/测试)。 */