Persist PageTree expand state via control-plane view-state and align chevron/DOM with restored expansion; keep Sidex-style shallow page-tree scan and drop the unused recursive scanner that only added cargo noise. Add password vault workbench routes/runtime/skill/CLI, split page_ai_pi into a module package, and retire Hermes/ACP/OpenHub recycle + root harness evidence from the index while gitignoring recycle and local diag dumps. Archive superseded design/bugs docs under old/, point architecture at ARCHITECTURE.md, and refresh smokes for Pi S1–S7, vault, and editor regressions so the working tree can stay clean.
908 lines
38 KiB
JavaScript
908 lines
38 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 localMarkdownRelativePathFromDocumentsHref = (href) => {
|
|
const value = String(href || '').trim();
|
|
if (!value) return '';
|
|
let url = null;
|
|
try {
|
|
url = new URL(value, window.location?.origin || 'http://127.0.0.1:3000');
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
if (!url.pathname.startsWith('/documents/')) return '';
|
|
const segment = decodeURIComponent(url.pathname.slice('/documents/'.length));
|
|
if (!segment.startsWith('local-md:')) return '';
|
|
return localMarkdownRelativePathFromDocumentId(segment);
|
|
};
|
|
|
|
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 currentPath = localMarkdownRelativePathFromDocumentId(context?.documentId);
|
|
const textPath = text.replace(/^\/+/, '');
|
|
const currentTop = currentPath.split('/').filter(Boolean)[0] || '';
|
|
const textTop = textPath.split('/').filter(Boolean)[0] || '';
|
|
const looksRootRelative = Boolean(currentTop && textTop && currentTop === textTop);
|
|
const baseDir = text.startsWith('/') || looksRootRelative ? '' : 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') {
|
|
const children = Array.isArray(node?.content) ? node.content : [];
|
|
if (children.length === 1 && children[0]?.type === 'text') {
|
|
const textNode = children[0];
|
|
const linkMark = Array.isArray(textNode.marks)
|
|
? textNode.marks.find((mark) => {
|
|
const className = String(mark?.attrs?.class || '');
|
|
return mark?.type === 'link' && className.split(/\s+/).includes('mnote-page-block-link');
|
|
})
|
|
: null;
|
|
if (linkMark?.attrs?.href) {
|
|
const href = String(linkMark.attrs.href || '').trim();
|
|
const sourcePath = localMarkdownRelativePathFromDocumentsHref(href) || unwrapMarkdownLinkTarget(href);
|
|
return {
|
|
blockId,
|
|
blockType: 'page_reference',
|
|
props: {
|
|
title: String(textNode.text || '').trim() || '页面',
|
|
sourcePath,
|
|
},
|
|
contentNodes: inlineTextNodes(node),
|
|
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 === 'page_reference'
|
|
? {
|
|
title: block.props?.title || flattenText(block.contentNodes || []) || '页面',
|
|
sourcePath: block.props?.sourcePath || block.props?.source_path || block.props?.href || '',
|
|
}
|
|
: 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;
|
|
};
|