Compare commits

..

No commits in common. "1cc09e4cbc07fb37b17a357ba3518eb165856f8d" and "c1610ace9902e62812d77ee9befdd88b6ae9b57f" have entirely different histories.

16 changed files with 996 additions and 2135 deletions

View File

@ -121,8 +121,7 @@ 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`. Rich-text editor in `libs/tiptap-editor/`, built as an IIFE library exposing `window.TiptapEditor`.
- Output: `public/tiptap/` - Output: `public/tiptap/`
- `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) - `make build` runs `npm install && npx vite build` inside `libs/tiptap-editor`
- 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. - `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. Do not edit `public/tiptap/` — they are build artifacts.
@ -150,7 +149,7 @@ Do not edit `public/tiptap/` — they are build artifacts.
## Testing ## Testing
```bash ```bash
make test # cargo test (Rust, 392 tests) + vitest (editor lib, 46 tests) cargo test # standard Rust test suite
dx check # Dioxus type-check (catches component/Router issues) dx check # Dioxus type-check (catches component/Router issues)
cargo clippy # lint cargo clippy # lint
``` ```

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -4,28 +4,22 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "tsc --noEmit && vite build", "build": "vite build",
"dev": "vite", "dev": "vite"
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@tiptap/core": "^3.27.1", "@tiptap/core": "^3.25.0",
"@tiptap/extension-file-handler": "^3.27.1", "@tiptap/extension-file-handler": "^3.25.0",
"@tiptap/extension-image": "^3.27.1", "@tiptap/extension-image": "^3.25.0",
"@tiptap/extension-link": "^3.27.1", "@tiptap/extension-link": "^3.25.0",
"@tiptap/extension-list": "^3.27.1", "@tiptap/extension-list": "^3.25.0",
"@tiptap/extension-table": "^3.27.1", "@tiptap/extension-table": "^3.25.0",
"@tiptap/markdown": "^3.27.1", "@tiptap/markdown": "^3.25.0",
"@tiptap/pm": "^3.27.1", "@tiptap/pm": "^3.25.0",
"@tiptap/starter-kit": "^3.27.1", "@tiptap/starter-kit": "^3.25.0",
"@tiptap/suggestion": "^3.27.1" "@tiptap/suggestion": "^3.25.0"
}, },
"devDependencies": { "devDependencies": {
"happy-dom": "^20.10.6", "vite": "^5.4.20"
"typescript": "^6.0.3",
"vite": "^8.1.0",
"vitest": "^4.1.9"
} }
} }

View File

@ -1,56 +0,0 @@
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

@ -1,293 +0,0 @@
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

@ -1,199 +0,0 @@
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,20 +5,11 @@ import { TableKit } from '@tiptap/extension-table'
import { TaskList, TaskItem } from '@tiptap/extension-list' import { TaskList, TaskItem } from '@tiptap/extension-list'
import { FileHandler } from '@tiptap/extension-file-handler' import { FileHandler } from '@tiptap/extension-file-handler'
import { SlashCommand } from './slash-command' import { SlashCommand } from './slash-command'
import { UploadCoordinator, UPLOAD_COORDINATOR_STORAGE_KEY, type UploadEvent } from './upload-coordinator' import { UploadCoordinator, type UploadEvent } from './upload-coordinator'
import { UploadImage } from './upload-image' import { UploadImage, setUploadCoordinator } from './upload-image'
import './style.css' 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 content?: string
placeholder?: string placeholder?: string
onUpdate?: (markdown: string) => void onUpdate?: (markdown: string) => void
@ -33,21 +24,17 @@ class EditorOptions {
onUploadEvent?: (event: UploadEvent) => void 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 { class TiptapEditorInstance {
private editor: Editor | null = null private editor: Editor | null = null
private container: HTMLElement private container: HTMLElement
private options: EditorOptions private options: EditorOptions
// 源码模式相关状态
private isSourceMode = false private isSourceMode = false
private sourceTextarea: HTMLTextAreaElement | null = null private sourceTextarea: HTMLTextAreaElement | null = null
private toggleButton: HTMLButtonElement | null = null private toggleButton: HTMLButtonElement | null = null
private coordinator: UploadCoordinator | null = null private coordinator: UploadCoordinator | null = null
constructor(container: HTMLElement, options: EditorOptions = new EditorOptions()) { constructor(container: HTMLElement, options: EditorOptions = {}) {
this.container = container this.container = container
this.options = options this.options = options
this.init() this.init()
@ -95,7 +82,9 @@ class TiptapEditorInstance {
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' }, HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
}, },
}), }),
Markdown, Markdown.configure({
html: false,
}),
TableKit, TableKit,
UploadImage, UploadImage,
TaskList, TaskList,
@ -142,16 +131,15 @@ class TiptapEditorInstance {
}, },
}) })
// 创建上传协调器,挂到 editor.storage 供 NodeView 按实例读取(支持多编辑器实例)。 // 创建上传协调器,注入给 NodeView 的 onRetry/onRemove
// onUploadEvent 透传给 coordinator.emit未提供时空操作兜底 // onUploadEvent 透传给 coordinator.emit未提供时空操作兜底
if (this.options.onImageUpload) { if (this.options.onImageUpload) {
this.coordinator = new UploadCoordinator( this.coordinator = new UploadCoordinator(
this.editor, this.editor,
this.options.onImageUpload, this.options.onImageUpload,
this.options.onUploadEvent ?? (() => {}), this.options.onUploadEvent ?? (() => {}),
) )
// editor.storage 是开放式索引签名;自定义 key 需绕过严格检查Tiptap 官方扩展 storage 模式)。 setUploadCoordinator(this.coordinator)
;(this.editor.storage as unknown as Record<string, unknown>)[UPLOAD_COORDINATOR_STORAGE_KEY] = this.coordinator
} }
// 通知宿主编辑器已就绪(替代 window.__tiptap_ready 轮询) // 通知宿主编辑器已就绪(替代 window.__tiptap_ready 轮询)
@ -257,8 +245,9 @@ class TiptapEditorInstance {
destroy(): void { destroy(): void {
this.editor?.destroy() this.editor?.destroy()
this.editor = null this.editor = null
// coordinator 通过 editor.storage 访问,随 editor 实例一同回收,无需显式清除引用。
this.coordinator = null this.coordinator = null
// 清除 NodeView 的 coordinator 引用,避免指向已销毁的实例
setUploadCoordinator(null)
// 清理源码模式相关引用(容器 innerHTML 已清空DOM 会随之移除) // 清理源码模式相关引用(容器 innerHTML 已清空DOM 会随之移除)
this.sourceTextarea = null this.sourceTextarea = null
this.toggleButton = null this.toggleButton = null
@ -270,7 +259,7 @@ class TiptapEditorInstance {
const TiptapEditor = { const TiptapEditor = {
_instances: new Map<string, TiptapEditorInstance>(), _instances: new Map<string, TiptapEditorInstance>(),
create(containerId: string, options: EditorOptions = new EditorOptions()): TiptapEditorInstance | null { create(containerId: string, options: EditorOptions = {}): TiptapEditorInstance | null {
const container = document.getElementById(containerId) const container = document.getElementById(containerId)
if (!container) { if (!container) {
console.error(`[TiptapEditor] Container not found: #${containerId}`) console.error(`[TiptapEditor] Container not found: #${containerId}`)

View File

@ -1,4 +1,4 @@
import { Extension, type Editor, type Range } from '@tiptap/core' import { Extension, type Range } from '@tiptap/core'
import { Suggestion, type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion' import { Suggestion, type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion'
import { PluginKey } from '@tiptap/pm/state' import { PluginKey } from '@tiptap/pm/state'
@ -6,7 +6,7 @@ interface CommandItem {
title: string title: string
description: string description: string
icon: string icon: string
command: (props: { editor: Editor; range: Range }) => void command: (props: { editor: any; range: Range }) => void
} }
/** /**
@ -167,7 +167,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
icon: '🖼', icon: '🖼',
command: ({ editor, range }) => { command: ({ editor, range }) => {
const url = window.prompt('输入图片 URL') const url = window.prompt('输入图片 URL')
if (url && isValidUrl(url)) { if (url) {
editor.chain().focus().deleteRange(range).setImage({ src: url }).run() editor.chain().focus().deleteRange(range).setImage({ src: url }).run()
} }
}, },
@ -178,17 +178,9 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
icon: '🔗', icon: '🔗',
command: ({ editor, range }) => { command: ({ editor, range }) => {
const url = window.prompt('输入链接 URL') const url = window.prompt('输入链接 URL')
if (!url || !isValidUrl(url)) return if (url) {
// deleteRange 后光标停在 range.to先插入 URL 文本,再选中刚插入的范围设 link editor.chain().focus().deleteRange(range).setLink({ href: url }).insertContent(url).run()
// setLink 需要非空选区才生效,原顺序 setLink 在空选区无效)。 }
const insertFrom = range.to
editor.chain()
.focus()
.deleteRange(range)
.insertContent(url)
.setTextSelection({ from: insertFrom, to: insertFrom + url.length })
.setLink({ href: url })
.run()
}, },
}, },
) )
@ -206,7 +198,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
) )
}, },
render() { render() {
let popup: SlashPopup | null = null let popup: ReturnType<typeof createPopup> | null = null
return { return {
onStart(props) { onStart(props) {
@ -237,21 +229,7 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
}, },
}) })
/** 斜杠命令浮层实例:供 Suggestion render 生命周期驱动。 */ function createPopup(props: SuggestionProps<CommandItem>) {
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') const component = document.createElement('div')
component.classList.add('slash-command') component.classList.add('slash-command')

View File

@ -1,23 +1,10 @@
import type { Editor } from '@tiptap/core' import type { Editor } from '@tiptap/core'
/** editor.storage 上挂载 coordinator 的 key。NodeView 通过此 key 拿到当前编辑器实例的 coordinator。 */ /** pending 上传条目:保留 File 供重试blobUrl 供本地预览。 */
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 { interface UploadEntry {
file: File file: File
blobUrl: string blobUrl: string
fileName: string fileName: string
/** 当前态uploading进行中或 error失败待重试。成功后整个 entry 删除。 */
state: 'uploading' | 'error'
} }
/** counts 快照:随事件一起传给宿主,替代 window 全局。 */ /** counts 快照:随事件一起传给宿主,替代 window 全局。 */
@ -55,9 +42,6 @@ function genUploadId(): string {
*/ */
export class UploadCoordinator { export class UploadCoordinator {
private pending = new Map<string, UploadEntry>() private pending = new Map<string, UploadEntry>()
/** 内部计数:替代每次事件全量遍历文档。 */
private uploadingCount = 0
private errorCount = 0
constructor( constructor(
private editor: Editor, private editor: Editor,
@ -73,8 +57,7 @@ export class UploadCoordinator {
insertUploading(file: File, pos?: number): void { insertUploading(file: File, pos?: number): void {
const uploadId = genUploadId() const uploadId = genUploadId()
const blobUrl = URL.createObjectURL(file) const blobUrl = URL.createObjectURL(file)
this.pending.set(uploadId, { file, blobUrl, fileName: file.name, state: 'uploading' }) this.pending.set(uploadId, { file, blobUrl, fileName: file.name })
this.uploadingCount++
this.editor.chain().focus().insertContentAt(pos ?? this.editor.state.selection.head, { this.editor.chain().focus().insertContentAt(pos ?? this.editor.state.selection.head, {
type: 'image', type: 'image',
@ -94,32 +77,11 @@ export class UploadCoordinator {
if (!entry) return false if (!entry) return false
this.removeNodeByUploadId(uploadId) this.removeNodeByUploadId(uploadId)
URL.revokeObjectURL(entry.blobUrl) URL.revokeObjectURL(entry.blobUrl)
// 按当前态减对应计数
if (entry.state === 'uploading') this.uploadingCount--
else this.errorCount--
this.pending.delete(uploadId)
this.notifyRust({ kind: 'removed', uploadId, fileName: entry.fileName }) this.notifyRust({ kind: 'removed', uploadId, fileName: entry.fileName })
this.pending.delete(uploadId)
return true return true
} }
/**
* NodeView.destroy ProseMirror 退
* removeUpload PM pending + revoke +
* removeNodeByUploadId emit 'removed'counts
*
* entry pending delete no-op
*/
handleNodeDestroyed(uploadId: string): void {
const entry = this.pending.get(uploadId)
if (!entry) return
URL.revokeObjectURL(entry.blobUrl)
if (entry.state === 'uploading') this.uploadingCount--
else this.errorCount--
this.pending.delete(uploadId)
// emit removed 让宿主顶部提示同步移除该 id
this.notifyRust({ kind: 'removed', uploadId, fileName: entry.fileName })
}
/** 核心上传逻辑:成功更新 src + 清上传属性,失败转 error 态。 */ /** 核心上传逻辑:成功更新 src + 清上传属性,失败转 error 态。 */
private async runUpload(uploadId: string): Promise<void> { private async runUpload(uploadId: string): Promise<void> {
const entry = this.pending.get(uploadId) const entry = this.pending.get(uploadId)
@ -135,7 +97,6 @@ export class UploadCoordinator {
}) })
URL.revokeObjectURL(entry.blobUrl) URL.revokeObjectURL(entry.blobUrl)
this.pending.delete(uploadId) this.pending.delete(uploadId)
this.uploadingCount--
this.notifyRust({ kind: 'success', uploadId, fileName: entry.fileName }) this.notifyRust({ kind: 'success', uploadId, fileName: entry.fileName })
} catch (err) { } catch (err) {
const msg = this.extractErrorMessage(err) const msg = this.extractErrorMessage(err)
@ -143,10 +104,6 @@ export class UploadCoordinator {
'data-upload-state': 'error', 'data-upload-state': 'error',
'data-error-msg': msg, 'data-error-msg': msg,
}) })
// 失败:从 uploading 转 error 态
entry.state = 'error'
this.uploadingCount--
this.errorCount++
this.notifyRust({ kind: 'error', uploadId, fileName: entry.fileName, errorMsg: msg }) this.notifyRust({ kind: 'error', uploadId, fileName: entry.fileName, errorMsg: msg })
} }
} }
@ -155,9 +112,6 @@ export class UploadCoordinator {
retryUpload(uploadId: string): void { retryUpload(uploadId: string): void {
const entry = this.pending.get(uploadId) const entry = this.pending.get(uploadId)
if (!entry) return if (!entry) return
// 重试前节点处于 error 态,转回 uploading
this.errorCount--
this.uploadingCount++
this.updateNodeAttrs(uploadId, { this.updateNodeAttrs(uploadId, {
'data-upload-state': 'uploading', 'data-upload-state': 'uploading',
'data-error-msg': null, 'data-error-msg': null,
@ -184,30 +138,25 @@ export class UploadCoordinator {
} }
/** 按 uploadId 定位节点并合并更新属性。 */ /** 按 uploadId 定位节点并合并更新属性。 */
private updateNodeAttrs(uploadId: string, attrs: UploadNodeAttrs): void { private updateNodeAttrs(uploadId: string, attrs: Record<string, unknown>): void {
const found = this.findImageNodeByUploadId(uploadId) let targetPos: number | null = null
if (!found) return let oldAttrs: Record<string, unknown> | null = null
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) => { this.editor.state.doc.descendants((node, pos) => {
if (node.type.name === 'image' && node.attrs['data-upload-id'] === uploadId) { if (node.type.name === 'image' && node.attrs['data-upload-id'] === uploadId) {
// 就地 spreadnode.attrs 在此作用域内TS 接受;产出 plain object 供后续合并。 targetPos = pos
result = { pos, attrs: { ...(node.attrs as Record<string, unknown>) } } oldAttrs = node.attrs
return false return false
} }
return true return true
}) })
return result if (targetPos !== null && oldAttrs) {
const tr = this.editor.state.tr.setNodeMarkup(
targetPos,
undefined,
{ ...oldAttrs, ...attrs },
)
this.editor.view.dispatch(tr)
}
} }
/** 从错误对象提取消息。改造后的 fetch 直接抛服务端中文(如"文件超过大小限制")。 */ /** 从错误对象提取消息。改造后的 fetch 直接抛服务端中文(如"文件超过大小限制")。 */
@ -218,14 +167,18 @@ export class UploadCoordinator {
/** /**
* emit counts 宿 * emit counts 宿
* counts * window.__tiptap_uploads Rust
*/ */
private notifyRust(event: Omit<UploadEvent, 'ts' | 'counts'>): void { private notifyRust(event: Omit<UploadEvent, 'ts' | 'counts'>): void {
this.emit({ let uploading = 0
...event, let error = 0
ts: Date.now(), this.editor.state.doc.descendants((node) => {
counts: { uploading: this.uploadingCount, error: this.errorCount }, 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, error } })
} }
/** pending Map 查询(供内部/测试)。 */ /** pending Map 查询(供内部/测试)。 */

View File

@ -1,13 +1,11 @@
import { Image } from '@tiptap/extension-image' import { Image } from '@tiptap/extension-image'
import { mergeAttributes } from '@tiptap/core'
import type { Node as PMNode } from '@tiptap/pm/model' import type { Node as PMNode } from '@tiptap/pm/model'
import { UPLOAD_COORDINATOR_STORAGE_KEY, type UploadCoordinator } from './upload-coordinator'
/** NodeView 按钮点击 / destroy 回调注入接口。 */ /** NodeView 按钮点击回调注入接口。 */
export interface UploadNodeViewCallbacks { export interface UploadNodeViewCallbacks {
onRetry: (uploadId: string) => void onRetry: (uploadId: string) => void
onRemove: (uploadId: string) => void onRemove: (uploadId: string) => void
/** NodeView.destroy 兜底:节点被 PM 删除时清理 pending + revoke blob。 */
onDestroyed: (uploadId: string) => void
} }
/** /**
@ -21,10 +19,9 @@ export interface UploadNodeViewCallbacks {
* NodeView callbacks coordinator * NodeView callbacks coordinator
* ProseMirror update(node)NodeView data-upload-state * ProseMirror update(node)NodeView data-upload-state
*/ */
export class UploadImageNodeView { class UploadImageNodeView {
private node: PMNode private node: PMNode
private callbacks: UploadNodeViewCallbacks private callbacks: UploadNodeViewCallbacks
private uploadId: string | null
private container: HTMLDivElement private container: HTMLDivElement
private img: HTMLImageElement private img: HTMLImageElement
@ -37,14 +34,14 @@ export class UploadImageNodeView {
}) { }) {
this.node = opts.node this.node = opts.node
this.callbacks = opts.callbacks this.callbacks = opts.callbacks
this.uploadId = (this.node.attrs['data-upload-id'] as string | null) ?? null
this.container = document.createElement('div') this.container = document.createElement('div')
this.container.classList.add('upload-image-container') this.container.classList.add('upload-image-container')
this.img = document.createElement('img') this.img = document.createElement('img')
this.img.draggable = false this.img.draggable = false
Object.entries(opts.HTMLAttributes).forEach(([k, v]) => { const merged = mergeAttributes(opts.HTMLAttributes)
Object.entries(merged).forEach(([k, v]) => {
if (v != null) this.img.setAttribute(k, String(v)) if (v != null) this.img.setAttribute(k, String(v))
}) })
const src = this.node.attrs.src const src = this.node.attrs.src
@ -70,7 +67,6 @@ export class UploadImageNodeView {
const oldSrc = this.node.attrs.src const oldSrc = this.node.attrs.src
const newSrc = node.attrs.src const newSrc = node.attrs.src
this.node = node this.node = node
this.uploadId = (node.attrs['data-upload-id'] as string | null) ?? null
if (oldSrc !== newSrc && newSrc != null) { if (oldSrc !== newSrc && newSrc != null) {
this.img.src = newSrc this.img.src = newSrc
} }
@ -135,17 +131,20 @@ export class UploadImageNodeView {
} }
destroy(): void { 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?.remove()
this.overlay = null this.overlay = null
this.container.remove() 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 * Image NodeView
*/ */
@ -181,15 +180,13 @@ export const UploadImage = Image.configure({ allowBase64: true }).extend({
}, },
addNodeView() { addNodeView() {
return ({ node, HTMLAttributes, editor }) => { return ({ node, HTMLAttributes }) => {
const coordinator = (editor.storage as unknown as Record<string, unknown>)[UPLOAD_COORDINATOR_STORAGE_KEY] as UploadCoordinator | undefined
return new UploadImageNodeView({ return new UploadImageNodeView({
node, node,
HTMLAttributes, HTMLAttributes,
callbacks: { callbacks: {
onRetry: (id) => coordinator?.retryUpload(id), onRetry: (id) => coordinatorRef?.retryUpload(id),
onRemove: (id) => coordinator?.removeUpload(id), onRemove: (id) => coordinatorRef?.removeUpload(id),
onDestroyed: (id) => coordinator?.handleNodeDestroyed(id),
}, },
}) })
} }

View File

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

View File

@ -1,19 +0,0 @@
{
"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,10 +11,11 @@ export default defineConfig({
fileName: () => 'editor.js', fileName: () => 'editor.js',
formats: ['iife'], formats: ['iife'],
}, },
rolldownOptions: { rollupOptions: {
output: { output: {
exports: 'default', exports: 'default',
assetFileNames: 'editor.[ext]', assetFileNames: 'editor.[ext]',
inlineDynamicImports: true,
}, },
}, },
cssCodeSplit: false, cssCodeSplit: false,

View File

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

View File

@ -35,15 +35,14 @@ pub mod wasm {
use wasm_bindgen::JsCast; use wasm_bindgen::JsCast;
// —— window.TiptapEditor 模块对象 —— // —— 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] #[wasm_bindgen]
extern "C" { extern "C" {
pub type TiptapEditorModule; 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)`。 /// 调用 `TiptapEditor.create(containerId, options)`。
/// 找不到容器返回 null被 Option 捕获);构造失败抛异常(被 catch 捕获)。 /// 找不到容器返回 null被 Option 捕获);构造失败抛异常(被 catch 捕获)。
#[wasm_bindgen(method, catch)] #[wasm_bindgen(method, catch)]
@ -54,18 +53,6 @@ pub mod wasm {
) -> Result<Option<EditorInstance>, JsValue>; ) -> 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—— // —— 编辑器实例TiptapEditorInstance——
#[wasm_bindgen] #[wasm_bindgen]
extern "C" { extern "C" {