diff --git a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md index 7f37763b..70a74b3b 100644 --- a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md +++ b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md @@ -120,7 +120,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::tests::mno 拆分项: - [ ] B1. `document-pane-runtime.js`:secondary pane URL、pane resize、pane close、pane runtime registry。 -- [ ] B2. `document-tiptap-conversion-runtime.js`:legacy block / inline content / marks 转 Tiptap document。 +- [x] B2. `document-tiptap-conversion-runtime.js`:legacy block / inline content / marks 转 Tiptap document。 - [ ] B3. `document-resource-tab-runtime.js`:resource tab registry、MRU、close guard、resource text/image/frame editor mount。 - [ ] B4. `document-mindmap-host-runtime.js`:primary mindmap object shell、mindmap resource tab mount/unmount。 - [ ] B5. `document-slash-position-runtime.js`:slash menu active root、positioning、mutation observer。 diff --git a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js index 2970e1a9..ee9b02be 100644 --- a/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js +++ b/rust/crates/mnote-web/browser/document-editor-adapter-runtime.js @@ -1,3 +1,16 @@ +import { + conflictDetectionKeyBelongsToSession, + conflictDetectionKeyFromBody, + editorDocumentFromTiptapDocument, + flattenText, + hydrateMindmapAttrsFromDom, + legacyBlocksFromEditorDocument, + legacyInlineContentToTiptap, + pageBodyTiptapDocument, + revisionFromConflictKey, + toTiptapDocument, +} from './document-tiptap-conversion-runtime.js'; + (() => { const PANES_BOOTSTRAP_ID = '__MNOTE_DOCUMENT_PANES_BOOTSTRAP__'; const ROOT_SELECTOR = '[data-testid="mnote-leptos-tiptap-island-editor-root"]'; @@ -200,555 +213,7 @@ return window.__mnoteLeptosTiptapRuntimePromise; }; - const flattenText = (value) => { - if (typeof value === 'string') return value; - if (Array.isArray(value)) return value.map(flattenText).join(''); - if (value && typeof value === 'object') { - return `${flattenText(value.text)}${flattenText(value.content)}`; - } - return ''; - }; - - const firstNonEmptyText = (...values) => { - for (const value of values) { - const text = flattenText(value).trim(); - if (text) return text; - } - return ''; - }; - - // 过渡适配(TODO step-4):legacy→Tiptap inline marks 转换函数组。 - // AST/block 迁移 complete 后,前端应直接消费 block document 中的 - // tiptap 格式 marks(已由 Rust 侧 local_markdown_parser 输出), - // 不再需要此 JS 侧样式→marks 适配层。 - const legacyStylesToTiptapMarks = (styles) => { - if (!styles || typeof styles !== 'object') return []; - const marks = []; - if (styles.bold) marks.push({ type: 'bold' }); - if (styles.italic) marks.push({ type: 'italic' }); - if (styles.underline) marks.push({ type: 'underline' }); - if (styles.strike || styles.strikethrough) marks.push({ type: 'strike' }); - if (styles.code || styles.inlineCode) marks.push({ type: 'code' }); - const href = typeof styles.link === 'string' && styles.link.trim() - ? styles.link.trim() - : typeof styles.href === 'string' && styles.href.trim() - ? styles.href.trim() - : ''; - if (href) marks.push({ type: 'link', attrs: { href } }); - return marks; - }; - - const legacyMarkArrayToTiptapMarks = (inlineMarks) => { - if (!Array.isArray(inlineMarks)) return []; - return inlineMarks.flatMap((mark) => { - if (!mark || typeof mark !== 'object') return []; - if (mark.type === 'bold' || mark.type === 'italic' || mark.type === 'underline' || mark.type === 'strike' || mark.type === 'code') { - return [{ type: mark.type }]; - } - if (mark.type === 'link') { - const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : ''; - return href ? [{ type: 'link', attrs: { href } }] : []; - } - return []; - }); - }; - - const mergeTiptapMarks = (...groups) => { - const seen = new Set(); - return groups.flat().filter((mark) => { - const key = `${mark.type}:${JSON.stringify(mark.attrs || {})}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - }; - - const legacyInlineContentToTiptap = (value) => { - if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : []; - if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap); - if (value && typeof value === 'object') { - const text = typeof value.text === 'string' ? value.text : ''; - if (text) { - const marks = mergeTiptapMarks( - legacyStylesToTiptapMarks(value.styles), - legacyMarkArrayToTiptapMarks(value.marks) - ); - return [{ type: 'text', text, ...(marks.length ? { marks } : {}) }]; - } - return legacyInlineContentToTiptap(value.content || value.contentNodes); - } - return []; - }; - - const legacyBlockToTiptap = (block, index = 0) => { - const type = typeof block?.type === 'string' ? block.type : 'paragraph'; - const blockId = typeof block?.id === 'string' && block.id.trim() - ? block.id.trim() - : typeof block?.blockId === 'string' && block.blockId.trim() - ? block.blockId.trim() - : `block-${index + 1}`; - const content = legacyInlineContentToTiptap(block?.content ?? block?.contentNodes); - const textAlign = typeof block?.props?.textAlign === 'string' - ? block.props.textAlign - : typeof block?.props?.text_align === 'string' - ? block.props.text_align - : undefined; - const withTextAlign = (attrs = {}) => textAlign ? { ...attrs, textAlign } : attrs; - const nestedChildren = Array.isArray(block?.children) - ? block.children.map((child, childIndex) => legacyBlockToTiptap(child, childIndex)).filter(Boolean) - : []; - const withListChildren = (itemType, listType, attrs = {}) => ({ - type: listType, - attrs: { blockId }, - content: [{ - type: itemType, - attrs: { blockId, ...attrs }, - content: [ - { type: 'paragraph', attrs: { blockId }, content }, - ...nestedChildren, - ], - }], - }); - if (type === 'heading') { - const level = Number(block?.props?.level || block?.level || 1) || 1; - const collapsed = typeof block?.props?.collapsed === 'boolean' ? { collapsed: block.props.collapsed } : {}; - return { type: 'heading', attrs: withTextAlign({ blockId, level: Math.max(1, Math.min(6, level)), ...collapsed }), content }; - } - if (type === 'bulletListItem' || type === 'bullet_list_item') return withListChildren('listItem', 'bulletList'); - if (type === 'numberedListItem' || type === 'numbered_list_item') return withListChildren('listItem', 'orderedList'); - if (type === 'checkListItem' || type === 'advancedTodo' || type === 'todo') { - return withListChildren('taskItem', 'taskList', { checked: Boolean(block?.props?.checked) }); - } - if (type === 'quote' || type === 'blockquote') { - return { type: 'blockquote', attrs: withTextAlign({ blockId }), content: [{ type: 'paragraph', attrs: withTextAlign({ blockId }), content }] }; - } - if (type === 'codeBlock' || type === 'code_block') { - return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content }; - } - if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } }; - if (type === 'mindmap') { - const data = block?.props?.data && typeof block.props.data === 'object' ? block.props.data : null; - const mindmapId = firstNonEmptyText( - block?.props?.mindmapId, - block?.props?.mindmap_id, - block?.props?.sourcePath, - block?.props?.source_path, - block?.mindmapId, - block?.mindmap_id, - data?.mindmapId, - data?.mindmap_id, - data?.id - ); - const rootNodeId = firstNonEmptyText(block?.props?.rootNodeId, block?.props?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root'; - return { - type: 'paragraph', - attrs: withTextAlign({ - blockId, - mnoteBlockType: 'mindmap', - mindmapId, - rootNodeId, - }), - }; - } - if (type === 'media') { - const sourcePath = firstNonEmptyText(block?.props?.sourcePath, block?.props?.url, block?.props?.src); - const name = firstNonEmptyText(block?.props?.name, block?.props?.fileName, block?.props?.title, sourcePath); - const mediaContent = name - ? [{ - type: 'text', - text: name, - ...(sourcePath ? { marks: [{ type: 'link', attrs: { href: sourcePath } }] } : {}), - }] - : content; - return { type: 'paragraph', attrs: withTextAlign({ blockId }), content: mediaContent }; - } - if (type === 'table') { - const tableSnapshot = block?.props?.tiptapTable; - if (tableSnapshot && typeof tableSnapshot === 'object' && tableSnapshot.type === 'table') return tableSnapshot; - return { - type: 'table', - content: [{ - type: 'tableRow', - content: [{ - type: 'tableCell', - attrs: { colspan: 1, rowspan: 1, colwidth: null }, - content: [{ type: 'paragraph', content }], - }], - }], - }; - } - if (type === 'toc' || type === 'tocNode' || type === 'toc_node') { - const tocSnapshot = block?.props?.tiptapTocNode || block?.props?.tiptapToc; - if (tocSnapshot && typeof tocSnapshot === 'object' && tocSnapshot.type === 'tocNode') return tocSnapshot; - return { - type: 'tocNode', - attrs: { - topOffset: Number(block?.props?.topOffset || block?.props?.top_offset || 0) || 0, - maxShowCount: Number(block?.props?.maxShowCount || block?.props?.max_show_count || 20) || 20, - showTitle: block?.props?.showTitle !== false, - }, - }; - } - if (type === 'image') { - const imageSnapshot = block?.props?.tiptapImage; - if (imageSnapshot && typeof imageSnapshot === 'object' && imageSnapshot.type === 'image') return imageSnapshot; - const attrs = { - src: String(block?.props?.src || block?.src || ''), - alt: block?.props?.alt || block?.alt || null, - title: block?.props?.title || block?.title || null, - }; - return attrs.src ? { type: 'image', attrs: { blockId, ...attrs } } : null; - } - return { type: 'paragraph', attrs: withTextAlign({ blockId }), content }; - }; - - const textToTiptapDocument = (text) => ({ - type: 'doc', - content: [{ - type: 'paragraph', - content: text ? [{ type: 'text', text }] : [], - }], - }); - - const isTiptapDocument = (content) => ( - content && - typeof content === 'object' && - !Array.isArray(content) && - content.type === 'doc' - ); - - const mindmapDomDescriptors = (root) => { - if (!(root instanceof HTMLElement)) return []; - return Array.from(root.querySelectorAll('[data-testid="mnote-mindmap-editor-root"]')) - .flatMap((node) => { - if (!(node instanceof HTMLElement)) return []; - const mindmapId = typeof node.dataset.mnoteMindmapId === 'string' ? node.dataset.mnoteMindmapId.trim() : ''; - if (!mindmapId) return []; - const rootNodeId = typeof node.dataset.mnoteRootNodeId === 'string' && node.dataset.mnoteRootNodeId.trim() - ? node.dataset.mnoteRootNodeId.trim() - : 'root'; - return [{ mindmapId, rootNodeId }]; - }); - }; - - const hydrateMindmapAttrsFromDom = (tiptapDocument, root) => { - if (!isTiptapDocument(tiptapDocument) || !Array.isArray(tiptapDocument.content)) return tiptapDocument; - const descriptors = mindmapDomDescriptors(root); - if (!descriptors.length) return tiptapDocument; - let index = 0; - for (const node of tiptapDocument.content) { - if (node?.type !== 'paragraph' || node?.attrs?.mnoteBlockType !== 'mindmap') continue; - const descriptor = descriptors[index]; - index += 1; - if (!descriptor) continue; - node.attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {}; - if (typeof node.attrs.mindmapId !== 'string' || !node.attrs.mindmapId.trim()) { - node.attrs.mindmapId = descriptor.mindmapId; - } - if (typeof node.attrs.rootNodeId !== 'string' || !node.attrs.rootNodeId.trim()) { - node.attrs.rootNodeId = descriptor.rootNodeId; - } - } - return tiptapDocument; - }; - - const toTiptapDocument = (content, fallbackText = '') => { - if (isTiptapDocument(content)) return content; - const blocks = Array.isArray(content) ? content : Array.isArray(content?.blocks) ? content.blocks : []; - const nodes = blocks.map(legacyBlockToTiptap).filter(Boolean); - if (nodes.length) return { type: 'doc', content: nodes }; - return textToTiptapDocument(fallbackText); - }; - - const decodeLocalIdSegment = (segment) => { - const encoded = String(segment || '').replace(/~([0-9a-fA-F]{2})/g, '%$1'); - try { - return decodeURIComponent(encoded); - } catch (_) { - return String(segment || '').replace(/~2F/g, '/').replace(/~20/g, ' '); - } - }; - - const localMarkdownRelativePathFromDocumentId = (documentId) => { - const raw = String(documentId || '').trim(); - const segment = raw.startsWith('local-md:') ? raw.slice('local-md:'.length) : raw; - return decodeLocalIdSegment(segment).replace(/^\/+/, ''); - }; - - const localMarkdownDocumentIdFromRelativePath = (relativePath) => { - const normalized = String(relativePath || '').trim().replace(/^\/+/, ''); - if (!normalized) return ''; - return 'local-md:' + normalized - .split('/') - .map((part) => part.replace(/ /g, '~20')) - .join('~2F'); - }; - - const localMarkdownDirectoryFromDocumentId = (documentId) => { - const relativePath = localMarkdownRelativePathFromDocumentId(documentId); - const slash = relativePath.lastIndexOf('/'); - return slash >= 0 ? relativePath.slice(0, slash) : ''; - }; - - const isExternalOrSpecialUrl = (value) => { - const text = String(value || '').trim(); - return !text - || text.startsWith('#') - || text.startsWith('data:') - || text.startsWith('blob:') - || text.startsWith('mailto:') - || text.startsWith('http://') - || text.startsWith('https://') - || text.startsWith('/api/'); - }; - - const normalizeLocalAssetRelativePath = (value, context) => { - const text = String(value || '').trim(); - if (!text || isExternalOrSpecialUrl(text)) return text; - if (text.startsWith('/')) return text.replace(/^\/+/, ''); - const baseDir = localMarkdownDirectoryFromDocumentId(context?.documentId); - return (baseDir ? `${baseDir}/${text}` : text) - .split('/') - .filter((part) => part && part !== '.') - .join('/'); - }; - - const localFileOpenUrlForTiptap = (value, context) => { - if (!context || context.sourceKind !== 'local_folder' || !context.rootUri) return value; - const relativePath = normalizeLocalAssetRelativePath(value, context); - if (!relativePath || isExternalOrSpecialUrl(relativePath)) return value; - const url = new URL('/api/local-folder/files/open', window.location.origin); - url.searchParams.set('rootUri', context.rootUri); - url.searchParams.set('path', relativePath); - return url.toString(); - }; - - const localizeTiptapAssetUrls = (node, context) => { - if (!node || typeof node !== 'object') return node; - if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') { - node.attrs = { ...node.attrs, src: localFileOpenUrlForTiptap(node.attrs.src, context) }; - } - if (Array.isArray(node.marks)) { - node.marks = node.marks.map((mark) => { - if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark; - return { ...mark, attrs: { ...mark.attrs, href: localFileOpenUrlForTiptap(mark.attrs.href, context) } }; - }); - } - if (Array.isArray(node.content)) { - node.content = node.content.map((child) => localizeTiptapAssetUrls(child, context)); - } - return node; - }; - - const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => { - if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) { - return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), context); - } - const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null; - if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), context); - return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), context); - }; - - const inlineTextNodes = (node) => { - if (!node || typeof node !== 'object') return []; - if (Array.isArray(node.content)) { - return node.content.flatMap((child) => { - if (child?.type === 'text') { - const text = typeof child.text === 'string' ? child.text : ''; - if (!text) return []; - const styles = {}; - const marks = []; - for (const mark of Array.isArray(child.marks) ? child.marks : []) { - if (mark?.type === 'bold') { - styles.bold = true; - marks.push('bold'); - } - if (mark?.type === 'italic') { - styles.italic = true; - marks.push('italic'); - } - if (mark?.type === 'underline') { - styles.underline = true; - marks.push('underline'); - } - if (mark?.type === 'strike') { - styles.strike = true; - marks.push('strike'); - } - if (mark?.type === 'code') { - styles.code = true; - marks.push('code'); - } - if (mark?.type === 'link') { - const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : ''; - if (href) styles.link = href; - } - } - return [{ - payload: { type: 'text', text, ...(marks.length ? { marks } : {}) }, - attrs: Object.keys(styles).length ? { styles } : {}, - type: 'text', - text, - ...(Object.keys(styles).length ? { styles } : {}), - }]; - } - if (child?.type === 'hardBreak') return [{ payload: { type: 'hard_break' }, attrs: {}, type: 'text', text: '\n' }]; - return inlineTextNodes(child); - }); - } - return []; - }; - - const firstChild = (node) => Array.isArray(node?.content) ? node.content[0] : null; - const blockIdOf = (node, index) => { - const raw = typeof node?.attrs?.blockId === 'string' ? node.attrs.blockId.trim() : ''; - return raw || `block-${index + 1}`; - }; - const mindmapPropsFromAttrs = (attrs, fallbackMindmapId) => { - const data = attrs?.data && typeof attrs.data === 'object' ? attrs.data : {}; - const mindmapId = firstNonEmptyText( - attrs?.mindmapId, - attrs?.mindmap_id, - attrs?.sourcePath, - attrs?.source_path, - data?.mindmapId, - data?.mindmap_id, - data?.id, - fallbackMindmapId - ); - const rootNodeId = firstNonEmptyText(attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root'; - const projectionVersion = Number(attrs?.projectionVersion ?? attrs?.projection_version); - return { - mindmapId, - rootNodeId, - ...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}), - }; - }; - - const tiptapNodeToEditorBlock = (node, index) => { - const blockId = blockIdOf(node, index); - if (node?.type === 'paragraph' && node?.attrs?.mnoteBlockType === 'mindmap') { - const mindmapId = firstNonEmptyText( - node?.attrs?.mindmapId, - node?.attrs?.mindmap_id, - node?.attrs?.sourcePath, - node?.attrs?.source_path, - blockId - ); - return { - blockId, - blockType: 'mindmap', - props: { - ...mindmapPropsFromAttrs(node?.attrs, blockId), - sourcePath: mindmapId, - }, - contentNodes: [], - childBlockIds: [], - }; - } - if (node?.type === 'paragraph') return { blockId, blockType: 'paragraph', props: {}, contentNodes: inlineTextNodes(node), childBlockIds: [] }; - if (node?.type === 'heading') { - const level = Math.max(1, Math.min(6, Number(node?.attrs?.level || 1) || 1)); - return { blockId, blockType: 'heading', props: { headingLevel: level }, contentNodes: inlineTextNodes(node), childBlockIds: [] }; - } - if (node?.type === 'bulletList') return { blockId, blockType: 'bullet_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] }; - if (node?.type === 'orderedList') return { blockId, blockType: 'numbered_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] }; - if (node?.type === 'taskList') { - const taskItem = firstChild(node); - return { blockId, blockType: 'todo', props: { checked: Boolean(taskItem?.attrs?.checked) }, contentNodes: inlineTextNodes(firstChild(taskItem)), childBlockIds: [] }; - } - if (node?.type === 'blockquote') return { blockId, blockType: 'quote', props: {}, contentNodes: inlineTextNodes(firstChild(node)), childBlockIds: [] }; - if (node?.type === 'codeBlock') return { blockId, blockType: 'code_block', props: { language: typeof node?.attrs?.language === 'string' ? node.attrs.language : null }, contentNodes: inlineTextNodes(node), childBlockIds: [] }; - if (node?.type === 'horizontalRule') return { blockId, blockType: 'divider', props: {}, contentNodes: [], childBlockIds: [] }; - if (node?.type === 'image') return { blockId, blockType: 'image', props: { src: node?.attrs?.src || '', alt: node?.attrs?.alt || null, title: node?.attrs?.title || null, tiptapImage: node }, contentNodes: [], childBlockIds: [] }; - if (node?.type === 'tocNode') return { blockId, blockType: 'toc', props: { tiptapTocNode: node }, contentNodes: [], childBlockIds: [] }; - if (node?.type === 'table') return { blockId, blockType: 'table', props: { tiptapTable: node }, contentNodes: inlineTextNodes(node), childBlockIds: [] }; - return null; - }; - - const editorDocumentFromTiptapDocument = (bootstrap, tiptapDocument) => { - const content = Array.isArray(tiptapDocument?.content) ? tiptapDocument.content : []; - const blocks = content.map(tiptapNodeToEditorBlock).filter(Boolean); - return { - documentId: bootstrap.documentId, - rootBlockIds: blocks.map((block) => block.blockId), - blocks, - }; - }; - - const legacyBlocksFromEditorDocument = (editorDocument) => ( - Array.isArray(editorDocument?.blocks) ? editorDocument.blocks : [] - ).map((block) => ({ - id: block.blockId, - type: block.blockType, - props: block.blockType === 'heading' - ? { level: block.props?.headingLevel || 1 } - : block.blockType === 'todo' - ? { checked: Boolean(block.props?.checked) } - : block.blockType === 'code_block' - ? { language: block.props?.language || null } - : block.blockType === 'mindmap' - ? mindmapPropsFromAttrs(block.props || {}, block.blockId) - : block.blockType === 'image' - ? { ...(block.props || {}) } - : block.blockType === 'toc' - ? { ...(block.props || {}) } - : block.blockType === 'table' - ? { ...(block.props || {}) } - : undefined, - content: block.blockType === 'mindmap' - ? '' - : Array.isArray(block.contentNodes) - ? block.contentNodes.map((node) => { - if (!node || typeof node !== 'object') return null; - const payload = node.payload && typeof node.payload === 'object' ? node.payload : {}; - const text = typeof payload.text === 'string' - ? payload.text - : payload.type === 'hard_break' - ? '\n' - : typeof node.text === 'string' - ? node.text - : ''; - if (!text) return null; - const attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {}; - const styles = attrs.styles && typeof attrs.styles === 'object' - ? attrs.styles - : node.styles && typeof node.styles === 'object' - ? node.styles - : null; - const marks = Array.isArray(payload.marks) ? payload.marks : []; - return { - type: 'text', - text, - ...(styles ? { styles } : {}), - ...(marks.length ? { marks } : {}), - }; - }).filter(Boolean) - : '', - })); - - const conflictDetectionKeyFromBody = (body) => typeof body?.conflictDetectionKey === 'string' - ? body.conflictDetectionKey - : typeof body?.conflict_detection_key === 'string' - ? body.conflict_detection_key - : typeof body?.fileVersion === 'string' - ? body.fileVersion - : typeof body?.file_version === 'string' - ? body.file_version - : null; - - const conflictDetectionKeyBelongsToSession = (session, key) => { - if (!session || session.sourceKind !== 'local_folder') return true; - const value = String(key || '').trim(); - if (!value) return false; - return value.startsWith(`local-md:${session.documentId}:`); - }; - - const revisionFromConflictKey = (value) => { - const match = String(value || '').match(/:(\d+)$/); - return match ? Number(match[1]) : null; - }; - + // Tiptap/legacy conversion helpers live in document-tiptap-conversion-runtime.js. const normalizeBridgeValue = (value) => { if (value instanceof Map) { const out = {}; diff --git a/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js b/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js new file mode 100644 index 00000000..5aa397f1 --- /dev/null +++ b/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js @@ -0,0 +1,548 @@ +export const flattenText = (value) => { + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(flattenText).join(''); + if (value && typeof value === 'object') { + return `${flattenText(value.text)}${flattenText(value.content)}`; + } + return ''; +}; + +export const firstNonEmptyText = (...values) => { + for (const value of values) { + const text = flattenText(value).trim(); + if (text) return text; + } + return ''; +}; + +// 过渡适配(TODO step-4):legacy→Tiptap inline marks 转换函数组。 +// AST/block 迁移 complete 后,前端应直接消费 block document 中的 +// tiptap 格式 marks(已由 Rust 侧 local_markdown_parser 输出), +// 不再需要此 JS 侧样式→marks 适配层。 +export const legacyStylesToTiptapMarks = (styles) => { + if (!styles || typeof styles !== 'object') return []; + const marks = []; + if (styles.bold) marks.push({ type: 'bold' }); + if (styles.italic) marks.push({ type: 'italic' }); + if (styles.underline) marks.push({ type: 'underline' }); + if (styles.strike || styles.strikethrough) marks.push({ type: 'strike' }); + if (styles.code || styles.inlineCode) marks.push({ type: 'code' }); + const href = typeof styles.link === 'string' && styles.link.trim() + ? styles.link.trim() + : typeof styles.href === 'string' && styles.href.trim() + ? styles.href.trim() + : ''; + if (href) marks.push({ type: 'link', attrs: { href } }); + return marks; +}; + +export const legacyMarkArrayToTiptapMarks = (inlineMarks) => { + if (!Array.isArray(inlineMarks)) return []; + return inlineMarks.flatMap((mark) => { + if (!mark || typeof mark !== 'object') return []; + if (mark.type === 'bold' || mark.type === 'italic' || mark.type === 'underline' || mark.type === 'strike' || mark.type === 'code') { + return [{ type: mark.type }]; + } + if (mark.type === 'link') { + const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : ''; + return href ? [{ type: 'link', attrs: { href } }] : []; + } + return []; + }); +}; + +export const mergeTiptapMarks = (...groups) => { + const seen = new Set(); + return groups.flat().filter((mark) => { + const key = `${mark.type}:${JSON.stringify(mark.attrs || {})}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +}; + +export const legacyInlineContentToTiptap = (value) => { + if (typeof value === 'string') return value ? [{ type: 'text', text: value }] : []; + if (Array.isArray(value)) return value.flatMap(legacyInlineContentToTiptap); + if (value && typeof value === 'object') { + const text = typeof value.text === 'string' ? value.text : ''; + if (text) { + const marks = mergeTiptapMarks( + legacyStylesToTiptapMarks(value.styles), + legacyMarkArrayToTiptapMarks(value.marks) + ); + return [{ type: 'text', text, ...(marks.length ? { marks } : {}) }]; + } + return legacyInlineContentToTiptap(value.content || value.contentNodes); + } + return []; +}; + +export const legacyBlockToTiptap = (block, index = 0) => { + const type = typeof block?.type === 'string' ? block.type : 'paragraph'; + const blockId = typeof block?.id === 'string' && block.id.trim() + ? block.id.trim() + : typeof block?.blockId === 'string' && block.blockId.trim() + ? block.blockId.trim() + : `block-${index + 1}`; + const content = legacyInlineContentToTiptap(block?.content ?? block?.contentNodes); + const textAlign = typeof block?.props?.textAlign === 'string' + ? block.props.textAlign + : typeof block?.props?.text_align === 'string' + ? block.props.text_align + : undefined; + const withTextAlign = (attrs = {}) => textAlign ? { ...attrs, textAlign } : attrs; + const nestedChildren = Array.isArray(block?.children) + ? block.children.map((child, childIndex) => legacyBlockToTiptap(child, childIndex)).filter(Boolean) + : []; + const withListChildren = (itemType, listType, attrs = {}) => ({ + type: listType, + attrs: { blockId }, + content: [{ + type: itemType, + attrs: { blockId, ...attrs }, + content: [ + { type: 'paragraph', attrs: { blockId }, content }, + ...nestedChildren, + ], + }], + }); + if (type === 'heading') { + const level = Number(block?.props?.level || block?.level || 1) || 1; + const collapsed = typeof block?.props?.collapsed === 'boolean' ? { collapsed: block.props.collapsed } : {}; + return { type: 'heading', attrs: withTextAlign({ blockId, level: Math.max(1, Math.min(6, level)), ...collapsed }), content }; + } + if (type === 'bulletListItem' || type === 'bullet_list_item') return withListChildren('listItem', 'bulletList'); + if (type === 'numberedListItem' || type === 'numbered_list_item') return withListChildren('listItem', 'orderedList'); + if (type === 'checkListItem' || type === 'advancedTodo' || type === 'todo') { + return withListChildren('taskItem', 'taskList', { checked: Boolean(block?.props?.checked) }); + } + if (type === 'quote' || type === 'blockquote') { + return { type: 'blockquote', attrs: withTextAlign({ blockId }), content: [{ type: 'paragraph', attrs: withTextAlign({ blockId }), content }] }; + } + if (type === 'codeBlock' || type === 'code_block') { + return { type: 'codeBlock', attrs: withTextAlign({ blockId, language: block?.props?.language || null }), content }; + } + if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } }; + if (type === 'mindmap') { + const data = block?.props?.data && typeof block.props.data === 'object' ? block.props.data : null; + const mindmapId = firstNonEmptyText( + block?.props?.mindmapId, + block?.props?.mindmap_id, + block?.props?.sourcePath, + block?.props?.source_path, + block?.mindmapId, + block?.mindmap_id, + data?.mindmapId, + data?.mindmap_id, + data?.id + ); + const rootNodeId = firstNonEmptyText(block?.props?.rootNodeId, block?.props?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root'; + return { + type: 'paragraph', + attrs: withTextAlign({ + blockId, + mnoteBlockType: 'mindmap', + mindmapId, + rootNodeId, + }), + }; + } + if (type === 'media') { + const sourcePath = firstNonEmptyText(block?.props?.sourcePath, block?.props?.url, block?.props?.src); + const name = firstNonEmptyText(block?.props?.name, block?.props?.fileName, block?.props?.title, sourcePath); + const mediaContent = name + ? [{ + type: 'text', + text: name, + ...(sourcePath ? { marks: [{ type: 'link', attrs: { href: sourcePath } }] } : {}), + }] + : content; + return { type: 'paragraph', attrs: withTextAlign({ blockId }), content: mediaContent }; + } + if (type === 'table') { + const tableSnapshot = block?.props?.tiptapTable; + if (tableSnapshot && typeof tableSnapshot === 'object' && tableSnapshot.type === 'table') return tableSnapshot; + return { + type: 'table', + content: [{ + type: 'tableRow', + content: [{ + type: 'tableCell', + attrs: { colspan: 1, rowspan: 1, colwidth: null }, + content: [{ type: 'paragraph', content }], + }], + }], + }; + } + if (type === 'toc' || type === 'tocNode' || type === 'toc_node') { + const tocSnapshot = block?.props?.tiptapTocNode || block?.props?.tiptapToc; + if (tocSnapshot && typeof tocSnapshot === 'object' && tocSnapshot.type === 'tocNode') return tocSnapshot; + return { + type: 'tocNode', + attrs: { + topOffset: Number(block?.props?.topOffset || block?.props?.top_offset || 0) || 0, + maxShowCount: Number(block?.props?.maxShowCount || block?.props?.max_show_count || 20) || 20, + showTitle: block?.props?.showTitle !== false, + }, + }; + } + if (type === 'image') { + const imageSnapshot = block?.props?.tiptapImage; + if (imageSnapshot && typeof imageSnapshot === 'object' && imageSnapshot.type === 'image') return imageSnapshot; + const attrs = { + src: String(block?.props?.src || block?.src || ''), + alt: block?.props?.alt || block?.alt || null, + title: block?.props?.title || block?.title || null, + }; + return attrs.src ? { type: 'image', attrs: { blockId, ...attrs } } : null; + } + return { type: 'paragraph', attrs: withTextAlign({ blockId }), content }; +}; + +export const textToTiptapDocument = (text) => ({ + type: 'doc', + content: [{ + type: 'paragraph', + content: text ? [{ type: 'text', text }] : [], + }], +}); + +export const isTiptapDocument = (content) => ( + content && + typeof content === 'object' && + !Array.isArray(content) && + content.type === 'doc' +); + +export const mindmapDomDescriptors = (root) => { + if (!(root instanceof HTMLElement)) return []; + return Array.from(root.querySelectorAll('[data-testid="mnote-mindmap-editor-root"]')) + .flatMap((node) => { + if (!(node instanceof HTMLElement)) return []; + const mindmapId = typeof node.dataset.mnoteMindmapId === 'string' ? node.dataset.mnoteMindmapId.trim() : ''; + if (!mindmapId) return []; + const rootNodeId = typeof node.dataset.mnoteRootNodeId === 'string' && node.dataset.mnoteRootNodeId.trim() + ? node.dataset.mnoteRootNodeId.trim() + : 'root'; + return [{ mindmapId, rootNodeId }]; + }); +}; + +export const hydrateMindmapAttrsFromDom = (tiptapDocument, root) => { + if (!isTiptapDocument(tiptapDocument) || !Array.isArray(tiptapDocument.content)) return tiptapDocument; + const descriptors = mindmapDomDescriptors(root); + if (!descriptors.length) return tiptapDocument; + let index = 0; + for (const node of tiptapDocument.content) { + if (node?.type !== 'paragraph' || node?.attrs?.mnoteBlockType !== 'mindmap') continue; + const descriptor = descriptors[index]; + index += 1; + if (!descriptor) continue; + node.attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {}; + if (typeof node.attrs.mindmapId !== 'string' || !node.attrs.mindmapId.trim()) { + node.attrs.mindmapId = descriptor.mindmapId; + } + if (typeof node.attrs.rootNodeId !== 'string' || !node.attrs.rootNodeId.trim()) { + node.attrs.rootNodeId = descriptor.rootNodeId; + } + } + return tiptapDocument; +}; + +export const toTiptapDocument = (content, fallbackText = '') => { + if (isTiptapDocument(content)) return content; + const blocks = Array.isArray(content) ? content : Array.isArray(content?.blocks) ? content.blocks : []; + const nodes = blocks.map(legacyBlockToTiptap).filter(Boolean); + if (nodes.length) return { type: 'doc', content: nodes }; + return textToTiptapDocument(fallbackText); +}; + +export const decodeLocalIdSegment = (segment) => { + const encoded = String(segment || '').replace(/~([0-9a-fA-F]{2})/g, '%$1'); + try { + return decodeURIComponent(encoded); + } catch (_) { + return String(segment || '').replace(/~2F/g, '/').replace(/~20/g, ' '); + } +}; + +export const localMarkdownRelativePathFromDocumentId = (documentId) => { + const raw = String(documentId || '').trim(); + const segment = raw.startsWith('local-md:') ? raw.slice('local-md:'.length) : raw; + return decodeLocalIdSegment(segment).replace(/^\/+/, ''); +}; + +export const localMarkdownDocumentIdFromRelativePath = (relativePath) => { + const normalized = String(relativePath || '').trim().replace(/^\/+/, ''); + if (!normalized) return ''; + return 'local-md:' + normalized + .split('/') + .map((part) => part.replace(/ /g, '~20')) + .join('~2F'); +}; + +export const localMarkdownDirectoryFromDocumentId = (documentId) => { + const relativePath = localMarkdownRelativePathFromDocumentId(documentId); + const slash = relativePath.lastIndexOf('/'); + return slash >= 0 ? relativePath.slice(0, slash) : ''; +}; + +export const isExternalOrSpecialUrl = (value) => { + const text = String(value || '').trim(); + return !text + || text.startsWith('#') + || text.startsWith('data:') + || text.startsWith('blob:') + || text.startsWith('mailto:') + || text.startsWith('http://') + || text.startsWith('https://') + || text.startsWith('/api/'); +}; + +export const normalizeLocalAssetRelativePath = (value, context) => { + const text = String(value || '').trim(); + if (!text || isExternalOrSpecialUrl(text)) return text; + if (text.startsWith('/')) return text.replace(/^\/+/, ''); + const baseDir = localMarkdownDirectoryFromDocumentId(context?.documentId); + return (baseDir ? `${baseDir}/${text}` : text) + .split('/') + .filter((part) => part && part !== '.') + .join('/'); +}; + +export const localFileOpenUrlForTiptap = (value, context) => { + if (!context || context.sourceKind !== 'local_folder' || !context.rootUri) return value; + const relativePath = normalizeLocalAssetRelativePath(value, context); + if (!relativePath || isExternalOrSpecialUrl(relativePath)) return value; + const url = new URL('/api/local-folder/files/open', window.location.origin); + url.searchParams.set('rootUri', context.rootUri); + url.searchParams.set('path', relativePath); + return url.toString(); +}; + +export const localizeTiptapAssetUrls = (node, context) => { + if (!node || typeof node !== 'object') return node; + if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') { + node.attrs = { ...node.attrs, src: localFileOpenUrlForTiptap(node.attrs.src, context) }; + } + if (Array.isArray(node.marks)) { + node.marks = node.marks.map((mark) => { + if (!mark || mark.type !== 'link' || !mark.attrs || typeof mark.attrs.href !== 'string') return mark; + return { ...mark, attrs: { ...mark.attrs, href: localFileOpenUrlForTiptap(mark.attrs.href, context) } }; + }); + } + if (Array.isArray(node.content)) { + node.content = node.content.map((child) => localizeTiptapAssetUrls(child, context)); + } + return node; +}; + +export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => { + if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) { + return localizeTiptapAssetUrls(toTiptapDocument(body.content, fallbackText), context); + } + const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null; + if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), context); + return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), context); +}; + +export const inlineTextNodes = (node) => { + if (!node || typeof node !== 'object') return []; + if (Array.isArray(node.content)) { + return node.content.flatMap((child) => { + if (child?.type === 'text') { + const text = typeof child.text === 'string' ? child.text : ''; + if (!text) return []; + const styles = {}; + const marks = []; + for (const mark of Array.isArray(child.marks) ? child.marks : []) { + if (mark?.type === 'bold') { + styles.bold = true; + marks.push('bold'); + } + if (mark?.type === 'italic') { + styles.italic = true; + marks.push('italic'); + } + if (mark?.type === 'underline') { + styles.underline = true; + marks.push('underline'); + } + if (mark?.type === 'strike') { + styles.strike = true; + marks.push('strike'); + } + if (mark?.type === 'code') { + styles.code = true; + marks.push('code'); + } + if (mark?.type === 'link') { + const href = typeof mark?.attrs?.href === 'string' ? mark.attrs.href.trim() : ''; + if (href) styles.link = href; + } + } + return [{ + payload: { type: 'text', text, ...(marks.length ? { marks } : {}) }, + attrs: Object.keys(styles).length ? { styles } : {}, + type: 'text', + text, + ...(Object.keys(styles).length ? { styles } : {}), + }]; + } + if (child?.type === 'hardBreak') return [{ payload: { type: 'hard_break' }, attrs: {}, type: 'text', text: '\n' }]; + return inlineTextNodes(child); + }); + } + return []; +}; + +export const firstChild = (node) => Array.isArray(node?.content) ? node.content[0] : null; +export const blockIdOf = (node, index) => { + const raw = typeof node?.attrs?.blockId === 'string' ? node.attrs.blockId.trim() : ''; + return raw || `block-${index + 1}`; +}; +export const mindmapPropsFromAttrs = (attrs, fallbackMindmapId) => { + const data = attrs?.data && typeof attrs.data === 'object' ? attrs.data : {}; + const mindmapId = firstNonEmptyText( + attrs?.mindmapId, + attrs?.mindmap_id, + attrs?.sourcePath, + attrs?.source_path, + data?.mindmapId, + data?.mindmap_id, + data?.id, + fallbackMindmapId + ); + const rootNodeId = firstNonEmptyText(attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root'; + const projectionVersion = Number(attrs?.projectionVersion ?? attrs?.projection_version); + return { + mindmapId, + rootNodeId, + ...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}), + }; +}; + +export const tiptapNodeToEditorBlock = (node, index) => { + const blockId = blockIdOf(node, index); + if (node?.type === 'paragraph' && node?.attrs?.mnoteBlockType === 'mindmap') { + const mindmapId = firstNonEmptyText( + node?.attrs?.mindmapId, + node?.attrs?.mindmap_id, + node?.attrs?.sourcePath, + node?.attrs?.source_path, + blockId + ); + return { + blockId, + blockType: 'mindmap', + props: { + ...mindmapPropsFromAttrs(node?.attrs, blockId), + sourcePath: mindmapId, + }, + contentNodes: [], + childBlockIds: [], + }; + } + if (node?.type === 'paragraph') return { blockId, blockType: 'paragraph', props: {}, contentNodes: inlineTextNodes(node), childBlockIds: [] }; + if (node?.type === 'heading') { + const level = Math.max(1, Math.min(6, Number(node?.attrs?.level || 1) || 1)); + return { blockId, blockType: 'heading', props: { headingLevel: level }, contentNodes: inlineTextNodes(node), childBlockIds: [] }; + } + if (node?.type === 'bulletList') return { blockId, blockType: 'bullet_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] }; + if (node?.type === 'orderedList') return { blockId, blockType: 'numbered_list_item', props: {}, contentNodes: inlineTextNodes(firstChild(firstChild(node))), childBlockIds: [] }; + if (node?.type === 'taskList') { + const taskItem = firstChild(node); + return { blockId, blockType: 'todo', props: { checked: Boolean(taskItem?.attrs?.checked) }, contentNodes: inlineTextNodes(firstChild(taskItem)), childBlockIds: [] }; + } + if (node?.type === 'blockquote') return { blockId, blockType: 'quote', props: {}, contentNodes: inlineTextNodes(firstChild(node)), childBlockIds: [] }; + if (node?.type === 'codeBlock') return { blockId, blockType: 'code_block', props: { language: typeof node?.attrs?.language === 'string' ? node.attrs.language : null }, contentNodes: inlineTextNodes(node), childBlockIds: [] }; + if (node?.type === 'horizontalRule') return { blockId, blockType: 'divider', props: {}, contentNodes: [], childBlockIds: [] }; + if (node?.type === 'image') return { blockId, blockType: 'image', props: { src: node?.attrs?.src || '', alt: node?.attrs?.alt || null, title: node?.attrs?.title || null, tiptapImage: node }, contentNodes: [], childBlockIds: [] }; + if (node?.type === 'tocNode') return { blockId, blockType: 'toc', props: { tiptapTocNode: node }, contentNodes: [], childBlockIds: [] }; + if (node?.type === 'table') return { blockId, blockType: 'table', props: { tiptapTable: node }, contentNodes: inlineTextNodes(node), childBlockIds: [] }; + return null; +}; + +export const editorDocumentFromTiptapDocument = (bootstrap, tiptapDocument) => { + const content = Array.isArray(tiptapDocument?.content) ? tiptapDocument.content : []; + const blocks = content.map(tiptapNodeToEditorBlock).filter(Boolean); + return { + documentId: bootstrap.documentId, + rootBlockIds: blocks.map((block) => block.blockId), + blocks, + }; +}; + +export const legacyBlocksFromEditorDocument = (editorDocument) => ( + Array.isArray(editorDocument?.blocks) ? editorDocument.blocks : [] +).map((block) => ({ + id: block.blockId, + type: block.blockType, + props: block.blockType === 'heading' + ? { level: block.props?.headingLevel || 1 } + : block.blockType === 'todo' + ? { checked: Boolean(block.props?.checked) } + : block.blockType === 'code_block' + ? { language: block.props?.language || null } + : block.blockType === 'mindmap' + ? mindmapPropsFromAttrs(block.props || {}, block.blockId) + : block.blockType === 'image' + ? { ...(block.props || {}) } + : block.blockType === 'toc' + ? { ...(block.props || {}) } + : block.blockType === 'table' + ? { ...(block.props || {}) } + : undefined, + content: block.blockType === 'mindmap' + ? '' + : Array.isArray(block.contentNodes) + ? block.contentNodes.map((node) => { + if (!node || typeof node !== 'object') return null; + const payload = node.payload && typeof node.payload === 'object' ? node.payload : {}; + const text = typeof payload.text === 'string' + ? payload.text + : payload.type === 'hard_break' + ? '\n' + : typeof node.text === 'string' + ? node.text + : ''; + if (!text) return null; + const attrs = node.attrs && typeof node.attrs === 'object' ? node.attrs : {}; + const styles = attrs.styles && typeof attrs.styles === 'object' + ? attrs.styles + : node.styles && typeof node.styles === 'object' + ? node.styles + : null; + const marks = Array.isArray(payload.marks) ? payload.marks : []; + return { + type: 'text', + text, + ...(styles ? { styles } : {}), + ...(marks.length ? { marks } : {}), + }; + }).filter(Boolean) + : '', +})); + +export const conflictDetectionKeyFromBody = (body) => typeof body?.conflictDetectionKey === 'string' + ? body.conflictDetectionKey + : typeof body?.conflict_detection_key === 'string' + ? body.conflict_detection_key + : typeof body?.fileVersion === 'string' + ? body.fileVersion + : typeof body?.file_version === 'string' + ? body.file_version + : null; + +export const conflictDetectionKeyBelongsToSession = (session, key) => { + if (!session || session.sourceKind !== 'local_folder') return true; + const value = String(key || '').trim(); + if (!value) return false; + return value.startsWith(`local-md:${session.documentId}:`); +}; + +export const revisionFromConflictKey = (value) => { + const match = String(value || '').match(/:(\d+)$/); + return match ? Number(match[1]) : null; +}; diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 670a1b89..7ef22ccb 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -142,6 +142,10 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/document-conflict-panel-runtime.js", get(web_shell::document_conflict_panel_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js", + get(web_shell::document_tiptap_conversion_runtime_asset), + ) .route( "/api/mnote-browser-runtime/document-editor-adapter-runtime.js", get(web_shell::document_editor_adapter_runtime_asset), @@ -559,6 +563,7 @@ mod tests { "/api/mnote-browser-runtime/tree-live-controller.js", "/api/mnote-browser-runtime/tree-shell-runtime.js", "/api/mnote-browser-runtime/document-conflict-panel-runtime.js", + "/api/mnote-browser-runtime/document-tiptap-conversion-runtime.js", "/api/mnote-browser-runtime/document-editor-adapter-runtime.js", ] { let response = app(false) diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index f3ede095..a0cc95d2 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -855,6 +855,20 @@ pub async fn document_conflict_panel_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn document_tiptap_conversion_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/document-tiptap-conversion-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn document_editor_adapter_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/document-editor-adapter-runtime.js"); Response::builder() @@ -1278,6 +1292,8 @@ mod tests { const DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS: &str = include_str!("../../browser/document-editor-adapter-runtime.js"); + const DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS: &str = + include_str!("../../browser/document-tiptap-conversion-runtime.js"); fn app() -> axum::Router { build_app(AppState::new(AppConfig { @@ -1566,11 +1582,14 @@ mod tests { assert!(html.contains("/api/tree/events")); assert!(html.contains("/api/realtime/ws")); assert!(runtime.contains("syncPageAggregateScript(session, nextAggregate);")); - assert!(runtime.contains( + let conversion_runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS; + assert!(conversion_runtime.contains( "const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {" )); - assert!(runtime.contains("body?.blockDocument || body?.block_document")); - assert!(runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content")); + assert!(conversion_runtime.contains("body?.blockDocument || body?.block_document")); + assert!( + conversion_runtime.contains("localizeTiptapAssetUrls(toTiptapDocument(body.content") + ); assert!(runtime .contains("const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);")); assert!(runtime.contains( @@ -2022,7 +2041,10 @@ mod tests { assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("PANES_BOOTSTRAP_ID")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("ROOT_SELECTOR")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("BRIDGE_PROTOCOL")); - assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("legacyInlineContentToTiptap")); + assert!( + DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document-tiptap-conversion-runtime.js") + ); + assert!(DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS.contains("legacyInlineContentToTiptap")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openSecondaryDocument")); assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("openPrimaryMindmap")); } @@ -2166,7 +2188,7 @@ mod tests { .expect("body"); let html = String::from_utf8(body.to_vec()).expect("html"); assert!(html.contains(r#""#)); - let runtime = DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS; + let runtime = DOCUMENT_TIPTAP_CONVERSION_RUNTIME_JS; assert!(runtime.contains("legacyInlineContentToTiptap")); assert!(runtime.contains("legacyStylesToTiptapMarks")); assert!(runtime.contains("legacyMarkArrayToTiptapMarks")); @@ -2183,7 +2205,8 @@ mod tests { assert!(runtime.contains("typeof payload.text === 'string'")); assert!(runtime.contains("payload.type === 'hard_break'")); assert!(runtime.contains("typeof body?.fileVersion === 'string'")); - assert!(runtime.contains("expectedFileVersion: session.conflictDetectionKey")); + assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS + .contains("expectedFileVersion: session.conflictDetectionKey")); assert!(runtime.contains("node?.attrs?.mnoteBlockType === 'mindmap'")); assert!(runtime.contains("blockType: 'mindmap'")); assert!(runtime.contains("...mindmapPropsFromAttrs(node?.attrs, blockId)"));