Compare commits
21 Commits
c1610ace99
...
1cc09e4cbc
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cc09e4cbc | |||
| 410d594ac0 | |||
| fb31ecb5ae | |||
| 368a651fa6 | |||
| c0af99736d | |||
| 08c2539dfc | |||
| 90cff2d0cc | |||
| f7042d3ab4 | |||
| 8ff051689f | |||
| 19532670d3 | |||
| 21c0359f5e | |||
| 939e0de171 | |||
| 6ffcd55c94 | |||
| e580c3ab18 | |||
| d099db4140 | |||
| 6b44a9c036 | |||
| 2e239908a4 | |||
| 71df6abe5d | |||
| 18ae6a915e | |||
| 558191e922 | |||
| 073f6936e3 |
@ -121,7 +121,8 @@ src/webp.rs — zenwebp encode/decode (image crate has no WebP)
|
||||
Rich-text editor in `libs/tiptap-editor/`, built as an IIFE library exposing `window.TiptapEditor`.
|
||||
|
||||
- Output: `public/tiptap/`
|
||||
- `make build` runs `npm install && npx vite build` inside `libs/tiptap-editor`
|
||||
- `make build` runs `npm ci --include=dev && npm run build` inside `libs/tiptap-editor`; the `build` script is `tsc --noEmit && vite build` (Vite 8 / Rolldown, type-check before bundle)
|
||||
- Unit tests: `npm test` (Vitest 4 + happy-dom), covering `UploadCoordinator` counts/lifecycle, `UploadImageNodeView` rendering/callbacks, and `isValidUrl`
|
||||
- `src/pages/admin/write.rs` initializes via `src/tiptap_bridge.rs` (wasm-bindgen bindings): injects `Closure` callbacks (`onUpdate`/`onReady`/`onUploadEvent`/`onImageUpload`) into `TiptapEditor.create`, holds the instance + closures in `EditorHandle` (Drop calls `destroy()`). No `js_sys::eval`, no `window` globals, no polling.
|
||||
|
||||
Do not edit `public/tiptap/` — they are build artifacts.
|
||||
@ -149,7 +150,7 @@ Do not edit `public/tiptap/` — they are build artifacts.
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cargo test # standard Rust test suite
|
||||
make test # cargo test (Rust, 392 tests) + vitest (editor lib, 46 tests)
|
||||
dx check # Dioxus type-check (catches component/Router issues)
|
||||
cargo clippy # lint
|
||||
```
|
||||
|
||||
1
Makefile
1
Makefile
@ -44,6 +44,7 @@ css-watch:
|
||||
|
||||
test:
|
||||
@cargo test
|
||||
@cd libs/tiptap-editor && npm test
|
||||
|
||||
clean:
|
||||
@cargo clean
|
||||
|
||||
2138
libs/tiptap-editor/package-lock.json
generated
2138
libs/tiptap-editor/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,22 +4,28 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.25.0",
|
||||
"@tiptap/extension-file-handler": "^3.25.0",
|
||||
"@tiptap/extension-image": "^3.25.0",
|
||||
"@tiptap/extension-link": "^3.25.0",
|
||||
"@tiptap/extension-list": "^3.25.0",
|
||||
"@tiptap/extension-table": "^3.25.0",
|
||||
"@tiptap/markdown": "^3.25.0",
|
||||
"@tiptap/pm": "^3.25.0",
|
||||
"@tiptap/starter-kit": "^3.25.0",
|
||||
"@tiptap/suggestion": "^3.25.0"
|
||||
"@tiptap/core": "^3.27.1",
|
||||
"@tiptap/extension-file-handler": "^3.27.1",
|
||||
"@tiptap/extension-image": "^3.27.1",
|
||||
"@tiptap/extension-link": "^3.27.1",
|
||||
"@tiptap/extension-list": "^3.27.1",
|
||||
"@tiptap/extension-table": "^3.27.1",
|
||||
"@tiptap/markdown": "^3.27.1",
|
||||
"@tiptap/pm": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"@tiptap/suggestion": "^3.27.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.4.20"
|
||||
"happy-dom": "^20.10.6",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.1.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
56
libs/tiptap-editor/src/__tests__/slash-command.test.ts
Normal file
56
libs/tiptap-editor/src/__tests__/slash-command.test.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { isValidUrl } from '../slash-command'
|
||||
|
||||
/**
|
||||
* isValidUrl 纯函数测试。
|
||||
*
|
||||
* 只允许 http(s):// 和 data:image/ 开头,拒绝 javascript: 等危险或非图片 scheme。
|
||||
*/
|
||||
|
||||
describe('isValidUrl', () => {
|
||||
describe('接受', () => {
|
||||
it.each([
|
||||
['http://example.com', 'http 协议'],
|
||||
['https://example.com', 'https 协议'],
|
||||
['https://example.com/path?q=1#frag', '带路径/查询/片段'],
|
||||
['HTTP://EXAMPLE.COM', 'http 大写(大小写不敏感)'],
|
||||
['Https://Example.Com', '混合大小写'],
|
||||
['http://localhost:3000/img.png', 'localhost + 端口'],
|
||||
['data:image/png;base64,iVBORw0KGgo=', 'data URL png'],
|
||||
['data:image/jpeg;base64,/9j/4AAQ', 'data URL jpeg'],
|
||||
['data:image/svg+xml,<svg/>', 'data URL svg'],
|
||||
['data:image/', 'data:image 前缀(最小匹配)'],
|
||||
])('%s (%s)', (url) => {
|
||||
expect(isValidUrl(url)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('拒绝', () => {
|
||||
it.each([
|
||||
['javascript:alert(1)', 'javascript scheme(XSS 风险)'],
|
||||
['JavaScript:alert(1)', 'javascript 大写'],
|
||||
['ftp://example.com/file', 'ftp scheme'],
|
||||
['file:///etc/passwd', 'file scheme'],
|
||||
['mailto:foo@bar.com', 'mailto scheme'],
|
||||
['data:text/html,<script>', '非 image 的 data URL'],
|
||||
['data:application/octet-stream,', '非 image 的 data URL'],
|
||||
['//example.com/img.png', '协议相对 URL(无 scheme)'],
|
||||
['/absolute/path/img.png', '绝对路径'],
|
||||
['relative/path/img.png', '相对路径'],
|
||||
['./img.png', './ 相对'],
|
||||
['example.com/img.png', '无 scheme 的域名'],
|
||||
['httpfoo://x', 'http 前缀但非 http scheme'],
|
||||
[' https://example.com', '前导空格(^ 锚定,不匹配)'],
|
||||
['', '空字符串'],
|
||||
])('%s (%s)', (url) => {
|
||||
expect(isValidUrl(url)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('正则边界(scheme 前缀校验,不锚定结尾)', () => {
|
||||
it('https:// 后含末尾空格仍被接受(只校验前缀)', () => {
|
||||
// 有意为之:isValidUrl 只校验 scheme 前缀,URL 其余部分由浏览器/服务端校验
|
||||
expect(isValidUrl('https://example.com ')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
293
libs/tiptap-editor/src/__tests__/upload-coordinator.test.ts
Normal file
293
libs/tiptap-editor/src/__tests__/upload-coordinator.test.ts
Normal file
@ -0,0 +1,293 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { UploadCoordinator, type UploadEvent } from '../upload-coordinator'
|
||||
|
||||
/**
|
||||
* Coordinator 单元测试。
|
||||
*
|
||||
* 用 plain-object mock editor 覆盖:
|
||||
* - 计数正确性(insert / 成功 / 失败 / retry / destroy)
|
||||
* - blob URL 的 createObjectURL / revokeObjectURL 配对
|
||||
* - emit 事件载荷(kind / counts / errorMsg)
|
||||
* - 幂等性(removeUpload 后 handleNodeDestroyed 不二次减计数)
|
||||
*
|
||||
* 不依赖真实 Tiptap Editor / ProseMirror schema —— coordinator 对 editor 的访问
|
||||
* 全是字符串 key(node.type.name / node.attrs[...]),用 plain object 即可精确 stub。
|
||||
*/
|
||||
|
||||
// ---- fake 节点:模拟 ProseMirror Node 的最小形状 ----
|
||||
interface FakeNode {
|
||||
type: { name: string }
|
||||
attrs: Record<string, unknown>
|
||||
nodeSize: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 mock editor。
|
||||
* nodes 数组模拟文档里的 image 节点,供 descendants / findImageNodeByUploadId 遍历。
|
||||
* insertUploading 会通过 chain.insertContentAt 把新节点 push 进来(测试里手动驱动)。
|
||||
*/
|
||||
function makeMockEditor(initialNodes: FakeNode[] = []) {
|
||||
const nodes: FakeNode[] = [...initialNodes]
|
||||
const dispatched: unknown[] = []
|
||||
|
||||
// setNodeMarkup / delete 调用后,把 attrs 变更同步回 nodes(让后续 descendants 反映新状态)
|
||||
const applyMarkup = (pos: number, attrs: Record<string, unknown>) => {
|
||||
if (nodes[pos]) nodes[pos] = { ...nodes[pos], attrs: { ...nodes[pos].attrs, ...attrs } }
|
||||
}
|
||||
|
||||
const editor = {
|
||||
state: {
|
||||
selection: { head: 0 },
|
||||
doc: {
|
||||
descendants(cb: (node: FakeNode, pos: number) => boolean | void) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const descend = cb(nodes[i], i)
|
||||
if (descend === false) break
|
||||
}
|
||||
},
|
||||
},
|
||||
tr: {
|
||||
delete(from: number, to: number) {
|
||||
const marker = { op: 'delete', from, to }
|
||||
nodes.splice(from, to - from)
|
||||
return marker
|
||||
},
|
||||
setNodeMarkup(pos: number, _type: unknown, attrs: Record<string, unknown>) {
|
||||
applyMarkup(pos, attrs)
|
||||
return { op: 'setNodeMarkup', pos, attrs }
|
||||
},
|
||||
},
|
||||
},
|
||||
view: {
|
||||
dispatch(tr: unknown) {
|
||||
dispatched.push(tr)
|
||||
},
|
||||
},
|
||||
chain() {
|
||||
const chain = {
|
||||
_inserted: null as { pos: number; content: { type: string; attrs: Record<string, unknown> } } | null,
|
||||
focus() {
|
||||
return chain
|
||||
},
|
||||
insertContentAt(pos: number, content: { type: string; attrs: Record<string,unknown> }) {
|
||||
// 把插入的节点追加到 nodes,让后续 descendants 能找到它
|
||||
nodes.push({ type: { name: content.type }, nodeSize: 1, attrs: content.attrs })
|
||||
chain._inserted = { pos, content }
|
||||
return chain
|
||||
},
|
||||
run() {},
|
||||
}
|
||||
return chain
|
||||
},
|
||||
_nodes: nodes,
|
||||
_dispatched: dispatched,
|
||||
}
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
function makeFile(name = 'test.png'): File {
|
||||
return new File(['fake-bytes'], name, { type: 'image/png' })
|
||||
}
|
||||
|
||||
describe('UploadCoordinator', () => {
|
||||
let createObjectURLSpy: ReturnType<typeof vi.spyOn>
|
||||
let revokeObjectURLSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url')
|
||||
revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1700000000000)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('insert 后 uploading 计数 +1,pending 存在,节点插入文档', () => {
|
||||
const editor = makeMockEditor()
|
||||
const upload = vi.fn().mockReturnValue(new Promise(() => {})) // 永不 resolve,停在 uploading
|
||||
const emit = vi.fn()
|
||||
const coord = new UploadCoordinator(editor as never, upload, emit)
|
||||
|
||||
coord.insertUploading(makeFile())
|
||||
|
||||
// insertUploading 本身不 emit(runUpload 还在 await);counts 只能通过后续 emit 观察
|
||||
expect(emit).not.toHaveBeenCalled()
|
||||
expect(createObjectURLSpy).toHaveBeenCalledTimes(1)
|
||||
// 节点已插入文档,处于 uploading 态
|
||||
const inserted = (editor as never as { _nodes: FakeNode[] })._nodes
|
||||
expect(inserted).toHaveLength(1)
|
||||
expect(inserted[0].attrs['data-upload-state']).toBe('uploading')
|
||||
expect(inserted[0].attrs['data-upload-id']).not.toBeNull()
|
||||
expect(upload).toHaveBeenCalledTimes(1)
|
||||
// pending 有一条(runUpload 未完成)
|
||||
expect(coord.hasPending(inserted[0].attrs['data-upload-id'] as string)).toBe(true)
|
||||
})
|
||||
|
||||
it('上传成功:counts 归 0,emit success,blob 被 revoke,pending 清除', async () => {
|
||||
const editor = makeMockEditor()
|
||||
const upload = vi.fn().mockResolvedValue('https://cdn.example.com/final.png')
|
||||
const emit = vi.fn()
|
||||
const coord = new UploadCoordinator(editor as never, upload, emit)
|
||||
|
||||
coord.insertUploading(makeFile())
|
||||
// runUpload 是 fire-and-forget,await 一次微任务让它完成
|
||||
await Promise.resolve()
|
||||
|
||||
expect(emit).toHaveBeenCalledTimes(1)
|
||||
const ev = emit.mock.calls[0][0] as UploadEvent
|
||||
expect(ev.kind).toBe('success')
|
||||
expect(ev.fileName).toBe('test.png')
|
||||
expect(ev.counts).toEqual({ uploading: 0, error: 0 })
|
||||
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledTimes(1)
|
||||
// 成功后节点 src 应更新为最终 URL,upload 属性被清空
|
||||
const node = (editor as never as { _nodes: FakeNode[] })._nodes[0]
|
||||
expect(node.attrs.src).toBe('https://cdn.example.com/final.png')
|
||||
expect(node.attrs['data-upload-id']).toBeNull()
|
||||
// pending 已清
|
||||
expect(coord.hasPending(ev.uploadId)).toBe(false)
|
||||
})
|
||||
|
||||
it('上传失败:counts 转 error=1,emit error 带 errorMsg,entry 转 error 态', async () => {
|
||||
const editor = makeMockEditor()
|
||||
const upload = vi.fn().mockRejectedValue(new Error('文件超过大小限制'))
|
||||
const emit = vi.fn()
|
||||
const coord = new UploadCoordinator(editor as never, upload, emit)
|
||||
|
||||
coord.insertUploading(makeFile())
|
||||
await Promise.resolve()
|
||||
|
||||
const ev = emit.mock.calls[0][0] as UploadEvent
|
||||
expect(ev.kind).toBe('error')
|
||||
expect(ev.errorMsg).toBe('文件超过大小限制')
|
||||
expect(ev.counts).toEqual({ uploading: 0, error: 1 })
|
||||
|
||||
// 节点转 error 态
|
||||
const node = (editor as never as { _nodes: FakeNode[] })._nodes[0]
|
||||
expect(node.attrs['data-upload-state']).toBe('error')
|
||||
expect(node.attrs['data-error-msg']).toBe('文件超过大小限制')
|
||||
// pending 仍在(失败保留供重试)
|
||||
expect(coord.hasPending(ev.uploadId)).toBe(true)
|
||||
})
|
||||
|
||||
it('重试:error 转 uploading,再次成功后归 0', async () => {
|
||||
const editor = makeMockEditor()
|
||||
// 第一次失败,第二次成功
|
||||
const upload = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('网络错误'))
|
||||
.mockResolvedValueOnce('https://cdn.example.com/retry.png')
|
||||
const emit = vi.fn()
|
||||
const coord = new UploadCoordinator(editor as never, upload, emit)
|
||||
|
||||
coord.insertUploading(makeFile())
|
||||
await Promise.resolve() // 第一次:失败
|
||||
const uploadId = (emit.mock.calls[0][0] as UploadEvent).uploadId
|
||||
expect((emit.mock.calls[0][0] as UploadEvent).counts).toEqual({ uploading: 0, error: 1 })
|
||||
|
||||
coord.retryUpload(uploadId)
|
||||
// retry 同步调 updateNodeAttrs(不 emit),然后 fire-and-forget runUpload
|
||||
expect(upload).toHaveBeenCalledTimes(2)
|
||||
await Promise.resolve() // 第二次:成功
|
||||
|
||||
const successEv = emit.mock.calls[emit.mock.calls.length - 1][0] as UploadEvent
|
||||
expect(successEv.kind).toBe('success')
|
||||
expect(successEv.counts).toEqual({ uploading: 0, error: 0 })
|
||||
expect(coord.hasPending(uploadId)).toBe(false)
|
||||
})
|
||||
|
||||
it('removeUpload(点按钮):按 state 减计数,emit removed,返回 true/false', async () => {
|
||||
const editor = makeMockEditor()
|
||||
const upload = vi.fn().mockRejectedValue(new Error('fail')) // 让它停在 error 态
|
||||
const emit = vi.fn()
|
||||
const coord = new UploadCoordinator(editor as never, upload, emit)
|
||||
|
||||
coord.insertUploading(makeFile())
|
||||
await Promise.resolve() // 失败,error=1
|
||||
const uploadId = (emit.mock.calls[0][0] as UploadEvent).uploadId
|
||||
|
||||
const result = coord.removeUpload(uploadId)
|
||||
expect(result).toBe(true)
|
||||
|
||||
const removedEv = emit.mock.calls[emit.mock.calls.length - 1][0] as UploadEvent
|
||||
expect(removedEv.kind).toBe('removed')
|
||||
expect(removedEv.counts).toEqual({ uploading: 0, error: 0 })
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalled()
|
||||
|
||||
// 不存在的 id 返回 false
|
||||
expect(coord.removeUpload('nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('handleNodeDestroyed(退格):revoke blob + 减计数 + emit removed', async () => {
|
||||
const editor = makeMockEditor()
|
||||
const upload = vi.fn().mockRejectedValue(new Error('fail'))
|
||||
const emit = vi.fn()
|
||||
const coord = new UploadCoordinator(editor as never, upload, emit)
|
||||
|
||||
coord.insertUploading(makeFile())
|
||||
await Promise.resolve() // 失败,error=1
|
||||
const uploadId = (emit.mock.calls[0][0] as UploadEvent).uploadId
|
||||
|
||||
coord.handleNodeDestroyed(uploadId)
|
||||
|
||||
const ev = emit.mock.calls[emit.mock.calls.length - 1][0] as UploadEvent
|
||||
expect(ev.kind).toBe('removed')
|
||||
expect(ev.counts).toEqual({ uploading: 0, error: 0 })
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handleNodeDestroyed 对已成功的 entry 是 no-op', async () => {
|
||||
const editor = makeMockEditor()
|
||||
const upload = vi.fn().mockResolvedValue('https://cdn.example.com/x.png')
|
||||
const emit = vi.fn()
|
||||
const coord = new UploadCoordinator(editor as never, upload, emit)
|
||||
|
||||
coord.insertUploading(makeFile())
|
||||
await Promise.resolve() // 成功,pending 已清
|
||||
const uploadId = (emit.mock.calls[0][0] as UploadEvent).uploadId
|
||||
|
||||
const emitCountBefore = emit.mock.calls.length
|
||||
coord.handleNodeDestroyed(uploadId)
|
||||
// 成功的 entry 不在 pending,no-op:不 emit,不减计数
|
||||
expect(emit.mock.calls.length).toBe(emitCountBefore)
|
||||
})
|
||||
|
||||
it('幂等:removeUpload 后再 handleNodeDestroyed 同 id 不二次减计数', async () => {
|
||||
const editor = makeMockEditor()
|
||||
const upload = vi.fn().mockRejectedValue(new Error('fail'))
|
||||
const emit = vi.fn()
|
||||
const coord = new UploadCoordinator(editor as never, upload, emit)
|
||||
|
||||
coord.insertUploading(makeFile())
|
||||
await Promise.resolve()
|
||||
const uploadId = (emit.mock.calls[0][0] as UploadEvent).uploadId
|
||||
|
||||
coord.removeUpload(uploadId)
|
||||
expect((emit.mock.calls[emit.mock.calls.length - 1][0] as UploadEvent).counts).toEqual({ uploading: 0, error: 0 })
|
||||
|
||||
const callsBefore = emit.mock.calls.length
|
||||
coord.handleNodeDestroyed(uploadId) // pending 已删,no-op
|
||||
expect(emit.mock.calls.length).toBe(callsBefore)
|
||||
})
|
||||
|
||||
it('多图并发:计数随每张的状态独立维护', async () => {
|
||||
const editor = makeMockEditor()
|
||||
// 三张图:一张立即成功,一张失败,一张永远 uploading
|
||||
const upload = vi.fn()
|
||||
.mockResolvedValueOnce('https://cdn.example.com/1.png')
|
||||
.mockRejectedValueOnce(new Error('fail'))
|
||||
.mockReturnValueOnce(new Promise(() => {}))
|
||||
const emit = vi.fn()
|
||||
const coord = new UploadCoordinator(editor as never, upload, emit)
|
||||
|
||||
coord.insertUploading(makeFile('1.png'))
|
||||
coord.insertUploading(makeFile('2.png'))
|
||||
coord.insertUploading(makeFile('3.png'))
|
||||
await Promise.resolve() // 让前两张 settle(第三张永不 resolve)
|
||||
|
||||
// 最终态:uploading=1(第三张), error=1(第二张)
|
||||
const lastEv = emit.mock.calls[emit.mock.calls.length - 1][0] as UploadEvent
|
||||
expect(lastEv.counts).toEqual({ uploading: 1, error: 1 })
|
||||
})
|
||||
})
|
||||
199
libs/tiptap-editor/src/__tests__/upload-image.test.ts
Normal file
199
libs/tiptap-editor/src/__tests__/upload-image.test.ts
Normal file
@ -0,0 +1,199 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { UploadImageNodeView, type UploadNodeViewCallbacks } from '../upload-image'
|
||||
|
||||
/**
|
||||
* UploadImageNodeView 单元测试(happy-dom 真实 DOM)。
|
||||
*
|
||||
* 构造 fake PMNode(plain object)+ 真 happy-dom document,
|
||||
* 断言三种态(uploading/error/null)的渲染 DOM、按钮 click 触发回调、destroy 调 onDestroyed。
|
||||
*
|
||||
* NodeView 不依赖 editor —— 它只读 node.attrs + 操作 DOM,callbacks 由调用方注入。
|
||||
*/
|
||||
|
||||
// 共用的 type sentinel:update() 用 === 比较 node.type,新旧 node 共用同一引用
|
||||
const IMAGE_TYPE = { name: 'image' }
|
||||
|
||||
function makeNode(attrs: {
|
||||
src?: string
|
||||
'data-upload-state'?: 'uploading' | 'error' | null
|
||||
'data-upload-id'?: string | null
|
||||
'data-error-msg'?: string | null
|
||||
}) {
|
||||
return {
|
||||
type: IMAGE_TYPE,
|
||||
attrs: {
|
||||
src: attrs.src ?? 'https://example.com/img.png',
|
||||
// 用 in 判断而非 ??,让显式传入的 null 生效(?? 会把 null 当缺失)
|
||||
'data-upload-state': 'data-upload-state' in attrs ? attrs['data-upload-state']! : null,
|
||||
'data-upload-id': 'data-upload-id' in attrs ? attrs['data-upload-id']! : 'upload-123',
|
||||
'data-error-msg': 'data-error-msg' in attrs ? attrs['data-error-msg']! : null,
|
||||
},
|
||||
} as never // PMNode 类型注解是编译期的,运行时只需这个形状
|
||||
}
|
||||
|
||||
function makeCallbacks(): UploadNodeViewCallbacks {
|
||||
return {
|
||||
onRetry: vi.fn<(id: string) => void>(),
|
||||
onRemove: vi.fn<(id: string) => void>(),
|
||||
onDestroyed: vi.fn<(id: string) => void>(),
|
||||
}
|
||||
}
|
||||
|
||||
describe('UploadImageNodeView', () => {
|
||||
describe('rendering by state', () => {
|
||||
it('uploading 态:渲染 spinner + "上传中…",容器有 is-uploading', () => {
|
||||
const view = new UploadImageNodeView({
|
||||
node: makeNode({ 'data-upload-state': 'uploading', 'data-upload-id': 'u1' }),
|
||||
HTMLAttributes: {},
|
||||
callbacks: makeCallbacks(),
|
||||
})
|
||||
|
||||
const dom = view.dom
|
||||
expect(dom.classList.contains('is-uploading')).toBe(true)
|
||||
expect(dom.classList.contains('is-error')).toBe(false)
|
||||
expect(dom.querySelector('.upload-spinner')).not.toBeNull()
|
||||
expect(dom.querySelector('.upload-overlay-text')?.textContent).toBe('上传中…')
|
||||
})
|
||||
|
||||
it('error 态:渲染错误图标 + msg + 重试/移除按钮,容器有 is-error', () => {
|
||||
const view = new UploadImageNodeView({
|
||||
node: makeNode({
|
||||
'data-upload-state': 'error',
|
||||
'data-upload-id': 'u2',
|
||||
'data-error-msg': '文件超过大小限制',
|
||||
}),
|
||||
HTMLAttributes: {},
|
||||
callbacks: makeCallbacks(),
|
||||
})
|
||||
|
||||
const dom = view.dom
|
||||
expect(dom.classList.contains('is-error')).toBe(true)
|
||||
expect(dom.classList.contains('is-uploading')).toBe(false)
|
||||
expect(dom.querySelector('.upload-error-icon')).not.toBeNull()
|
||||
expect(dom.querySelector('.upload-error-msg')?.textContent).toBe('文件超过大小限制')
|
||||
expect(dom.querySelector('.upload-btn-retry')).not.toBeNull()
|
||||
expect(dom.querySelector('.upload-btn-remove')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('error 态缺 msg:fallback 显示"上传失败"', () => {
|
||||
const view = new UploadImageNodeView({
|
||||
node: makeNode({ 'data-upload-state': 'error', 'data-upload-id': 'u3', 'data-error-msg': null }),
|
||||
HTMLAttributes: {},
|
||||
callbacks: makeCallbacks(),
|
||||
})
|
||||
|
||||
expect(view.dom.querySelector('.upload-error-msg')?.textContent).toBe('上传失败')
|
||||
})
|
||||
|
||||
it('null 态(普通图):无 overlay,无 is-uploading/is-error class', () => {
|
||||
const view = new UploadImageNodeView({
|
||||
node: makeNode({ 'data-upload-state': null }),
|
||||
HTMLAttributes: {},
|
||||
callbacks: makeCallbacks(),
|
||||
})
|
||||
|
||||
const dom = view.dom
|
||||
expect(dom.querySelector('.upload-image-overlay')).toBeNull()
|
||||
expect(dom.classList.contains('is-uploading')).toBe(false)
|
||||
expect(dom.classList.contains('is-error')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update()', () => {
|
||||
it('切换态(uploading → error):overlay 重新渲染', () => {
|
||||
const view = new UploadImageNodeView({
|
||||
node: makeNode({ 'data-upload-state': 'uploading', 'data-upload-id': 'u4' }),
|
||||
HTMLAttributes: {},
|
||||
callbacks: makeCallbacks(),
|
||||
})
|
||||
expect(view.dom.querySelector('.upload-spinner')).not.toBeNull()
|
||||
|
||||
const ok = view.update(makeNode({
|
||||
'data-upload-state': 'error',
|
||||
'data-upload-id': 'u4',
|
||||
'data-error-msg': '失败',
|
||||
}) as never)
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(view.dom.querySelector('.upload-spinner')).toBeNull()
|
||||
expect(view.dom.querySelector('.upload-error-icon')).not.toBeNull()
|
||||
expect(view.dom.classList.contains('is-uploading')).toBe(false)
|
||||
expect(view.dom.classList.contains('is-error')).toBe(true)
|
||||
})
|
||||
|
||||
it('拒绝非同类 node:返回 false', () => {
|
||||
const view = new UploadImageNodeView({
|
||||
node: makeNode({ 'data-upload-state': null }),
|
||||
HTMLAttributes: {},
|
||||
callbacks: makeCallbacks(),
|
||||
})
|
||||
|
||||
const ok = view.update({ type: { name: 'paragraph' }, attrs: {} } as never)
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
it('src 变化时更新 img.src', () => {
|
||||
const view = new UploadImageNodeView({
|
||||
node: makeNode({ src: 'old.png', 'data-upload-state': null }),
|
||||
HTMLAttributes: {},
|
||||
callbacks: makeCallbacks(),
|
||||
})
|
||||
expect(view.dom.querySelector('img')?.getAttribute('src')).toBe('old.png')
|
||||
|
||||
view.update(makeNode({ src: 'new.png', 'data-upload-state': null }) as never)
|
||||
expect(view.dom.querySelector('img')?.getAttribute('src')).toBe('new.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('button callbacks', () => {
|
||||
it('点击"重试"按钮触发 onRetry(uploadId)', () => {
|
||||
const callbacks = makeCallbacks()
|
||||
const view = new UploadImageNodeView({
|
||||
node: makeNode({ 'data-upload-state': 'error', 'data-upload-id': 'retry-me', 'data-error-msg': 'x' }),
|
||||
HTMLAttributes: {},
|
||||
callbacks,
|
||||
})
|
||||
|
||||
view.dom.querySelector('.upload-btn-retry')!.dispatchEvent(new Event('click', { bubbles: true }))
|
||||
expect(callbacks.onRetry).toHaveBeenCalledWith('retry-me')
|
||||
})
|
||||
|
||||
it('点击"移除"按钮触发 onRemove(uploadId)', () => {
|
||||
const callbacks = makeCallbacks()
|
||||
const view = new UploadImageNodeView({
|
||||
node: makeNode({ 'data-upload-state': 'error', 'data-upload-id': 'remove-me', 'data-error-msg': 'x' }),
|
||||
HTMLAttributes: {},
|
||||
callbacks,
|
||||
})
|
||||
|
||||
view.dom.querySelector('.upload-btn-remove')!.dispatchEvent(new Event('click', { bubbles: true }))
|
||||
expect(callbacks.onRemove).toHaveBeenCalledWith('remove-me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('destroy()', () => {
|
||||
it('调用 onDestroyed(uploadId) —— 兜底清理 blob 的接线点', () => {
|
||||
const callbacks = makeCallbacks()
|
||||
const view = new UploadImageNodeView({
|
||||
node: makeNode({ 'data-upload-state': 'error', 'data-upload-id': 'destroy-me', 'data-error-msg': 'x' }),
|
||||
HTMLAttributes: {},
|
||||
callbacks,
|
||||
})
|
||||
|
||||
view.destroy()
|
||||
expect(callbacks.onDestroyed).toHaveBeenCalledWith('destroy-me')
|
||||
})
|
||||
|
||||
it('uploadId 为 null 时 destroy 不调 onDestroyed(普通图无上传状态)', () => {
|
||||
const callbacks = makeCallbacks()
|
||||
const view = new UploadImageNodeView({
|
||||
node: makeNode({ 'data-upload-state': null, 'data-upload-id': null }),
|
||||
HTMLAttributes: {},
|
||||
callbacks,
|
||||
})
|
||||
|
||||
view.destroy()
|
||||
expect(callbacks.onDestroyed).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -5,11 +5,20 @@ import { TableKit } from '@tiptap/extension-table'
|
||||
import { TaskList, TaskItem } from '@tiptap/extension-list'
|
||||
import { FileHandler } from '@tiptap/extension-file-handler'
|
||||
import { SlashCommand } from './slash-command'
|
||||
import { UploadCoordinator, type UploadEvent } from './upload-coordinator'
|
||||
import { UploadImage, setUploadCoordinator } from './upload-image'
|
||||
import { UploadCoordinator, UPLOAD_COORDINATOR_STORAGE_KEY, type UploadEvent } from './upload-coordinator'
|
||||
import { UploadImage } from './upload-image'
|
||||
import './style.css'
|
||||
|
||||
export interface EditorOptions {
|
||||
/**
|
||||
* 编辑器选项。用 class 而非 interface,使其编译后保留运行时构造函数——
|
||||
* Rust 侧 wasm-bindgen 的 `#[wasm_bindgen(constructor)]` 会生成 `new EditorOptions()`,
|
||||
* 需要一个全局可访问的构造函数(interface 会被 TS 擦除)。
|
||||
* 字段全部可选,Rust 通过 setter(set_placeholder/set_on_update/...)逐个赋值。
|
||||
*
|
||||
* 不用 export(避免 IIFE 的 named export 与 default 冲突),而是挂到 globalThis,
|
||||
* 让 wasm-bindgen glue 里的裸标识符 `EditorOptions` 能解析到。
|
||||
*/
|
||||
class EditorOptions {
|
||||
content?: string
|
||||
placeholder?: string
|
||||
onUpdate?: (markdown: string) => void
|
||||
@ -24,17 +33,21 @@ export interface EditorOptions {
|
||||
onUploadEvent?: (event: UploadEvent) => void
|
||||
}
|
||||
|
||||
// wasm-bindgen 生成的 glue 用裸标识符 `new EditorOptions()` 从全局解析,
|
||||
// IIFE 的 name 只能挂一个全局(TiptapEditor),这里手动把 EditorOptions 也挂到 window 上。
|
||||
;(window as unknown as Record<string, unknown>).EditorOptions = EditorOptions
|
||||
|
||||
class TiptapEditorInstance {
|
||||
private editor: Editor | null = null
|
||||
private container: HTMLElement
|
||||
private options: EditorOptions
|
||||
// 源码模式相关状态
|
||||
|
||||
private isSourceMode = false
|
||||
private sourceTextarea: HTMLTextAreaElement | null = null
|
||||
private toggleButton: HTMLButtonElement | null = null
|
||||
private coordinator: UploadCoordinator | null = null
|
||||
|
||||
constructor(container: HTMLElement, options: EditorOptions = {}) {
|
||||
constructor(container: HTMLElement, options: EditorOptions = new EditorOptions()) {
|
||||
this.container = container
|
||||
this.options = options
|
||||
this.init()
|
||||
@ -82,9 +95,7 @@ class TiptapEditorInstance {
|
||||
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
||||
},
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: false,
|
||||
}),
|
||||
Markdown,
|
||||
TableKit,
|
||||
UploadImage,
|
||||
TaskList,
|
||||
@ -131,15 +142,16 @@ class TiptapEditorInstance {
|
||||
},
|
||||
})
|
||||
|
||||
// 创建上传协调器,注入给 NodeView 的 onRetry/onRemove
|
||||
// onUploadEvent 透传给 coordinator.emit,未提供时空操作兜底
|
||||
// 创建上传协调器,挂到 editor.storage 供 NodeView 按实例读取(支持多编辑器实例)。
|
||||
// onUploadEvent 透传给 coordinator.emit,未提供时空操作兜底。
|
||||
if (this.options.onImageUpload) {
|
||||
this.coordinator = new UploadCoordinator(
|
||||
this.editor,
|
||||
this.options.onImageUpload,
|
||||
this.options.onUploadEvent ?? (() => {}),
|
||||
)
|
||||
setUploadCoordinator(this.coordinator)
|
||||
// editor.storage 是开放式索引签名;自定义 key 需绕过严格检查(Tiptap 官方扩展 storage 模式)。
|
||||
;(this.editor.storage as unknown as Record<string, unknown>)[UPLOAD_COORDINATOR_STORAGE_KEY] = this.coordinator
|
||||
}
|
||||
|
||||
// 通知宿主编辑器已就绪(替代 window.__tiptap_ready 轮询)
|
||||
@ -245,9 +257,8 @@ class TiptapEditorInstance {
|
||||
destroy(): void {
|
||||
this.editor?.destroy()
|
||||
this.editor = null
|
||||
// coordinator 通过 editor.storage 访问,随 editor 实例一同回收,无需显式清除引用。
|
||||
this.coordinator = null
|
||||
// 清除 NodeView 的 coordinator 引用,避免指向已销毁的实例
|
||||
setUploadCoordinator(null)
|
||||
// 清理源码模式相关引用(容器 innerHTML 已清空,DOM 会随之移除)
|
||||
this.sourceTextarea = null
|
||||
this.toggleButton = null
|
||||
@ -259,7 +270,7 @@ class TiptapEditorInstance {
|
||||
const TiptapEditor = {
|
||||
_instances: new Map<string, TiptapEditorInstance>(),
|
||||
|
||||
create(containerId: string, options: EditorOptions = {}): TiptapEditorInstance | null {
|
||||
create(containerId: string, options: EditorOptions = new EditorOptions()): TiptapEditorInstance | null {
|
||||
const container = document.getElementById(containerId)
|
||||
if (!container) {
|
||||
console.error(`[TiptapEditor] Container not found: #${containerId}`)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Extension, type Range } from '@tiptap/core'
|
||||
import { Extension, type Editor, type Range } from '@tiptap/core'
|
||||
import { Suggestion, type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion'
|
||||
import { PluginKey } from '@tiptap/pm/state'
|
||||
|
||||
@ -6,7 +6,7 @@ interface CommandItem {
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
command: (props: { editor: any; range: Range }) => void
|
||||
command: (props: { editor: Editor; range: Range }) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@ -167,7 +167,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
|
||||
icon: '🖼',
|
||||
command: ({ editor, range }) => {
|
||||
const url = window.prompt('输入图片 URL')
|
||||
if (url) {
|
||||
if (url && isValidUrl(url)) {
|
||||
editor.chain().focus().deleteRange(range).setImage({ src: url }).run()
|
||||
}
|
||||
},
|
||||
@ -178,9 +178,17 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
|
||||
icon: '🔗',
|
||||
command: ({ editor, range }) => {
|
||||
const url = window.prompt('输入链接 URL')
|
||||
if (url) {
|
||||
editor.chain().focus().deleteRange(range).setLink({ href: url }).insertContent(url).run()
|
||||
}
|
||||
if (!url || !isValidUrl(url)) return
|
||||
// deleteRange 后光标停在 range.to;先插入 URL 文本,再选中刚插入的范围设 link
|
||||
// (setLink 需要非空选区才生效,原顺序 setLink 在空选区无效)。
|
||||
const insertFrom = range.to
|
||||
editor.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent(url)
|
||||
.setTextSelection({ from: insertFrom, to: insertFrom + url.length })
|
||||
.setLink({ href: url })
|
||||
.run()
|
||||
},
|
||||
},
|
||||
)
|
||||
@ -198,7 +206,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
|
||||
)
|
||||
},
|
||||
render() {
|
||||
let popup: ReturnType<typeof createPopup> | null = null
|
||||
let popup: SlashPopup | null = null
|
||||
|
||||
return {
|
||||
onStart(props) {
|
||||
@ -229,7 +237,21 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
|
||||
},
|
||||
})
|
||||
|
||||
function createPopup(props: SuggestionProps<CommandItem>) {
|
||||
/** 斜杠命令浮层实例:供 Suggestion render 生命周期驱动。 */
|
||||
interface SlashPopup {
|
||||
component: HTMLElement
|
||||
updateItems(items: CommandItem[]): void
|
||||
updatePosition(): void
|
||||
onKeyDown(props: SuggestionKeyDownProps): boolean
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
/** 校验图片/链接 URL:只允许 http(s) 和 data:image。拒绝 javascript: 等。 */
|
||||
export function isValidUrl(url: string): boolean {
|
||||
return /^https?:\/\//i.test(url) || /^data:image\//i.test(url)
|
||||
}
|
||||
|
||||
function createPopup(props: SuggestionProps<CommandItem>): SlashPopup {
|
||||
const component = document.createElement('div')
|
||||
component.classList.add('slash-command')
|
||||
|
||||
|
||||
@ -1,10 +1,23 @@
|
||||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
/** pending 上传条目:保留 File 供重试,blobUrl 供本地预览。 */
|
||||
/** editor.storage 上挂载 coordinator 的 key。NodeView 通过此 key 拿到当前编辑器实例的 coordinator。 */
|
||||
export const UPLOAD_COORDINATOR_STORAGE_KEY = 'uploadCoordinator'
|
||||
|
||||
/** 可更新的上传相关节点属性。收窄类型,拼写错误可被 TS 捕获。 */
|
||||
type UploadNodeAttrs = Partial<{
|
||||
src: string | null
|
||||
'data-upload-state': 'uploading' | 'error' | null
|
||||
'data-upload-id': string | null
|
||||
'data-error-msg': string | null
|
||||
}>
|
||||
|
||||
/** pending 上传条目:保留 File 供重试,blobUrl 供本地预览,state 跟踪当前态。 */
|
||||
interface UploadEntry {
|
||||
file: File
|
||||
blobUrl: string
|
||||
fileName: string
|
||||
/** 当前态:uploading(进行中)或 error(失败待重试)。成功后整个 entry 删除。 */
|
||||
state: 'uploading' | 'error'
|
||||
}
|
||||
|
||||
/** counts 快照:随事件一起传给宿主,替代 window 全局。 */
|
||||
@ -42,6 +55,9 @@ function genUploadId(): string {
|
||||
*/
|
||||
export class UploadCoordinator {
|
||||
private pending = new Map<string, UploadEntry>()
|
||||
/** 内部计数:替代每次事件全量遍历文档。 */
|
||||
private uploadingCount = 0
|
||||
private errorCount = 0
|
||||
|
||||
constructor(
|
||||
private editor: Editor,
|
||||
@ -57,7 +73,8 @@ export class UploadCoordinator {
|
||||
insertUploading(file: File, pos?: number): void {
|
||||
const uploadId = genUploadId()
|
||||
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, {
|
||||
type: 'image',
|
||||
@ -77,11 +94,32 @@ export class UploadCoordinator {
|
||||
if (!entry) return false
|
||||
this.removeNodeByUploadId(uploadId)
|
||||
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.notifyRust({ kind: 'removed', uploadId, fileName: entry.fileName })
|
||||
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 态。 */
|
||||
private async runUpload(uploadId: string): Promise<void> {
|
||||
const entry = this.pending.get(uploadId)
|
||||
@ -97,6 +135,7 @@ export class UploadCoordinator {
|
||||
})
|
||||
URL.revokeObjectURL(entry.blobUrl)
|
||||
this.pending.delete(uploadId)
|
||||
this.uploadingCount--
|
||||
this.notifyRust({ kind: 'success', uploadId, fileName: entry.fileName })
|
||||
} catch (err) {
|
||||
const msg = this.extractErrorMessage(err)
|
||||
@ -104,6 +143,10 @@ export class UploadCoordinator {
|
||||
'data-upload-state': 'error',
|
||||
'data-error-msg': msg,
|
||||
})
|
||||
// 失败:从 uploading 转 error 态
|
||||
entry.state = 'error'
|
||||
this.uploadingCount--
|
||||
this.errorCount++
|
||||
this.notifyRust({ kind: 'error', uploadId, fileName: entry.fileName, errorMsg: msg })
|
||||
}
|
||||
}
|
||||
@ -112,6 +155,9 @@ export class UploadCoordinator {
|
||||
retryUpload(uploadId: string): void {
|
||||
const entry = this.pending.get(uploadId)
|
||||
if (!entry) return
|
||||
// 重试前节点处于 error 态,转回 uploading
|
||||
this.errorCount--
|
||||
this.uploadingCount++
|
||||
this.updateNodeAttrs(uploadId, {
|
||||
'data-upload-state': 'uploading',
|
||||
'data-error-msg': null,
|
||||
@ -138,25 +184,30 @@ export class UploadCoordinator {
|
||||
}
|
||||
|
||||
/** 按 uploadId 定位节点并合并更新属性。 */
|
||||
private updateNodeAttrs(uploadId: string, attrs: Record<string, unknown>): void {
|
||||
let targetPos: number | null = null
|
||||
let oldAttrs: Record<string, unknown> | null = null
|
||||
private updateNodeAttrs(uploadId: string, attrs: UploadNodeAttrs): void {
|
||||
const found = this.findImageNodeByUploadId(uploadId)
|
||||
if (!found) return
|
||||
const merged: Record<string, unknown> = { ...found.attrs, ...(attrs as Record<string, unknown>) }
|
||||
const tr = this.editor.state.tr.setNodeMarkup(
|
||||
found.pos,
|
||||
undefined,
|
||||
merged,
|
||||
)
|
||||
this.editor.view.dispatch(tr)
|
||||
}
|
||||
|
||||
/** 按 uploadId 查找 image 节点,返回其位置与属性快照(已断言为可 spread 的 plain object)。 */
|
||||
private findImageNodeByUploadId(uploadId: string): { pos: number; attrs: Record<string, unknown> } | null {
|
||||
let result: { pos: number; attrs: 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
|
||||
// 就地 spread:node.attrs 在此作用域内,TS 接受;产出 plain object 供后续合并。
|
||||
result = { pos, attrs: { ...(node.attrs as Record<string, unknown>) } }
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (targetPos !== null && oldAttrs) {
|
||||
const tr = this.editor.state.tr.setNodeMarkup(
|
||||
targetPos,
|
||||
undefined,
|
||||
{ ...oldAttrs, ...attrs },
|
||||
)
|
||||
this.editor.view.dispatch(tr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 从错误对象提取消息。改造后的 fetch 直接抛服务端中文(如"文件超过大小限制")。 */
|
||||
@ -167,18 +218,14 @@ export class UploadCoordinator {
|
||||
|
||||
/**
|
||||
* 通过注入的 emit 回调把事件(含 counts 快照)输出给宿主,
|
||||
* 替代原先写 window.__tiptap_uploads 全局供 Rust 轮询。
|
||||
* counts 直接读内部维护的计数,不再每次遍历文档。
|
||||
*/
|
||||
private notifyRust(event: Omit<UploadEvent, 'ts' | 'counts'>): void {
|
||||
let uploading = 0
|
||||
let error = 0
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
const state = node.attrs['data-upload-state']
|
||||
if (state === 'uploading') uploading++
|
||||
else if (state === 'error') error++
|
||||
return true
|
||||
this.emit({
|
||||
...event,
|
||||
ts: Date.now(),
|
||||
counts: { uploading: this.uploadingCount, error: this.errorCount },
|
||||
})
|
||||
this.emit({ ...event, ts: Date.now(), counts: { uploading, error } })
|
||||
}
|
||||
|
||||
/** pending Map 查询(供内部/测试)。 */
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { Image } from '@tiptap/extension-image'
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import type { Node as PMNode } from '@tiptap/pm/model'
|
||||
import { UPLOAD_COORDINATOR_STORAGE_KEY, type UploadCoordinator } from './upload-coordinator'
|
||||
|
||||
/** NodeView 按钮点击回调注入接口。 */
|
||||
/** NodeView 按钮点击 / destroy 回调注入接口。 */
|
||||
export interface UploadNodeViewCallbacks {
|
||||
onRetry: (uploadId: string) => void
|
||||
onRemove: (uploadId: string) => void
|
||||
/** NodeView.destroy 兜底:节点被 PM 删除时清理 pending + revoke blob。 */
|
||||
onDestroyed: (uploadId: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@ -19,9 +21,10 @@ export interface UploadNodeViewCallbacks {
|
||||
* NodeView 纯渲染,不发起上传——按钮点击转发给注入的 callbacks(实际是 coordinator)。
|
||||
* 属性变化时 ProseMirror 调 update(node),NodeView 比较新旧 data-upload-state 重渲染遮罩。
|
||||
*/
|
||||
class UploadImageNodeView {
|
||||
export class UploadImageNodeView {
|
||||
private node: PMNode
|
||||
private callbacks: UploadNodeViewCallbacks
|
||||
private uploadId: string | null
|
||||
|
||||
private container: HTMLDivElement
|
||||
private img: HTMLImageElement
|
||||
@ -34,14 +37,14 @@ class UploadImageNodeView {
|
||||
}) {
|
||||
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
|
||||
const merged = mergeAttributes(opts.HTMLAttributes)
|
||||
Object.entries(merged).forEach(([k, v]) => {
|
||||
Object.entries(opts.HTMLAttributes).forEach(([k, v]) => {
|
||||
if (v != null) this.img.setAttribute(k, String(v))
|
||||
})
|
||||
const src = this.node.attrs.src
|
||||
@ -67,6 +70,7 @@ class UploadImageNodeView {
|
||||
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
|
||||
}
|
||||
@ -131,20 +135,17 @@ class UploadImageNodeView {
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/** coordinator 引用(由 index.ts 在创建 editor 前注入)。 */
|
||||
let coordinatorRef: { retryUpload: (id: string) => void; removeUpload: (id: string) => boolean } | null = null
|
||||
|
||||
/** index.ts 注入 coordinator,供 NodeView 的 onRetry/onRemove 调用。 */
|
||||
export function setUploadCoordinator(c: typeof coordinatorRef): void {
|
||||
coordinatorRef = c
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义 Image 扩展:继承父类属性,加三个上传状态属性,用自定义 NodeView。
|
||||
*/
|
||||
@ -180,13 +181,15 @@ export const UploadImage = Image.configure({ allowBase64: true }).extend({
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, HTMLAttributes }) => {
|
||||
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) => coordinatorRef?.retryUpload(id),
|
||||
onRemove: (id) => coordinatorRef?.removeUpload(id),
|
||||
onRetry: (id) => coordinator?.retryUpload(id),
|
||||
onRemove: (id) => coordinator?.removeUpload(id),
|
||||
onDestroyed: (id) => coordinator?.handleNodeDestroyed(id),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
1
libs/tiptap-editor/src/vite-env.d.ts
vendored
Normal file
1
libs/tiptap-editor/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
19
libs/tiptap-editor/tsconfig.json
Normal file
19
libs/tiptap-editor/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@ -11,11 +11,10 @@ export default defineConfig({
|
||||
fileName: () => 'editor.js',
|
||||
formats: ['iife'],
|
||||
},
|
||||
rollupOptions: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
exports: 'default',
|
||||
assetFileNames: 'editor.[ext]',
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
cssCodeSplit: false,
|
||||
|
||||
8
libs/tiptap-editor/vitest.config.ts
Normal file
8
libs/tiptap-editor/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@ -35,14 +35,15 @@ pub mod wasm {
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
// —— window.TiptapEditor 模块对象 ——
|
||||
//
|
||||
// TiptapEditor 是 IIFE 产物挂在 window 上的模块对象(含 create 等方法),
|
||||
// 不是函数。wasm-bindgen 对 `fn get_module() -> T` 形式的 extern 会生成
|
||||
// `window.TiptapEditor()`(函数调用),会因"not a function"失败。
|
||||
// 因此用 js_sys::Reflect::get 做属性访问拿到模块对象,再 dyn_into。
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
pub type TiptapEditorModule;
|
||||
|
||||
/// 读取 `window.TiptapEditor`(IIFE 默认导出,顶层 var 即 window 属性)。
|
||||
#[wasm_bindgen(js_namespace = window, js_name = TiptapEditor)]
|
||||
pub fn get_module() -> TiptapEditorModule;
|
||||
|
||||
/// 调用 `TiptapEditor.create(containerId, options)`。
|
||||
/// 找不到容器返回 null(被 Option 捕获);构造失败抛异常(被 catch 捕获)。
|
||||
#[wasm_bindgen(method, catch)]
|
||||
@ -53,6 +54,18 @@ pub mod wasm {
|
||||
) -> Result<Option<EditorInstance>, JsValue>;
|
||||
}
|
||||
|
||||
/// 读取 `window.TiptapEditor`(IIFE 默认导出,顶层 var 即 window 属性)。
|
||||
/// 用 Reflect::get 做属性访问——extern fn 形式会被 wasm-bindgen 编成函数调用。
|
||||
///
|
||||
/// 用 unchecked_into 而非 dyn_into:TiptapEditor 是 JS 对象字面量,
|
||||
/// 不是 wasm-bindgen 注册的构造函数实例,dyn_into 的 instanceof 检查必然失败。
|
||||
/// unchecked_into 只做编译期类型标注,不做运行时校验(Reflect.get 已保证拿到的是目标对象)。
|
||||
pub fn get_module() -> TiptapEditorModule {
|
||||
let window = web_sys::window().expect("no window");
|
||||
let val = js_sys::Reflect::get(&window, &"TiptapEditor".into()).expect("window.TiptapEditor missing");
|
||||
val.unchecked_into::<TiptapEditorModule>()
|
||||
}
|
||||
|
||||
// —— 编辑器实例(TiptapEditorInstance)——
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user