yggdrasil/libs/tiptap-editor/src/upload-image.ts

198 lines
6.9 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),
},
})
}
},
})