Files
mnote/rust/crates/mnote-web/browser/document-tiptap-conversion-runtime.js
T

857 lines
36 KiB
JavaScript

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 '';
};
export const normalizeMindmapDimension = (value, kind) => {
const raw = typeof value === 'number'
? value
: typeof value === 'string'
? Number(value.trim().replace(/px$/i, ''))
: NaN;
if (!Number.isFinite(raw)) return null;
const rounded = Math.round(raw);
const min = kind === 'height' ? 240 : 900;
return rounded >= min ? rounded : null;
};
// 过渡适配(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) => {
const markType = typeof mark === 'string' ? mark : typeof mark?.type === 'string' ? mark.type : '';
if (!markType) return [];
if (markType === 'bold' || markType === 'italic' || markType === 'underline' || markType === 'strike' || markType === 'code') {
return [{ type: markType }];
}
if (markType === '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;
});
};
const legacyBlockAttrs = (block) => (
{
...(block?.attrs && typeof block.attrs === 'object' ? block.attrs : {}),
...(block?.props && typeof block.props === 'object' ? block.props : {}),
}
);
const legacyBlockAttr = (block, ...keys) => {
const props = block?.props && typeof block.props === 'object' ? block.props : {};
const attrs = block?.attrs && typeof block.attrs === 'object' ? block.attrs : {};
for (const key of keys) {
if (props[key] !== undefined) return props[key];
if (attrs[key] !== undefined) return attrs[key];
}
return undefined;
};
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 payload = value.payload && typeof value.payload === 'object' ? value.payload : null;
if (payload?.type === 'hard_break') return [{ type: 'hardBreak' }];
const text = typeof value.text === 'string'
? value.text
: typeof payload?.text === 'string'
? payload.text
: '';
if (text) {
const attrs = value.attrs && typeof value.attrs === 'object' ? value.attrs : {};
const marks = mergeTiptapMarks(
legacyStylesToTiptapMarks(attrs.styles),
legacyStylesToTiptapMarks(value.styles),
legacyMarkArrayToTiptapMarks(payload?.marks),
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 blockAttrs = legacyBlockAttrs(block);
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 legacyBlockAttr(block, 'textAlign') === 'string'
? legacyBlockAttr(block, 'textAlign')
: typeof legacyBlockAttr(block, 'text_align') === 'string'
? legacyBlockAttr(block, '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(legacyBlockAttr(block, 'level', 'headingLevel') || block?.level || 1) || 1;
const collapsed = typeof legacyBlockAttr(block, 'collapsed') === 'boolean' ? { collapsed: legacyBlockAttr(block, '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: legacyBlockAttr(block, 'checked') === true });
}
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: legacyBlockAttr(block, 'language') || null }), content };
}
if (type === 'divider' || type === 'horizontalRule' || type === 'horizontal_rule') return { type: 'horizontalRule', attrs: { blockId } };
if (type === 'mindmap') {
const attrs = blockAttrs;
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,
attrs?.mindmapId,
attrs?.mindmap_id,
attrs?.sourcePath,
attrs?.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, attrs?.rootNodeId, attrs?.root_node_id, data?.rootNodeId, data?.root_node_id) || 'root';
const mindmapWidth = normalizeMindmapDimension(block?.props?.mindmapWidth ?? block?.props?.mindmap_width ?? attrs?.mindmapWidth ?? attrs?.mindmap_width ?? data?.mindmapWidth ?? data?.mindmap_width, 'width');
const mindmapHeight = normalizeMindmapDimension(block?.props?.mindmapHeight ?? block?.props?.mindmap_height ?? attrs?.mindmapHeight ?? attrs?.mindmap_height ?? data?.mindmapHeight ?? data?.mindmap_height, 'height');
return {
type: 'paragraph',
attrs: withTextAlign({
blockId,
mnoteBlockType: 'mindmap',
mindmapId,
rootNodeId,
...(mindmapWidth !== null ? { mindmapWidth } : {}),
...(mindmapHeight !== null ? { mindmapHeight } : {}),
}),
};
}
if (type === 'page_reference' || type === 'pageReference' || type === 'pagereference') {
const sourcePath = firstNonEmptyText(
block?.props?.sourcePath,
block?.props?.source_path,
block?.props?.href,
block?.props?.url,
blockAttrs?.sourcePath,
blockAttrs?.source_path,
blockAttrs?.href,
blockAttrs?.url,
blockAttrs?.pageId,
blockAttrs?.page_id
);
const title = firstNonEmptyText(
block?.props?.title,
block?.props?.name,
blockAttrs?.title,
blockAttrs?.name,
content,
sourcePath
) || '页面';
return {
type: 'paragraph',
attrs: withTextAlign({ blockId }),
content: [{
type: 'text',
text: title,
marks: [{
type: 'link',
attrs: {
href: sourcePath || '#',
target: '_self',
rel: 'noopener noreferrer nofollow',
class: 'mnote-page-block-link',
},
}],
}],
};
}
if (type === 'media') {
const sourcePath = firstNonEmptyText(
block?.props?.sourcePath,
block?.props?.source_path,
block?.props?.url,
block?.props?.src,
blockAttrs?.sourcePath,
blockAttrs?.source_path,
blockAttrs?.url,
blockAttrs?.src
);
const name = firstNonEmptyText(
block?.props?.name,
block?.props?.fileName,
block?.props?.file_name,
block?.props?.title,
blockAttrs?.name,
blockAttrs?.fileName,
blockAttrs?.file_name,
blockAttrs?.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 || block?.attrs?.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 || block?.attrs?.tiptapImage;
if (imageSnapshot && typeof imageSnapshot === 'object' && imageSnapshot.type === 'image') return imageSnapshot;
const attrsSource = block?.attrs && typeof block.attrs === 'object' ? block.attrs : {};
const attrs = {
src: String(block?.props?.src || attrsSource.src || block?.src || ''),
alt: block?.props?.alt || attrsSource.alt || block?.alt || null,
title: block?.props?.title || attrsSource.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';
const mindmapWidth = normalizeMindmapDimension(node.dataset.mnoteMindmapWidth, 'width');
const mindmapHeight = normalizeMindmapDimension(node.dataset.mnoteMindmapHeight, 'height');
return [{ mindmapId, rootNodeId, mindmapWidth, mindmapHeight }];
});
};
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;
}
if (normalizeMindmapDimension(node.attrs.mindmapWidth, 'width') === null && descriptor.mindmapWidth !== null) {
node.attrs.mindmapWidth = descriptor.mindmapWidth;
}
if (normalizeMindmapDimension(node.attrs.mindmapHeight, 'height') === null && descriptor.mindmapHeight !== null) {
node.attrs.mindmapHeight = descriptor.mindmapHeight;
}
}
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) => encodeURIComponent(part).replace(/%/g, '~'))
.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 = unwrapMarkdownLinkTarget(value);
return !text
|| text.startsWith('#')
|| text.startsWith('data:')
|| text.startsWith('blob:')
|| text.startsWith('mailto:')
|| text.startsWith('http://')
|| text.startsWith('https://')
|| text.startsWith('/api/');
};
export const unwrapMarkdownLinkTarget = (value) => {
const text = String(value || '').trim();
if (text.length >= 2 && text.startsWith('<') && text.endsWith('>')) {
return text.slice(1, -1).trim();
}
return text;
};
export const normalizeLocalAssetRelativePath = (value, context) => {
const text = unwrapMarkdownLinkTarget(value);
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 normalizeLocalPageRelativePath = (value, context) => {
const text = unwrapMarkdownLinkTarget(value);
if (!text || text.startsWith('#')) return '';
if (text.startsWith('/documents/')) return text;
if (text.startsWith('/api/') || text.startsWith('http://') || text.startsWith('https://') || text.startsWith('mailto:')) return '';
const baseDir = text.startsWith('/') ? '' : localMarkdownDirectoryFromDocumentId(context?.documentId);
const parts = (baseDir ? `${baseDir}/${text}` : text)
.replace(/\\/g, '/')
.split('/');
const normalized = [];
for (const part of parts) {
if (!part || part === '.') continue;
if (part === '..') {
if (!normalized.length) return '';
normalized.pop();
continue;
}
normalized.push(part);
}
return normalized.join('/');
};
export const localPageOpenUrlForTiptap = (value, context) => {
const text = unwrapMarkdownLinkTarget(value);
if (!context || context.sourceKind !== 'local_folder' || !context.rootUri) return text || value;
if (text.startsWith('/documents/')) {
const url = new URL(text, window.location.origin);
if (!url.searchParams.get('sourceKind')) url.searchParams.set('sourceKind', 'local_folder');
if (!url.searchParams.get('rootUri')) url.searchParams.set('rootUri', context.rootUri);
return `${url.pathname}${url.search}`;
}
const relativePath = normalizeLocalPageRelativePath(text, context);
if (!relativePath) return text || value;
const documentId = localMarkdownDocumentIdFromRelativePath(relativePath);
if (!documentId) return text || value;
const url = new URL(`/documents/${documentId}`, window.location.origin);
url.searchParams.set('sourceKind', 'local_folder');
url.searchParams.set('rootUri', context.rootUri);
url.searchParams.set('treeView', 'filetree');
return `${url.pathname}${url.search}`;
};
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 localFileOpenPathFromTiptapHref = (href) => {
try {
const url = new URL(String(href || ''), window.location.origin);
if (url.pathname !== '/api/local-folder/files/open') return '';
return String(url.searchParams.get('path') || '').trim();
} catch (_) {
return '';
}
};
export const fileNameFromPath = (path) => {
const value = String(path || '').trim();
return value.includes('/') ? value.split('/').pop() : value;
};
export const attachmentClassForFileName = (fileName) => {
const name = String(fileName || '').trim().toLowerCase();
const ext = name.includes('.') ? name.split('.').pop() : '';
if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-word';
if (['ppt', 'pptx', 'odp'].includes(ext)) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-ppt';
if (['xls', 'xlsx', 'ods', 'csv'].includes(ext)) return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-sheet';
if (ext === 'pdf') return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-pdf';
if (['md', 'markdown', 'txt', 'json', 'js', 'ts', 'tsx', 'jsx', 'rs', 'py', 'go', 'java', 'kt', 'swift', 'c', 'cpp', 'h', 'hpp', 'css', 'scss', 'html', 'xml', 'yaml', 'yml', 'toml', 'ini', 'sh', 'bash', 'zsh', 'sql', 'vue', 'svelte'].includes(ext)) {
return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-code';
}
return 'mnote-uploaded-attachment-row mnote-uploaded-attachment-file';
};
export const mergeClassNames = (...values) => {
const seen = new Set();
return values
.flatMap((value) => String(value || '').split(/\s+/))
.filter((name) => {
if (!name || seen.has(name)) return false;
seen.add(name);
return true;
})
.join(' ');
};
export const localAttachmentClassForTiptapHref = (href) => {
const localFilePath = localFileOpenPathFromTiptapHref(href);
if (localFilePath) return attachmentClassForFileName(fileNameFromPath(localFilePath));
const value = String(href || '').trim();
if (!value || isExternalOrSpecialUrl(value)) return '';
if (value.startsWith('../') || value.includes('/../')) return '';
return attachmentClassForFileName(fileNameFromPath(value));
};
const attachmentRefsFromContext = (context) => {
if (Array.isArray(context?.attachmentRefs)) return context.attachmentRefs;
if (Array.isArray(context?.attachment_refs)) return context.attachment_refs;
if (Array.isArray(context?.body?.attachmentRefs)) return context.body.attachmentRefs;
if (Array.isArray(context?.body?.attachment_refs)) return context.body.attachment_refs;
if (Array.isArray(context?.latestAggregate?.body?.attachmentRefs)) return context.latestAggregate.body.attachmentRefs;
if (Array.isArray(context?.latestAggregate?.body?.attachment_refs)) return context.latestAggregate.body.attachment_refs;
if (Array.isArray(context?.aggregate?.body?.attachmentRefs)) return context.aggregate.body.attachmentRefs;
if (Array.isArray(context?.aggregate?.body?.attachment_refs)) return context.aggregate.body.attachment_refs;
return [];
};
const attachmentRefForTiptapHref = (href, context) => {
const value = String(href || '').trim();
if (!value) return null;
return attachmentRefsFromContext(context).find((ref) => (
ref && typeof ref === 'object' && (
String(ref.rawHref || '') === value
|| String(ref.normalizedHref || '') === value
|| String(ref.resolvedUri || '') === value
)
)) || null;
};
const withAttachmentProjectionAttrs = (attrs, attachmentRef) => {
if (!attachmentRef || typeof attachmentRef !== 'object') return attrs;
const next = { ...attrs };
const fileSize = attachmentFileSizeText(attachmentRef);
if (fileSize) next['data-file-size'] = fileSize;
if (attachmentRef.exists === false) {
next.class = mergeClassNames(next.class, 'mnote-uploaded-attachment-missing');
next['data-mnote-attachment-missing'] = 'true';
next['aria-label'] = `${String(attachmentRef.label || '附件')}(文件不存在)`;
}
if (attachmentRef.authorized === false) {
next.class = mergeClassNames(next.class, 'mnote-uploaded-attachment-unauthorized');
next['data-mnote-attachment-unauthorized'] = 'true';
next['aria-label'] = `${String(attachmentRef.label || '附件')}(无权访问)`;
}
return next;
};
export const attachmentFileSizeText = (attachmentRef) => {
const size = Number(attachmentRef && (attachmentRef.fileSize || attachmentRef.file_size) || 0);
if (!Number.isFinite(size) || size <= 0) return '';
if (size >= 1024 * 1024) return `${(size / 1024 / 1024).toFixed(size >= 10 * 1024 * 1024 ? 1 : 2)} MB`;
if (size >= 1024) return `${(size / 1024).toFixed(size >= 100 * 1024 ? 0 : 2)} KB`;
return `${Math.round(size)} B`;
};
export const localizeTiptapAssetUrls = (node, context) => {
if (!node || typeof node !== 'object') return node;
if (node.type === 'image' && node.attrs && typeof node.attrs.src === 'string') {
const originalSrc = String(node.attrs.src || '').trim();
node.attrs = {
...node.attrs,
src: localFileOpenUrlForTiptap(originalSrc, context),
mnoteMarkdownSrc: node.attrs.mnoteMarkdownSrc || originalSrc,
};
}
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;
const href = String(mark.attrs.href || '').trim();
const markClass = String(mark.attrs.class || '');
if (markClass.split(/\s+/).includes('mnote-page-block-link')) {
return {
...mark,
attrs: {
...mark.attrs,
href: localPageOpenUrlForTiptap(href, context),
target: mark.attrs.target || '_self',
rel: mark.attrs.rel || 'noopener noreferrer nofollow',
class: mergeClassNames(mark.attrs.class, 'mnote-page-block-link'),
},
};
}
const attachmentClass = localAttachmentClassForTiptapHref(href);
const attachmentRef = attachmentRefForTiptapHref(href, context);
const localizedHref = attachmentClass ? localFileOpenUrlForTiptap(href, context) : href;
const attrs = attachmentClass
? withAttachmentProjectionAttrs({
...mark.attrs,
href: localizedHref,
class: mergeClassNames(mark.attrs.class, attachmentClass),
target: mark.attrs.target || '_blank',
rel: mark.attrs.rel || 'noopener noreferrer nofollow',
}, attachmentRef)
: { ...mark.attrs, href };
return { ...mark, attrs };
});
}
if (Array.isArray(node.content)) {
node.content = node.content.map((child) => localizeTiptapAssetUrls(child, context));
}
return node;
};
export const pageBodyTiptapDocumentSource = (body, fallbackText = '') => {
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
if (blockDocument) return 'page_aggregate.block_document';
if (String(body?.projectionSource || body?.projection_source || '') === 'local_markdown.content' && body?.content) {
return 'local_markdown.content';
}
if (body?.content) return 'compat.legacy_content';
return fallbackText ? 'degraded.fallback_text' : 'empty';
};
export const pageBodyTiptapDocument = (body, fallbackText = '', context = null) => {
const projectionContext = { ...(context || {}), body };
const blockDocument = body?.blockDocument || body?.block_document || body?.editorDocument || body?.editor_document || null;
if (blockDocument) return localizeTiptapAssetUrls(toTiptapDocument(blockDocument, fallbackText), projectionContext);
return localizeTiptapAssetUrls(toTiptapDocument(body?.content, fallbackText), projectionContext);
};
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);
const mindmapWidth = normalizeMindmapDimension(attrs?.mindmapWidth ?? attrs?.mindmap_width ?? data?.mindmapWidth ?? data?.mindmap_width, 'width');
const mindmapHeight = normalizeMindmapDimension(attrs?.mindmapHeight ?? attrs?.mindmap_height ?? data?.mindmapHeight ?? data?.mindmap_height, 'height');
return {
mindmapId,
rootNodeId,
...(Number.isFinite(projectionVersion) ? { projectionVersion } : {}),
...(mindmapWidth !== null ? { mindmapWidth } : {}),
...(mindmapHeight !== null ? { mindmapHeight } : {}),
};
};
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?.mnoteMarkdownSrc || 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;
};