Compare commits

...

21 Commits

Author SHA1 Message Date
xfy
1cc09e4cbc fix(bridge): use unchecked_into for TiptapEditorModule (not dyn_into)
Some checks failed
CI / check (push) Failing after 4m44s
CI / build (push) Has been skipped
dyn_into runs 'instanceof TiptapEditorModule', but the IIFE TiptapEditor
is a plain object literal — not an instance of wasm-bindgen's registered
constructor — so the check always fails with a panic.

unchecked_into does only the compile-time type annotation; Reflect::get
already guarantees we have the right object.
2026-06-24 10:38:33 +08:00
xfy
410d594ac0 fix(bridge): read window.TiptapEditor via Reflect.get, not function call
The extern 'fn get_module() -> TiptapEditorModule' was compiled by
wasm-bindgen to 'window.TiptapEditor()' — a function call. But
TiptapEditor is the IIFE module object, not a function, so this threw
'window.TiptapEditor is not a function'.

Replace with js_sys::Reflect::get(&window, "TiptapEditor") +
dyn_into, which reads the property without invoking it.

This bug predated the Vite 8 upgrade but only surfaced after the
EditorOptions fix let initialization progress further.
2026-06-24 10:35:11 +08:00
xfy
fb31ecb5ae fix(tiptap): expose EditorOptions as runtime class for wasm-bindgen
EditorOptions was a TS interface (erased at runtime), but the Rust
wasm-bindgen extern uses #[wasm_bindgen(constructor)] which generates
'new EditorOptions()' — requiring a global constructor.

Vite 5/Rollup accidentally leaked the name into IIFE global scope,
masking this. Vite 8/Rolldown strictly erases interfaces, exposing it
as 'EditorOptions is not defined'.

Fix: make EditorOptions a class (retains runtime constructor) and mount
it on window, since the IIFE bundle name is taken by TiptapEditor.
2026-06-24 10:27:07 +08:00
xfy
368a651fa6 build: make test runs both cargo test and vitest 2026-06-24 10:14:20 +08:00
xfy
c0af99736d docs: update AGENTS.md for vite 8 build, vitest tests, and tiptap toolchain 2026-06-24 10:12:16 +08:00
xfy
08c2539dfc test(tiptap): add isValidUrl tests covering scheme validation edge cases 2026-06-24 10:10:39 +08:00
xfy
90cff2d0cc test(tiptap): add UploadImageNodeView tests for rendering/update/callbacks/destroy 2026-06-24 10:09:36 +08:00
xfy
f7042d3ab4 test(tiptap): add UploadCoordinator tests for counts/lifecycle/emit/idempotency 2026-06-24 10:07:43 +08:00
xfy
8ff051689f refactor(tiptap): export isValidUrl and UploadImageNodeView for testability 2026-06-24 10:04:59 +08:00
xfy
19532670d3 build(tiptap): add vitest config with happy-dom environment 2026-06-24 10:04:23 +08:00
xfy
21c0359f5e build(tiptap): upgrade to vite 8/vitest 4/typescript 6/tiptap 3.27, migrate to rolldownOptions 2026-06-24 10:03:47 +08:00
xfy
939e0de171 build(tiptap): run tsc --noEmit before vite build 2026-06-23 18:45:17 +08:00
xfy
6ffcd55c94 fix(tiptap): correct link command order + validate URL scheme in slash commands 2026-06-23 18:42:57 +08:00
xfy
e580c3ab18 refactor(tiptap): narrow updateNodeAttrs type, extract findImageNodeByUploadId helper 2026-06-23 18:42:05 +08:00
xfy
d099db4140 refactor(tiptap): move coordinator to editor.storage, drop dead Markdown html option 2026-06-23 18:39:38 +08:00
xfy
6b44a9c036 fix(tiptap): clean up pending upload on NodeView.destroy to prevent blob leak 2026-06-23 18:36:47 +08:00
xfy
2e239908a4 fix(tiptap): revoke blob on node destroy + maintain upload counts internally 2026-06-23 18:36:07 +08:00
xfy
71df6abe5d refactor(tiptap): drop redundant single-arg mergeAttributes call 2026-06-23 18:35:05 +08:00
xfy
18ae6a915e refactor(tiptap): type coordinatorRef via Pick<UploadCoordinator> instead of duck typing 2026-06-23 18:34:37 +08:00
xfy
558191e922 refactor(tiptap): type CommandItem.editor as Editor, explicit SlashPopup return type 2026-06-23 18:33:59 +08:00
xfy
073f6936e3 build(tiptap): add tsconfig + typecheck script 2026-06-23 18:32:26 +08:00
16 changed files with 2062 additions and 923 deletions

View File

@ -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
```

View File

@ -44,6 +44,7 @@ css-watch:
test:
@cargo test
@cd libs/tiptap-editor && npm test
clean:
@cargo clean

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View 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 schemeXSS 风险)'],
['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)
})
})
})

View 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 访
* keynode.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 计数 +1pending 存在,节点插入文档', () => {
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 本身不 emitrunUpload 还在 awaitcounts 只能通过后续 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 归 0emit successblob 被 revokepending 清除', 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-forgetawait 一次微任务让它完成
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 应更新为最终 URLupload 属性被清空
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=1emit error 带 errorMsgentry 转 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 不在 pendingno-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 })
})
})

View File

@ -0,0 +1,199 @@
import { describe, it, expect, vi } from 'vitest'
import { UploadImageNodeView, type UploadNodeViewCallbacks } from '../upload-image'
/**
* UploadImageNodeView happy-dom DOM
*
* fake PMNodeplain object+ happy-dom document
* uploading/error/null DOM click destroy onDestroyed
*
* NodeView editor node.attrs + DOMcallbacks
*/
// 共用的 type sentinelupdate() 用 === 比较 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 态缺 msgfallback 显示"上传失败"', () => {
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 → erroroverlay 重新渲染', () => {
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()
})
})
})

View File

@ -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 setterset_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}`)

View File

@ -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')

View File

@ -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
// 就地 spreadnode.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 查询(供内部/测试)。 */

View File

@ -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
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View File

@ -11,11 +11,10 @@ export default defineConfig({
fileName: () => 'editor.js',
formats: ['iife'],
},
rollupOptions: {
rolldownOptions: {
output: {
exports: 'default',
assetFileNames: 'editor.[ext]',
inlineDynamicImports: true,
},
},
cssCodeSplit: false,

View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'happy-dom',
include: ['src/**/*.test.ts'],
},
})

View File

@ -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_intoTiptapEditor 是 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" {