From f31892a15b69a1b7db2f9b208e2586d846befeae Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 23 Mar 2026 20:37:31 +0800 Subject: [PATCH] feat(web-ui): improve markdown editor compatibility --- pnpm-lock.yaml | 117 +- src/web-ui/package.json | 7 +- .../components/panels/base/FlexiblePanel.tsx | 2 +- .../components/Markdown/Markdown.scss | 41 + .../components/Markdown/Markdown.tsx | 254 +++- src/web-ui/src/locales/en-US/tools.json | 16 +- src/web-ui/src/locales/zh-CN/tools.json | 16 +- .../editor/components/MarkdownEditor.scss | 112 +- .../editor/components/MarkdownEditor.tsx | 148 +- .../components/InlineMarkdownPreview.tsx | 106 +- .../editor/meditor/components/MEditor.scss | 8 - .../editor/meditor/components/MEditor.tsx | 51 +- .../editor/meditor/components/Preview.scss | 73 +- .../editor/meditor/components/Preview.tsx | 121 +- .../meditor/components/TiptapEditor.scss | 302 ++++ .../meditor/components/TiptapEditor.tsx | 17 + .../meditor/extensions/BlockIdExtension.ts | 3 + .../extensions/MarkdownAlignmentExtension.ts | 3 + .../meditor/extensions/RawHtmlExtensions.ts | 706 +++++++++ .../tools/editor/meditor/hooks/useMarkdown.ts | 61 - src/web-ui/src/tools/editor/meditor/index.ts | 5 +- .../src/tools/editor/meditor/types/index.ts | 17 - .../tools/editor/meditor/utils/markdown.ts | 135 -- .../editor/meditor/utils/rehype-mermaid.ts | 65 - .../meditor/utils/tiptapMarkdown.test.ts | 242 +++ .../editor/meditor/utils/tiptapMarkdown.ts | 1301 +++++++++++++++-- 26 files changed, 3120 insertions(+), 809 deletions(-) create mode 100644 src/web-ui/src/tools/editor/meditor/extensions/RawHtmlExtensions.ts delete mode 100644 src/web-ui/src/tools/editor/meditor/hooks/useMarkdown.ts delete mode 100644 src/web-ui/src/tools/editor/meditor/utils/markdown.ts delete mode 100644 src/web-ui/src/tools/editor/meditor/utils/rehype-mermaid.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c80fc769..14e9d0f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: '@tiptap/core': specifier: ^3.20.4 version: 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/extension-details': + specifier: ^3.20.4 + version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/extension-text-style@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)))(@tiptap/pm@3.20.4) '@tiptap/extension-link': specifier: ^3.20.4 version: 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) @@ -251,15 +254,15 @@ importers: react-virtuoso: specifier: ^4.14.1 version: 4.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - rehype-highlight: - specifier: ^7.0.2 - version: 7.0.2 rehype-katex: specifier: ^7.0.1 version: 7.0.1 - rehype-stringify: - specifier: ^10.0.1 - version: 10.0.1 + rehype-raw: + specifier: ^7.0.0 + version: 7.0.0 + rehype-sanitize: + specifier: ^6.0.0 + version: 6.0.0 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -1449,6 +1452,13 @@ packages: peerDependencies: '@tiptap/core': ^3.20.4 + '@tiptap/extension-details@3.20.4': + resolution: {integrity: sha512-AFOKfnnfe6j6O+KGWy1Lmb4Pu8xuRvohB6TEPgkad01c2zlB00I+shdjKv+Tb9sr4k6Zho2bXb1rePhjEz9ZQw==} + peerDependencies: + '@tiptap/core': ^3.20.4 + '@tiptap/extension-text-style': ^3.20.4 + '@tiptap/pm': ^3.20.4 + '@tiptap/extension-document@3.20.4': resolution: {integrity: sha512-zF1CIFVLt8MfSpWWnPwtGyxPOsT0xYM2qJKcXf2yZcTG37wDKmUi6heG53vGigIavbQlLaAFvs+1mNdOu2x/0A==} peerDependencies: @@ -1544,6 +1554,11 @@ packages: peerDependencies: '@tiptap/extension-list': ^3.20.4 + '@tiptap/extension-text-style@3.20.4': + resolution: {integrity: sha512-PvW0Ja7ahWpo4bRuR8YCCVv4PH8lXjzhzlBAa4bMbsumOg+GbhX8Su7fwqd+IIPrHqfPXz9HTBMApSfzP6/08A==} + peerDependencies: + '@tiptap/core': ^3.20.4 + '@tiptap/extension-text@3.20.4': resolution: {integrity: sha512-jchJcBZixDEO2J66Zx5dchsI2mA6IYsROqF8P1poxL4ienH7RVQRCTsBNnSfIeOtREKKWeOU/tEs5fcpvvGwIQ==} peerDependencies: @@ -3067,12 +3082,18 @@ packages: hast-util-parse-selector@4.0.0: resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} - hast-util-to-html@9.0.5: - resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + hast-util-to-text@4.0.2: resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} @@ -3488,9 +3509,6 @@ packages: lowlight@1.20.0: resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==} - lowlight@3.3.0: - resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} - lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -4200,14 +4218,14 @@ packages: refractor@3.6.0: resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==} - rehype-highlight@7.0.2: - resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} - rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} - rehype-stringify@10.0.1: - resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -6045,6 +6063,12 @@ snapshots: dependencies: '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/extension-details@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/extension-text-style@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)))(@tiptap/pm@3.20.4)': + dependencies: + '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/extension-text-style': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4)) + '@tiptap/pm': 3.20.4 + '@tiptap/extension-document@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': dependencies: '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) @@ -6124,6 +6148,10 @@ snapshots: dependencies: '@tiptap/extension-list': 3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))(@tiptap/pm@3.20.4) + '@tiptap/extension-text-style@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': + dependencies: + '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) + '@tiptap/extension-text@3.20.4(@tiptap/core@3.20.4(@tiptap/pm@3.20.4))': dependencies: '@tiptap/core': 3.20.4(@tiptap/pm@3.20.4) @@ -8094,20 +8122,28 @@ snapshots: dependencies: '@types/hast': 3.0.4 - hast-util-to-html@9.0.5: + hast-util-raw@9.1.0: dependencies: '@types/hast': 3.0.4 '@types/unist': 3.0.3 - ccount: 2.0.1 - comma-separated-tokens: 2.0.3 - hast-util-whitespace: 3.0.0 + '@ungap/structured-clone': 1.3.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 html-void-elements: 3.0.0 mdast-util-to-hast: 13.2.1 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - stringify-entities: 4.0.4 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -8128,6 +8164,16 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + hast-util-to-text@4.0.2: dependencies: '@types/hast': 3.0.4 @@ -8538,12 +8584,6 @@ snapshots: fault: 1.0.4 highlight.js: 10.7.3 - lowlight@3.3.0: - dependencies: - '@types/hast': 3.0.4 - devlop: 1.1.0 - highlight.js: 11.11.1 - lru-cache@10.4.3: {} lru-cache@11.2.6: {} @@ -9611,14 +9651,6 @@ snapshots: parse-entities: 2.0.0 prismjs: 1.27.0 - rehype-highlight@7.0.2: - dependencies: - '@types/hast': 3.0.4 - hast-util-to-text: 4.0.2 - lowlight: 3.3.0 - unist-util-visit: 5.1.0 - vfile: 6.0.3 - rehype-katex@7.0.1: dependencies: '@types/hast': 3.0.4 @@ -9629,11 +9661,16 @@ snapshots: unist-util-visit-parents: 6.0.2 vfile: 6.0.3 - rehype-stringify@10.0.1: + rehype-raw@7.0.0: dependencies: '@types/hast': 3.0.4 - hast-util-to-html: 9.0.5 - unified: 11.0.5 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 remark-gfm@4.0.1: dependencies: diff --git a/src/web-ui/package.json b/src/web-ui/package.json index 2a1a48a6..2c7cd73b 100644 --- a/src/web-ui/package.json +++ b/src/web-ui/package.json @@ -17,12 +17,13 @@ "dependencies": { "@monaco-editor/react": "^4.6.0", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-autostart": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-fs": "^2.0.0", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-opener": "^2.5.2", - "@tauri-apps/plugin-autostart": "^2.0.0", "@tiptap/core": "^3.20.4", + "@tiptap/extension-details": "^3.20.4", "@tiptap/extension-link": "^3.20.4", "@tiptap/extension-placeholder": "^3.20.4", "@tiptap/extension-task-item": "^3.20.4", @@ -53,9 +54,9 @@ "react-markdown": "^10.1.0", "react-syntax-highlighter": "^15.6.6", "react-virtuoso": "^4.14.1", - "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", - "rehype-stringify": "^10.0.1", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "remark-parse": "^11.0.0", diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index bc0e2964..91c0cc6f 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -111,7 +111,7 @@ const FlexiblePanel: React.FC = memo(({ // Sync dirty state from MonacoModelManager on component mount React.useEffect(() => { - if (content?.type !== 'code-editor' && content?.type !== 'markdown-editor') { + if (content?.type !== 'code-editor') { return; } diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.scss b/src/web-ui/src/component-library/components/Markdown/Markdown.scss index 210f8bc9..c489078c 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.scss +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.scss @@ -459,6 +459,47 @@ margin: 0.5rem 0; } +.markdown-renderer img, +.markdown-renderer .markdown-image { + display: block; + max-width: 100%; + height: auto; + margin: 0.75rem 0; + border-radius: 8px; +} + +.markdown-renderer .markdown-image--loading { + opacity: 0.75; +} + +.markdown-renderer .markdown-image--error { + outline: 1px dashed rgba(239, 68, 68, 0.35); +} + +.markdown-renderer details { + margin: 1rem 0; + padding: 0.9rem 1rem; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; +} + +.markdown-renderer summary { + cursor: pointer; + font-weight: 600; + color: var(--color-text-primary); +} + +.markdown-renderer details[open] > summary { + margin-bottom: 0.75rem; +} + +.markdown-renderer .katex-display { + overflow-x: auto; + overflow-y: hidden; + padding: 0.25rem 0; +} + .table-wrapper { overflow: auto; diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index 2947872c..0836eb3f 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -3,9 +3,13 @@ * Used to render Markdown-formatted text */ -import React, { useState, useMemo, useCallback, Component, type ReactNode } from 'react'; +import React, { useState, useMemo, useCallback, useEffect, Component, type ReactNode } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; +import rehypeKatex from 'rehype-katex'; +import rehypeRaw from 'rehype-raw'; +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { visit } from 'unist-util-visit'; @@ -17,6 +21,7 @@ import { getPrismLanguageFromAlias } from '@/infrastructure/language-detection'; import { useTheme } from '@/infrastructure/theme'; import { createLogger } from '@/shared/utils/logger'; import path from 'path-browserify'; +import 'katex/dist/katex.min.css'; import './Markdown.scss'; const log = createLogger('Markdown'); @@ -56,6 +61,8 @@ class MarkdownErrorBoundary extends Component< } const FILE_LINK_PREFIX = 'file://'; const WORKSPACE_FOLDER_PLACEHOLDER = '{{workspaceFolder}}'; +const LOCAL_IMAGE_PLACEHOLDER = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; const EDITOR_OPENABLE_EXTENSIONS = new Set([ 'js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs', 'mts', 'cts', 'py', 'pyw', 'pyi', @@ -106,6 +113,30 @@ const EDITOR_OPENABLE_BASENAMES = new Set([ 'readme.txt', ]); +const localImageDataUrlCache = new Map(); +const localImageRequestCache = new Map>(); + +const sanitizeSchema = { + ...defaultSchema, + tagNames: [...(defaultSchema.tagNames || []), 'details', 'summary'], + attributes: { + ...defaultSchema.attributes, + a: [...(defaultSchema.attributes?.a || []), 'href', 'title'], + code: [...(defaultSchema.attributes?.code || []), 'className'], + details: [...(defaultSchema.attributes?.details || []), 'open'], + img: [...(defaultSchema.attributes?.img || []), 'src', 'alt', 'title', 'width', 'height', 'align'], + input: [...(defaultSchema.attributes?.input || []), 'type', 'checked', 'disabled'], + p: [...(defaultSchema.attributes?.p || []), 'align'], + pre: [...(defaultSchema.attributes?.pre || []), 'className'], + summary: [...(defaultSchema.attributes?.summary || [])], + }, + protocols: { + ...defaultSchema.protocols, + href: [...(defaultSchema.protocols?.href || []), 'computer', 'file', 'tab', 'visualization'], + src: [...(defaultSchema.protocols?.src || []), 'asset', 'data', 'http', 'https', 'tauri'], + }, +}; + function remarkAutolinkComputerFileLinks() { return (tree: any) => { visit(tree, 'text', (node: any, index: number | undefined, parent: any) => { @@ -196,6 +227,181 @@ function normalizeFileLikeHref(rawHref: string): string { } } +function normalizePath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function isAbsoluteFilesystemPath(filePath: string): boolean { + const normalized = normalizePath(filePath); + if (/^[A-Za-z]:/.test(normalized) || /^\/[A-Za-z]:/.test(normalized)) { + return true; + } + + return normalized.startsWith('/') && !normalized.startsWith('//'); +} + +function resolveBaseRelativePath(targetPath: string, basePath?: string): string { + if (!targetPath || !basePath || isAbsoluteFilesystemPath(targetPath)) { + return targetPath; + } + + const normalizedTarget = normalizePath(targetPath); + if (normalizedTarget.startsWith('./') || normalizedTarget.startsWith('../')) { + return path.normalize(path.join(basePath, normalizedTarget)); + } + + return path.normalize(path.join(basePath, normalizedTarget)); +} + +function isLocalAssetPath(src: string): boolean { + if (!src) { + return false; + } + + return !/^(https?:|data:|asset:|tauri:)/i.test(src); +} + +function normalizeExternalImageSrc(src: string): string { + const githubBlobMatch = src.match( + /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/i, + ); + + if (githubBlobMatch) { + const [, owner, repo, ref, assetPath] = githubBlobMatch; + return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${assetPath}`; + } + + return src; +} + +function getMimeType(filePath: string): string { + const ext = filePath.toLowerCase().split('.').pop(); + const mimeTypes: Record = { + avif: 'image/avif', + bmp: 'image/bmp', + gif: 'image/gif', + ico: 'image/x-icon', + jpeg: 'image/jpeg', + jpg: 'image/jpeg', + png: 'image/png', + svg: 'image/svg+xml', + webp: 'image/webp', + }; + + return mimeTypes[ext || ''] || 'image/jpeg'; +} + +async function getLocalImageDataUrl(localPath: string): Promise { + const cachedDataUrl = localImageDataUrlCache.get(localPath); + if (cachedDataUrl) { + return cachedDataUrl; + } + + const pendingRequest = localImageRequestCache.get(localPath); + if (pendingRequest) { + return pendingRequest; + } + + const request = (async () => { + const base64Content = await workspaceAPI.readFileContent(localPath); + const dataUrl = `data:${getMimeType(localPath)};base64,${base64Content}`; + localImageDataUrlCache.set(localPath, dataUrl); + localImageRequestCache.delete(localPath); + return dataUrl; + })().catch((error) => { + localImageRequestCache.delete(localPath); + throw error; + }); + + localImageRequestCache.set(localPath, request); + return request; +} + +interface MarkdownImageProps extends React.ImgHTMLAttributes { + basePath?: string; +} + +const MarkdownImage: React.FC = ({ src, alt, className, basePath, ...imgProps }) => { + const rawSrc = typeof src === 'string' ? normalizeExternalImageSrc(src) : ''; + const localPath = useMemo(() => { + if (!rawSrc || !isLocalAssetPath(rawSrc)) { + return null; + } + + return resolveBaseRelativePath(rawSrc, basePath); + }, [basePath, rawSrc]); + const [resolvedSrc, setResolvedSrc] = useState(() => { + if (!localPath) { + return rawSrc; + } + + return localImageDataUrlCache.get(localPath) || LOCAL_IMAGE_PLACEHOLDER; + }); + const [loadState, setLoadState] = useState<'idle' | 'loading' | 'loaded' | 'error'>(() => { + if (!localPath) { + return 'loaded'; + } + + return localImageDataUrlCache.has(localPath) ? 'loaded' : 'idle'; + }); + + useEffect(() => { + if (!localPath) { + setResolvedSrc(rawSrc); + setLoadState('loaded'); + return; + } + + const cachedDataUrl = localImageDataUrlCache.get(localPath); + if (cachedDataUrl) { + setResolvedSrc(cachedDataUrl); + setLoadState('loaded'); + return; + } + + let cancelled = false; + setResolvedSrc(LOCAL_IMAGE_PLACEHOLDER); + setLoadState('loading'); + + void getLocalImageDataUrl(localPath) + .then((dataUrl) => { + if (cancelled) { + return; + } + + setResolvedSrc(dataUrl); + setLoadState('loaded'); + }) + .catch((error) => { + if (cancelled) { + return; + } + + log.error('Failed to load local markdown image', { path: localPath, error }); + setResolvedSrc(rawSrc); + setLoadState('error'); + }); + + return () => { + cancelled = true; + }; + }, [localPath, rawSrc]); + + return ( + {alt} + ); +}; + function isEditorOpenableFilePath(filePath: string): boolean { const normalizedPath = filePath.trim().replace(/[?#].*$/, ''); const fileName = normalizedPath.split(/[\\/]/).pop()?.toLowerCase() || ''; @@ -257,8 +463,10 @@ export interface LineRange { export interface MarkdownProps { content: string; + basePath?: string; className?: string; isStreaming?: boolean; + expandDetailsByDefault?: boolean; onOpenVisualization?: (visualization: any) => void; onFileViewRequest?: (filePath: string, fileName: string, lineRange?: LineRange) => void; onTabOpen?: (tabInfo: any) => void; @@ -267,8 +475,10 @@ export interface MarkdownProps { export const Markdown = React.memo(({ content, + basePath, className = '', isStreaming = false, + expandDetailsByDefault = false, onOpenVisualization, onFileViewRequest, onTabOpen, @@ -422,13 +632,15 @@ export const Markdown = React.memo(({ const linkText = typeof children === 'string' ? children : String(children); const originalHref = linkMap.get(linkText); const hrefValue = originalHref || href || node?.properties?.href; + const isHashLink = typeof hrefValue === 'string' && hrefValue.startsWith('#'); const isComputerLink = typeof hrefValue === 'string' && hrefValue.startsWith(COMPUTER_LINK_PREFIX); const isVisualizationLink = typeof hrefValue === 'string' && hrefValue.startsWith('visualization:'); const isTabLink = typeof hrefValue === 'string' && hrefValue.startsWith('tab:'); const isHttpLink = typeof hrefValue === 'string' && (hrefValue.startsWith('http://') || hrefValue.startsWith('https://')); + const isMailtoLink = typeof hrefValue === 'string' && hrefValue.startsWith('mailto:'); - if (typeof hrefValue === 'string' && !isVisualizationLink && !isTabLink && !isHttpLink) { + if (typeof hrefValue === 'string' && !isVisualizationLink && !isTabLink && !isHttpLink && !isMailtoLink && !isHashLink) { let filePath = normalizeFileLikeHref(hrefValue); let lineRange: LineRange | undefined; @@ -456,6 +668,8 @@ export const Markdown = React.memo(({ } } + filePath = resolveBaseRelativePath(filePath, basePath); + fileName = filePath.split(/[\\/]/).pop() || filePath; const isFolder = filePath.endsWith('/'); @@ -563,6 +777,14 @@ export const Markdown = React.memo(({ ); } + + if (isMailtoLink && typeof hrefValue === 'string') { + return ( + + {children} + + ); + } return ( (({ ); }, + + details({ children, open, ...props }: any) { + return ( +
+ {children} +
+ ); + }, + + img({ node, ...props }: any) { + return ; + }, blockquote({ children }: any) { return
{children}
; @@ -602,10 +836,19 @@ export const Markdown = React.memo(({ return
  • {children}
  • ; }, - p({ children, ...props }: any) { - return

    {children}

    ; + p({ children, align, style, ...props }: any) { + return ( +

    + {children} +

    + ); } }), [ + basePath, + expandDetailsByDefault, isStreaming, linkMap, handleFileViewRequest, @@ -623,7 +866,8 @@ export const Markdown = React.memo(({
    {markdownContent} diff --git a/src/web-ui/src/locales/en-US/tools.json b/src/web-ui/src/locales/en-US/tools.json index fadda364..25b79765 100644 --- a/src/web-ui/src/locales/en-US/tools.json +++ b/src/web-ui/src/locales/en-US/tools.json @@ -60,7 +60,13 @@ }, "markdownEditor": { "loadingFile": "Loading file...", - "placeholder": "Start writing Markdown..." + "placeholder": "Start writing Markdown...", + "source": "Source", + "preview": "Preview", + "viewModeLabel": "Markdown source and preview mode", + "notice": { + "sourcePreviewFallback": "This document contains HTML fragments that are not safely editable in visual mode. Edit in source mode or switch to preview." + } }, "planViewer": { "loadingPlan": "Loading plan...", @@ -91,8 +97,6 @@ "meditor": { "placeholder": "Enter Markdown...", "toolbarPlaceholder": "Toolbar", - "loading": "Loading...", - "parseError": "Parse error", "mermaidRendering": "Rendering Mermaid diagram...", "mermaidRenderFailed": "Mermaid Render Failed", "mermaidRenderFailedWithMessage": "Render failed: {{message}}", @@ -101,6 +105,12 @@ "localImageTitleUnableToLoadWithPath": "Unable to load: {{path}}", "localImageLoading": "Loading image...", "localImageFailed": "Image failed to load", + "rawHtml": { + "inlineLabel": "HTML" + }, + "details": { + "summaryPlaceholder": "Summary" + }, "insertLinkTextPlaceholder": "Link text", "inlineAi": { "triggerHint": "Press Space to use AI", diff --git a/src/web-ui/src/locales/zh-CN/tools.json b/src/web-ui/src/locales/zh-CN/tools.json index fa5162ad..c792197f 100644 --- a/src/web-ui/src/locales/zh-CN/tools.json +++ b/src/web-ui/src/locales/zh-CN/tools.json @@ -60,7 +60,13 @@ }, "markdownEditor": { "loadingFile": "正在加载文件...", - "placeholder": "开始编写 Markdown 内容..." + "placeholder": "开始编写 Markdown 内容...", + "source": "源码", + "preview": "预览", + "viewModeLabel": "Markdown 源码与预览模式", + "notice": { + "sourcePreviewFallback": "该文档包含无法在可视化模式中安全编辑的 HTML 片段。请在源码模式中编辑,或切换到预览查看效果。" + } }, "planViewer": { "loadingPlan": "正在加载计划...", @@ -91,8 +97,6 @@ "meditor": { "placeholder": "请输入 Markdown 内容...", "toolbarPlaceholder": "工具栏", - "loading": "加载中...", - "parseError": "解析错误", "mermaidRendering": "正在渲染 Mermaid 图表...", "mermaidRenderFailed": "Mermaid 渲染失败", "mermaidRenderFailedWithMessage": "渲染失败: {{message}}", @@ -101,6 +105,12 @@ "localImageTitleUnableToLoadWithPath": "无法加载: {{path}}", "localImageLoading": "图片加载中...", "localImageFailed": "图片加载失败", + "rawHtml": { + "inlineLabel": "HTML" + }, + "details": { + "summaryPlaceholder": "摘要" + }, "insertLinkTextPlaceholder": "链接文本", "inlineAi": { "triggerHint": "按空格以启用 AI", diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.scss b/src/web-ui/src/tools/editor/components/MarkdownEditor.scss index 8706386f..100ba539 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.scss +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.scss @@ -10,6 +10,57 @@ overflow: hidden; } +.bitfun-markdown-editor__notice-bar { + display: flex; + align-items: flex-start; + gap: $size-gap-3; + padding: $size-gap-3 $size-gap-4; + border-bottom: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent); + background: color-mix(in srgb, var(--color-warning, #f59e0b) 8%, var(--color-bg-flowchat)); + color: var(--color-text-secondary); + flex-shrink: 0; +} + +.bitfun-markdown-editor__notice-icon { + width: 16px; + height: 16px; + margin-top: 2px; + color: var(--color-warning, #f59e0b); + flex-shrink: 0; +} + +.bitfun-markdown-editor__notice-copy { + display: flex; + flex-direction: column; + gap: 2px; + + p { + margin: 0; + font-size: $font-size-xs; + line-height: $line-height-relaxed; + } +} + +.bitfun-markdown-editor__unsafe-toolbar { + display: flex; + justify-content: flex-end; + padding: $size-gap-3 $size-gap-4; + border-bottom: 1px solid var(--border-subtle, #{$border-subtle}); + background: color-mix(in srgb, var(--color-bg-flowchat) 92%, var(--color-bg-elevated, #fff)); + flex-shrink: 0; +} + +.bitfun-markdown-editor__unsafe-toggle { + display: inline-flex; + align-items: center; + gap: $size-gap-2; +} + +.bitfun-markdown-editor__unsafe-body { + min-height: 0; + flex: 1; +} + .bitfun-markdown-editor-loading { display: flex; align-items: center; @@ -418,6 +469,33 @@ border-radius: $size-radius-base; } + details { + margin: 0 0 $size-gap-4; + padding: $size-gap-3 $size-gap-4; + background: color-mix(in srgb, var(--color-bg-secondary, #{$element-bg-subtle}) 86%, transparent); + border: 1px solid var(--border-subtle, #{$border-subtle}); + border-radius: $size-radius-lg; + + > :last-child { + margin-bottom: 0; + } + } + + summary { + cursor: pointer; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + list-style-position: outside; + + &::marker { + color: var(--color-text-secondary); + } + } + + details[open] > summary { + margin-bottom: $size-gap-3; + } + hr { height: 0; padding: 0; @@ -468,38 +546,4 @@ margin: 1em 0; } - // Mermaid diagram styles - .mermaid-container { - margin: $size-gap-4 0; - padding: $size-gap-4; - background: $element-bg-subtle; - border: 1px solid $border-base; - border-radius: $size-radius-base; - overflow-x: auto; - - &.mermaid-rendered { - display: flex; - justify-content: center; - align-items: center; - } - - svg { - max-width: 100%; - height: auto; - } - } - - .mermaid-placeholder { - text-align: center; - color: var(--color-text-muted); - font-size: $font-size-sm; - padding: $size-gap-4; - } - - .mermaid-error { - color: var(--color-error); - font-size: $font-size-sm; - padding: $size-gap-2; - text-align: center; - } } diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx index 2afdb5be..8343d719 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx @@ -5,14 +5,16 @@ * @module components/MarkdownEditor */ -import React, { useEffect, useState, useCallback, useRef } from 'react'; +import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { MEditor } from '../meditor'; import type { EditorInstance } from '../meditor'; +import { analyzeMarkdownEditability, type MarkdownEditabilityAnalysis } from '../meditor/utils/tiptapMarkdown'; import { AlertCircle } from 'lucide-react'; import { createLogger } from '@/shared/utils/logger'; import { CubeLoading, Button } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { useTheme } from '@/infrastructure/theme/hooks/useTheme'; +import CodeEditor from './CodeEditor'; import './MarkdownEditor.scss'; const log = createLogger('MarkdownEditor'); @@ -46,6 +48,7 @@ const MarkdownEditor: React.FC = ({ filePath, initialContent = '', workspacePath, + fileName, readOnly = false, className = '', onContentChange, @@ -57,8 +60,10 @@ const MarkdownEditor: React.FC = ({ const { isLight } = useTheme(); const [content, setContent] = useState(initialContent); const [hasChanges, setHasChanges] = useState(false); + const [unsafeViewMode, setUnsafeViewMode] = useState<'source' | 'preview'>('source'); const [loading, setLoading] = useState(!!filePath); const [error, setError] = useState(null); + const [editability, setEditability] = useState(() => analyzeMarkdownEditability(initialContent)); const editorRef = useRef(null); const isUnmountedRef = useRef(false); const lastModifiedTimeRef = useRef(0); @@ -87,6 +92,10 @@ const MarkdownEditor: React.FC = ({ }; }, []); + useEffect(() => { + setUnsafeViewMode('source'); + }, [filePath, initialContent]); + const loadFileContent = useCallback(async () => { if (!filePath || isUnmountedRef.current) return; @@ -109,11 +118,17 @@ const MarkdownEditor: React.FC = ({ } if (!isUnmountedRef.current) { - setContent(fileContent); + const nextEditability = analyzeMarkdownEditability(fileContent); + const nextContent = nextEditability.mode === 'unsafe' + ? fileContent + : nextEditability.canonicalMarkdown; + + setEditability(nextEditability); + setContent(nextContent); setHasChanges(false); lastReportedDirtyRef.current = false; setTimeout(() => { - editorRef.current?.setInitialContent?.(fileContent); + editorRef.current?.setInitialContent?.(nextContent); }, 0); // NOTE: Do NOT call onContentChange here during initial load. // Calling it triggers parent re-render which unmounts this component, @@ -152,11 +167,17 @@ const MarkdownEditor: React.FC = ({ loadFileContent(); } } else if (initialContent !== undefined) { - setContent(initialContent); + const nextEditability = analyzeMarkdownEditability(initialContent); + const nextContent = nextEditability.mode === 'unsafe' + ? initialContent + : nextEditability.canonicalMarkdown; + + setEditability(nextEditability); + setContent(nextContent); setHasChanges(false); lastReportedDirtyRef.current = false; setTimeout(() => { - editorRef.current?.setInitialContent?.(initialContent); + editorRef.current?.setInitialContent?.(nextContent); }, 0); // NOTE: Do NOT call onContentChange here during initial load. // Calling it triggers parent re-render which unmounts this component, @@ -260,6 +281,26 @@ const MarkdownEditor: React.FC = ({ return () => clearTimeout(timer); }, [jumpToLine, jumpToColumn, filePath, loading, content]); + const notices = useMemo(() => { + const nextNotices: string[] = []; + + if (filePath && ( + editability.mode === 'unsafe' || + editability.containsRenderOnlyBlocks || + editability.containsRawHtmlInlines + )) { + nextNotices.push(t('editor.markdownEditor.notice.sourcePreviewFallback')); + } + + return nextNotices; + }, [editability, filePath, t]); + + const shouldUseSourcePreviewFallback = !!filePath && ( + editability.mode === 'unsafe' || + editability.containsRenderOnlyBlocks || + editability.containsRawHtmlInlines + ); + if (loading) { return (
    @@ -284,8 +325,105 @@ const MarkdownEditor: React.FC = ({ ); } + if (shouldUseSourcePreviewFallback) { + return ( +
    + {notices.length > 0 && ( +
    + +
    + {notices.map(notice => ( +

    {notice}

    + ))} +
    +
    + )} +
    +
    + + +
    +
    +
    + {unsafeViewMode === 'source' ? ( + { + contentRef.current = newContent; + setContent(newContent); + setHasChanges(dirty); + if (lastReportedDirtyRef.current === dirty) { + return; + } + + lastReportedDirtyRef.current = dirty; + onContentChangeRef.current?.(newContent, dirty); + }} + onSave={(_savedContent) => { + setHasChanges(false); + lastReportedDirtyRef.current = false; + onContentChangeRef.current?.(contentRef.current, false); + }} + /> + ) : ( + + )} +
    +
    + ); + } + return (
    + {notices.length > 0 && ( +
    + +
    + {notices.map(notice => ( +

    {notice}

    + ))} +
    +
    + )} /g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} +import React from 'react'; +import { MarkdownRenderer } from '@/component-library'; interface InlineMarkdownPreviewProps { value: string; - options?: RenderOptions; basePath?: string; } export const InlineMarkdownPreview: React.FC = ({ value, - options, basePath, }) => { - const { t } = useI18n('tools'); - const mergedOptions = React.useMemo(() => ({ - ...options, - basePath, - }), [options, basePath]); - const { html, isLoading } = useMarkdown(value, mergedOptions); - const previewRef = useRef(null); - const mermaidService = useRef(MermaidService.getInstance()); - const lastHtmlRef = useRef(''); - - useEffect(() => { - if (previewRef.current && html && lastHtmlRef.current !== html) { - previewRef.current.innerHTML = html; - lastHtmlRef.current = html; - } - }, [html]); - - useEffect(() => { - if (!previewRef.current || !html) { - return; - } - - const mermaidContainers = previewRef.current.querySelectorAll('.mermaid-container:not(.mermaid-rendered)'); - if (mermaidContainers.length === 0) { - return; - } - - let isCancelled = false; - - const renderMermaidDiagrams = async () => { - for (const container of Array.from(mermaidContainers)) { - if (isCancelled) { - break; - } - - const mermaidCode = container.getAttribute('data-mermaid-code'); - if (!mermaidCode) { - continue; - } - - try { - const svg = await mermaidService.current.renderDiagram(mermaidCode); - if (!isCancelled && previewRef.current?.contains(container)) { - container.innerHTML = svg; - container.classList.add('mermaid-rendered'); - } - } catch (error) { - log.error('Mermaid render failed', error); - if (!isCancelled && previewRef.current?.contains(container)) { - const rawMsg = error instanceof Error ? error.message : t('editor.meditor.unknownError'); - const detail = rawMsg.replace(/^Render failed:\s*/i, ''); - const title = escapeHtml(t('editor.meditor.mermaidRenderFailed')); - container.innerHTML = `
    ${title}

    ${escapeHtml(detail)}
    `; - } - } - } - }; - - const loadImages = async () => { - if (previewRef.current && !isCancelled) { - await loadLocalImages(previewRef.current); - } - }; - - void renderMermaidDiagrams(); - void loadImages(); - - return () => { - isCancelled = true; - }; - }, [html, t]); - return (
    - {isLoading && ( -
    - {t('editor.meditor.loading')} -
    - )} -
    +
    + +
    ); }; diff --git a/src/web-ui/src/tools/editor/meditor/components/MEditor.scss b/src/web-ui/src/tools/editor/meditor/components/MEditor.scss index cec84342..48cfcdda 100644 --- a/src/web-ui/src/tools/editor/meditor/components/MEditor.scss +++ b/src/web-ui/src/tools/editor/meditor/components/MEditor.scss @@ -70,13 +70,5 @@ border-right-color: var(--border-base) !important; } - .mermaid-container { - border-color: var(--border-medium); - } - - .m-editor-preview-loading { - border-color: var(--border-medium); - } } } - diff --git a/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx b/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx index 6bf64b42..4a1df270 100644 --- a/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx +++ b/src/web-ui/src/tools/editor/meditor/components/MEditor.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useImperativeHandle, forwardRef, useRef } from 'react' +import React, { useCallback, useEffect, useImperativeHandle, forwardRef, useMemo, useRef } from 'react' import { createLogger } from '@/shared/utils/logger' import { activeEditTargetService } from '@/tools/editor/services/ActiveEditTargetService' import { useEditor } from '../hooks/useEditor' @@ -7,6 +7,7 @@ import { TiptapEditor, TiptapEditorHandle } from './TiptapEditor' import { Preview } from './Preview' import type { EditorOptions, EditorInstance } from '../types' import { useI18n } from '@/infrastructure/i18n' +import { analyzeMarkdownEditability } from '../utils/tiptapMarkdown' import './MEditor.scss' void createLogger('MEditor') @@ -76,9 +77,13 @@ export const MEditor = forwardRef((props, ref) => } = useEditor(controlledValue ?? defaultValue, onChange) const tiptapEditorRef = useRef(null) + const editability = useMemo(() => analyzeMarkdownEditability(value), [value]) + const effectiveMode = mode === 'ir' && editability.containsRenderOnlyBlocks + ? (readonly ? 'preview' : 'split') + : mode useEffect(() => { - if (mode === 'ir' || mode === 'preview') { + if (effectiveMode === 'ir' || effectiveMode === 'preview') { return } @@ -106,7 +111,7 @@ export const MEditor = forwardRef((props, ref) => return !!root && !!element && root.contains(element) } }) - }, [mode, textareaRef]) + }, [effectiveMode, textareaRef]) useEffect(() => { if (controlledValue !== undefined && controlledValue !== value) { @@ -129,57 +134,57 @@ export const MEditor = forwardRef((props, ref) => useImperativeHandle(ref, () => ({ ...editorInstance, scrollToLine: (line: number, highlight?: boolean) => { - if (mode === 'ir' && tiptapEditorRef.current) { + if (effectiveMode === 'ir' && tiptapEditorRef.current) { tiptapEditorRef.current.scrollToLine(line, highlight) } }, undo: () => { - if (mode === 'ir' && tiptapEditorRef.current) { + if (effectiveMode === 'ir' && tiptapEditorRef.current) { return tiptapEditorRef.current.undo() } - if (mode === 'edit' || mode === 'split') { + if (effectiveMode === 'edit' || effectiveMode === 'split') { return executeTextareaAction(textareaRef.current, 'undo') } return false }, redo: () => { - if (mode === 'ir' && tiptapEditorRef.current) { + if (effectiveMode === 'ir' && tiptapEditorRef.current) { return tiptapEditorRef.current.redo() } - if (mode === 'edit' || mode === 'split') { + if (effectiveMode === 'edit' || effectiveMode === 'split') { return executeTextareaAction(textareaRef.current, 'redo') } return false }, get canUndo() { - if (mode === 'ir' && tiptapEditorRef.current) { + if (effectiveMode === 'ir' && tiptapEditorRef.current) { return tiptapEditorRef.current.canUndo } return false }, get canRedo() { - if (mode === 'ir' && tiptapEditorRef.current) { + if (effectiveMode === 'ir' && tiptapEditorRef.current) { return tiptapEditorRef.current.canRedo } return false }, markSaved: () => { - if (mode === 'ir' && tiptapEditorRef.current) { + if (effectiveMode === 'ir' && tiptapEditorRef.current) { tiptapEditorRef.current.markSaved() } }, setInitialContent: (content: string) => { - if (mode === 'ir' && tiptapEditorRef.current) { + if (effectiveMode === 'ir' && tiptapEditorRef.current) { tiptapEditorRef.current.setInitialContent(content) } }, get isDirty() { - if (mode === 'ir' && tiptapEditorRef.current) { + if (effectiveMode === 'ir' && tiptapEditorRef.current) { return tiptapEditorRef.current.isDirty } return false } - }), [editorInstance, mode, textareaRef]) + }), [editorInstance, effectiveMode, textareaRef]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { @@ -190,15 +195,15 @@ export const MEditor = forwardRef((props, ref) => }, [value, onSave]) const handleFocusCapture = useCallback(() => { - if (mode === 'ir' || mode === 'preview') { + if (effectiveMode === 'ir' || effectiveMode === 'preview') { return } activeEditTargetService.setActiveTarget(textareaTargetIdRef.current) - }, [mode]) + }, [effectiveMode]) const handleBlurCapture = useCallback(() => { - if (mode === 'ir' || mode === 'preview') { + if (effectiveMode === 'ir' || effectiveMode === 'preview') { return } @@ -211,7 +216,7 @@ export const MEditor = forwardRef((props, ref) => activeEditTargetService.clearActiveTarget(textareaTargetIdRef.current) }, 0) - }, [mode]) + }, [effectiveMode]) const containerStyle: React.CSSProperties = { ...style, @@ -220,7 +225,7 @@ export const MEditor = forwardRef((props, ref) => } const themeClass = theme === 'dark' ? 'm-editor-dark' : 'm-editor-light' - const modeClass = `m-editor-mode-${mode}` + const modeClass = `m-editor-mode-${effectiveMode}` return (
    ((props, ref) => {toolbar &&
    {t('editor.meditor.toolbarPlaceholder')}
    }
    - {mode === 'preview' && ( + {effectiveMode === 'preview' && ( )} - {mode === 'edit' && ( + {effectiveMode === 'edit' && (
    ((props, ref) =>
    )} - {mode === 'split' && ( + {effectiveMode === 'split' && ( <>
    ((props, ref) => )} - {mode === 'ir' && ( + {effectiveMode === 'ir' && (
    /g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') -} +import React from 'react'; +import { MarkdownRenderer } from '@/component-library'; +import './Preview.scss'; interface PreviewProps { - value: string - options?: RenderOptions - /** - * Directory path of the Markdown file. - * Used to resolve relative image paths. - */ - basePath?: string + value: string; + basePath?: string; } -export const Preview: React.FC = ({ value, options, basePath }) => { - const { t } = useI18n('tools') - const mergedOptions = React.useMemo(() => ({ - ...options, - basePath - }), [options, basePath]) - - const { html, isLoading } = useMarkdown(value, mergedOptions) - const previewRef = useRef(null) - const mermaidService = useRef(MermaidService.getInstance()) - const lastHtmlRef = useRef('') - - useEffect(() => { - if (previewRef.current && html) { - if (lastHtmlRef.current !== html) { - previewRef.current.innerHTML = html - lastHtmlRef.current = html - } - } - }, [html]) - - useEffect(() => { - if (previewRef.current && html) { - const mermaidContainers = previewRef.current.querySelectorAll('.mermaid-container:not(.mermaid-rendered)') - if (mermaidContainers.length === 0) { - return - } - - let isCancelled = false - - const renderMermaidDiagrams = async () => { - for (const container of Array.from(mermaidContainers)) { - if (isCancelled) break - - const mermaidCode = container.getAttribute('data-mermaid-code') - - if (mermaidCode) { - try { - const svg = await mermaidService.current.renderDiagram(mermaidCode) - if (!isCancelled && previewRef.current?.contains(container)) { - container.innerHTML = svg - container.classList.add('mermaid-rendered') - } - } catch (error) { - log.error('Mermaid render failed', error) - if (!isCancelled && previewRef.current?.contains(container)) { - const rawMsg = error instanceof Error ? error.message : t('editor.meditor.unknownError') - const detail = rawMsg.replace(/^Render failed:\s*/i, '') - const title = escapeHtml(t('editor.meditor.mermaidRenderFailed')) - container.innerHTML = `
    ${title}

    ${escapeHtml(detail)}
    ` - } - } - } - } - } - - const loadImages = async () => { - if (previewRef.current && !isCancelled) { - await loadLocalImages(previewRef.current) - } - } - - renderMermaidDiagrams() - loadImages() - - return () => { - isCancelled = true - } - } - }, [html]) - +export const Preview: React.FC = ({ value, basePath }) => { return (
    - {isLoading && ( -
    - {t('editor.meditor.loading')} -
    - )} - {/* Use ref to manually control innerHTML, preventing React re-render from overwriting Mermaid SVG */} -
    +
    + +
    - ) -} - + ); +}; diff --git a/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.scss b/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.scss index 896bbf59..329f5a83 100644 --- a/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.scss +++ b/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.scss @@ -926,4 +926,306 @@ color: var(--color-error); font-size: $font-size-xs; } + + .m-editor-render-only-block, + .m-editor-raw-html-block { + margin: $size-gap-3 0; + color: var(--color-text-secondary); + } + + .m-editor-raw-html-inline { + color: var(--color-text-secondary); + background: color-mix(in srgb, var(--color-warning, #f59e0b) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 28%, transparent); + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0.08rem 0.4rem; + border-radius: 999px; + vertical-align: baseline; + } + + .m-editor-raw-html-block__label, + .m-editor-raw-html-inline__label { + font-size: $font-size-xs; + font-weight: $font-weight-semibold; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--color-warning, #f59e0b); + } + + .m-editor-raw-html-inline__source { + margin: 0; + font-family: var(--flowchat-md-code-font-mono); + font-size: $font-size-xs; + white-space: pre-wrap; + word-break: break-word; + } + + .m-editor-render-only-block__body, + .m-editor-raw-html-block__body { + min-width: 0; + } + + .m-editor-render-only-block__pane, + .m-editor-raw-html-block__pane { + min-width: 0; + min-height: 0; + } + + .m-editor-render-only-block__pane--editor, + .m-editor-render-only-block__pane--preview, + .m-editor-raw-html-block__pane--editor, + .m-editor-raw-html-block__pane--preview { + padding: 0; + } + + .m-editor-render-only-block[data-editing='true'] .m-editor-render-only-block__pane--preview, + .m-editor-raw-html-block[data-editing='true'] .m-editor-raw-html-block__pane--preview { + display: none; + } + + .m-editor-render-only-block[data-editing='false'] .m-editor-render-only-block__pane--editor, + .m-editor-raw-html-block[data-editing='false'] .m-editor-raw-html-block__pane--editor { + display: none; + } + + .m-editor-render-only-block__textarea, + .m-editor-raw-html-block__textarea { + margin: 0; + font-family: var(--flowchat-md-code-font-mono); + font-size: $font-size-sm; + display: block; + width: 100%; + min-height: 180px; + resize: vertical; + border: 1px solid color-mix(in srgb, var(--color-text-primary) 12%, transparent); + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--color-bg-flowchat) 94%, black); + color: var(--color-text-primary); + padding: $size-gap-3; + line-height: 1.55; + word-break: break-word; + outline: none; + user-select: text; + -webkit-user-select: text; + -webkit-user-drag: none; + + &:focus { + border-color: color-mix(in srgb, var(--color-text-primary) 20%, transparent); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-text-primary) 10%, transparent); + } + } + + .m-editor-render-only-block__preview, + .m-editor-raw-html-block__preview { + min-height: 180px; + cursor: text; + user-select: text; + -webkit-user-select: text; + -webkit-user-drag: none; + } + + .m-editor-render-only-block__preview .m-editor-render-only-block__markdown, + .m-editor-raw-html-block__preview .m-editor-raw-html-block__markdown { + color: var(--color-text-primary); + } + + .m-editor-render-only-block__preview .m-editor-render-only-block__markdown > :first-child, + .m-editor-raw-html-block__preview .m-editor-raw-html-block__markdown > :first-child { + margin-top: 0; + } + + .m-editor-render-only-block__preview .m-editor-render-only-block__markdown > :last-child, + .m-editor-raw-html-block__preview .m-editor-raw-html-block__markdown > :last-child { + margin-bottom: 0; + } + + .m-editor-render-only-block__preview > :first-child, + .m-editor-raw-html-block__preview > :first-child { + margin-top: 0; + } + + .m-editor-render-only-block__preview > :last-child, + .m-editor-raw-html-block__preview > :last-child { + margin-bottom: 0; + } + + .m-editor-render-only-block__source-fallback, + .m-editor-raw-html-block__source-fallback { + display: none; + margin: 0; + font-family: var(--flowchat-md-code-font-mono); + font-size: $font-size-sm; + white-space: pre-wrap; + word-break: break-word; + border: 1px solid color-mix(in srgb, var(--color-text-primary) 12%, transparent); + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--color-bg-flowchat) 94%, black); + color: var(--color-text-primary); + padding: $size-gap-3; + line-height: 1.55; + } + + .m-editor-render-only-block[data-preview-empty='true'] .m-editor-render-only-block__preview, + .m-editor-raw-html-block[data-preview-empty='true'] .m-editor-raw-html-block__preview { + display: none; + } + + .m-editor-render-only-block[data-preview-empty='true'] .m-editor-render-only-block__source-fallback, + .m-editor-raw-html-block[data-preview-empty='true'] .m-editor-raw-html-block__source-fallback { + display: block; + } + + .m-editor-render-only-block__details, + .m-editor-raw-html-block__details { + margin: 0; + } + + .m-editor-render-only-block__details-summary, + .m-editor-raw-html-block__details-summary { + cursor: pointer; + font-weight: $font-weight-semibold; + color: var(--color-text-primary); + list-style-position: outside; + } + + .m-editor-render-only-block__details-summary-content, + .m-editor-raw-html-block__details-summary-content { + display: inline; + } + + .m-editor-render-only-block__details-body, + .m-editor-raw-html-block__details-body { + margin-top: $size-gap-3; + } + + .m-editor-render-only-block__details-body > :first-child, + .m-editor-raw-html-block__details-body > :first-child { + margin-top: 0; + } + + .m-editor-render-only-block__details-body > :last-child, + .m-editor-raw-html-block__details-body > :last-child { + margin-bottom: 0; + } + + [data-type='details'] { + display: grid; + grid-template-columns: 20px minmax(0, 1fr); + align-items: start; + gap: $size-gap-2; + margin: $size-gap-3 0; + padding: $size-gap-2 $size-gap-3; + border: 1px solid color-mix(in srgb, var(--color-text-primary) 10%, transparent); + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--color-bg-flowchat) 88%, white); + } + + [data-type='details'] > button { + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + line-height: 1; + margin-top: 2px; + } + + [data-type='details'] > button::before { + content: '▸'; + font-size: 14px; + } + + [data-type='details'].is-open > button::before { + content: '▾'; + } + + [data-type='details'] > div { + min-width: 0; + } + + [data-type='details'] summary { + display: block; + list-style: none; + color: var(--color-text-primary); + font-size: $font-size-sm; + font-weight: $font-weight-medium; + outline: none; + } + + [data-type='details'] summary::marker, + [data-type='details'] summary::-webkit-details-marker { + display: none; + } + + [data-type='details'] [data-type='detailsContent'] { + padding-top: $size-gap-3; + } + + [data-type='details'] [data-type='detailsContent'] > :first-child { + margin-top: 0; + } + + [data-type='details'] [data-type='detailsContent'] > :last-child { + margin-bottom: 0; + } + + .m-editor-details-block { + margin: $size-gap-3 0; + border: 1px solid color-mix(in srgb, var(--color-text-primary) 10%, transparent); + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--color-bg-flowchat) 88%, white); + overflow: hidden; + } + + .m-editor-details-block__header { + display: flex; + align-items: center; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + background: color-mix(in srgb, var(--color-text-primary) 4%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--color-text-primary) 8%, transparent); + } + + .m-editor-details-block__toggle { + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + font-size: 14px; + line-height: 1; + } + + .m-editor-details-block__summary { + flex: 1; + min-width: 0; + border: none; + background: transparent; + color: var(--color-text-primary); + font-size: $font-size-sm; + font-weight: $font-weight-medium; + outline: none; + } + + .m-editor-details-block__summary::placeholder { + color: var(--color-text-tertiary); + } + + .m-editor-details-block__body { + padding: $size-gap-3; + } + + .m-editor-details-block__content > :first-child { + margin-top: 0; + } + + .m-editor-details-block__content > :last-child { + margin-bottom: 0; + } } diff --git a/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.tsx b/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.tsx index 675b56ee..6510bacd 100644 --- a/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.tsx +++ b/src/web-ui/src/tools/editor/meditor/components/TiptapEditor.tsx @@ -8,6 +8,7 @@ import React, { } from 'react'; import { EditorContent, useEditor } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; +import { Details, DetailsContent, DetailsSummary } from '@tiptap/extension-details'; import Placeholder from '@tiptap/extension-placeholder'; import TaskItem from '@tiptap/extension-task-item'; import TaskList from '@tiptap/extension-task-list'; @@ -33,6 +34,7 @@ import { InlineAiPreviewExtension, inlineAiPreviewPluginKey, } from '../extensions/InlineAiPreviewExtension'; +import { RawHtmlBlock, RawHtmlInline, RenderOnlyBlock } from '../extensions/RawHtmlExtensions'; import { getBlockIndexForLine } from '../utils/markdownBlocks'; import { buildInlineContinuePrompt, @@ -464,6 +466,21 @@ export const TiptapEditor = React.forwardRef 0) { + return true; + } + + if (preview.querySelector('img, svg, video, audio, table, pre, code, blockquote, ul, ol, hr')) { + return true; + } + + return Array.from(preview.querySelectorAll('details')).some((details) => { + const detailsText = details.textContent?.replace(/\u200b/g, '').trim() ?? ''; + return detailsText.length > 0 || !!details.querySelector( + 'img, svg, video, audio, table, pre, code, blockquote, ul, ol, hr', + ); + }); +} + +function normalizeDetailsBodyMarkdown(markdown: string): string { + return markdown + .replace(/^\s*\n/, '') + .replace(/\n\s*$/, ''); +} + +function parseDetailsSource(markdown: string): { + open: boolean; + summaryHtml: string; + bodyMarkdown: string; +} | null { + const trimmed = markdown.trim(); + const match = trimmed.match(/^]*)?>\s*
    ([\s\S]*?)<\/summary>\s*([\s\S]*?)\s*<\/details>$/); + if (!match) { + return null; + } + + const [, attrSource = '', summaryHtml = '', bodyRaw = ''] = match; + const isOpen = /\bopen\b/i.test(attrSource); + + return { + open: isOpen, + summaryHtml, + bodyMarkdown: normalizeDetailsBodyMarkdown(bodyRaw), + }; +} + +function isSafePreviewUrl(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return !normalized.startsWith('javascript:') && !normalized.startsWith('vbscript:'); +} + +function sanitizeDetailsSummaryHtml(summaryHtml: string): string { + if (typeof document === 'undefined') { + return summaryHtml; + } + + const template = document.createElement('template'); + template.innerHTML = summaryHtml; + const allowedTags = new Set(['A', 'STRONG', 'B', 'EM', 'I', 'CODE', 'BR', 'IMG']); + + const sanitizeNode = (node: globalThis.Node) => { + if (!(node instanceof HTMLElement)) { + return; + } + + if (!allowedTags.has(node.tagName)) { + const parent = node.parentNode; + if (!parent) { + return; + } + + while (node.firstChild) { + parent.insertBefore(node.firstChild, node); + } + parent.removeChild(node); + return; + } + + Array.from(node.attributes).forEach((attr) => { + const name = attr.name.toLowerCase(); + const value = attr.value; + + if (name.startsWith('on')) { + node.removeAttribute(attr.name); + return; + } + + if (node.tagName === 'A') { + if (!['href', 'title'].includes(name)) { + node.removeAttribute(attr.name); + return; + } + if (name === 'href' && !isSafePreviewUrl(value)) { + node.removeAttribute(attr.name); + } + return; + } + + if (node.tagName === 'IMG') { + if (!['src', 'alt', 'title', 'width', 'height', 'align'].includes(name)) { + node.removeAttribute(attr.name); + return; + } + if (name === 'src' && !isSafePreviewUrl(value)) { + node.removeAttribute(attr.name); + } + return; + } + + if (name !== 'class') { + node.removeAttribute(attr.name); + } + }); + + Array.from(node.children).forEach((child) => sanitizeNode(child)); + }; + + Array.from(template.content.children).forEach((child) => sanitizeNode(child)); + return template.innerHTML; +} + +function executeTextareaAction( + textarea: HTMLTextAreaElement | null, + action: 'undo' | 'redo' | 'cut' | 'copy' | 'paste' | 'selectAll', +): boolean { + if (!textarea || textarea.disabled) { + return false; + } + + textarea.focus(); + + if (textarea.readOnly && action !== 'copy' && action !== 'selectAll') { + return false; + } + + if (action === 'selectAll') { + textarea.select(); + return true; + } + + return document.execCommand(action); +} + +function createSourceBackedBlock( + name: string, + valueAttr: 'html' | 'markdown', + className: string, +) { + return Node.create({ + name, + group: 'block', + atom: true, + isolating: true, + selectable: true, + draggable: false, + defining: true, + + addOptions() { + return { + basePath: undefined, + }; + }, + + addAttributes() { + return { + [valueAttr]: { + default: '', + }, + kind: { + default: null, + }, + }; + }, + + parseHTML() { + return [{ + tag: `div[data-type="${name}"]`, + getAttrs: element => ({ + [valueAttr]: element.getAttribute(`data-${valueAttr}`) ?? '', + kind: element.getAttribute('data-kind'), + }), + }]; + }, + + renderHTML({ node }) { + const value = String(node.attrs[valueAttr] ?? ''); + const kind = typeof node.attrs.kind === 'string' && node.attrs.kind + ? node.attrs.kind + : null; + + return [ + 'div', + { + 'data-type': name, + [`data-${valueAttr}`]: value, + ...(kind ? { 'data-kind': kind } : {}), + }, + ]; + }, + + addNodeView() { + return ({ editor, node, getPos }) => { + let currentNode = node; + let isEditing = false; + let lastSyncedValue: string | null = null; + let lastEditableState = editor.isEditable; + let previewRoot: Root | null = null; + let previewCheckTimer: number | null = null; + const textareaTargetId = `${name}-textarea-${++sourceBackedBlockTextareaTargetCounter}`; + let unbindEditTarget: (() => void) | null = null; + + const dom = document.createElement('div'); + dom.className = className; + dom.draggable = false; + dom.setAttribute('draggable', 'false'); + + const body = document.createElement('div'); + body.className = `${className}__body`; + body.draggable = false; + body.setAttribute('draggable', 'false'); + + const editorPane = document.createElement('div'); + editorPane.className = `${className}__pane ${className}__pane--editor`; + + const textarea = document.createElement('textarea'); + textarea.className = `${className}__textarea`; + textarea.spellcheck = false; + textarea.wrap = 'off'; + textarea.draggable = false; + textarea.setAttribute('draggable', 'false'); + + editorPane.append(textarea); + + const previewPane = document.createElement('div'); + previewPane.className = `${className}__pane ${className}__pane--preview`; + + const preview = document.createElement('div'); + preview.className = `${className}__preview markdown-body`; + preview.draggable = false; + preview.setAttribute('draggable', 'false'); + preview.tabIndex = 0; + previewRoot = createRoot(preview); + + const sourceFallback = document.createElement('pre'); + sourceFallback.className = `${className}__source-fallback`; + + previewPane.append(preview, sourceFallback); + body.append(editorPane, previewPane); + dom.append(body); + + const applyAttrs = (attrs: Record) => { + const pos = typeof getPos === 'function' ? getPos() : null; + if (typeof pos !== 'number') { + return; + } + + editor.view.dispatch( + editor.view.state.tr.setNodeMarkup(pos, undefined, { + ...currentNode.attrs, + ...attrs, + }), + ); + }; + + const syncEditingState = () => { + dom.setAttribute('data-editing', isEditing ? 'true' : 'false'); + preview.tabIndex = editor.isEditable ? -1 : 0; + }; + + const setEditing = (nextEditing: boolean, options?: { focus?: boolean }) => { + const resolvedEditing = editor.isEditable ? nextEditing : false; + if (isEditing === resolvedEditing) { + if (resolvedEditing && options?.focus) { + focusElementWithoutScroll(textarea); + } + return; + } + + isEditing = resolvedEditing; + syncEditingState(); + + if (resolvedEditing && options?.focus) { + focusElementWithoutScroll(textarea); + } + }; + + const exitEditing = () => { + setEditing(false); + }; + + const enterEditing = () => { + setEditing(true, { focus: true }); + const end = textarea.value.length; + textarea.setSelectionRange(end, end); + sync(); + }; + + const renderPreview = (markdown: string) => { + const kind = typeof currentNode.attrs.kind === 'string' ? currentNode.attrs.kind : null; + const detailsSource = kind === 'details' ? parseDetailsSource(markdown) : null; + const shouldCheckPreviewVisibility = name === 'rawHtmlBlock' || kind === 'details'; + + const syncPreviewVisibility = (fallbackToMarkdownRenderer = false) => { + if (!shouldCheckPreviewVisibility) { + dom.setAttribute('data-preview-empty', 'false'); + return; + } + + if (previewCheckTimer !== null) { + window.clearTimeout(previewCheckTimer); + } + + previewCheckTimer = window.setTimeout(() => { + const hasVisibleContent = previewHasVisibleContent(preview); + + if (!hasVisibleContent && fallbackToMarkdownRenderer) { + previewRoot?.render( + React.createElement(MarkdownRenderer, { + content: markdown, + basePath: this.options.basePath, + className: `${className}__markdown`, + }), + ); + + previewCheckTimer = window.setTimeout(() => { + dom.setAttribute('data-preview-empty', previewHasVisibleContent(preview) ? 'false' : 'true'); + previewCheckTimer = null; + }, 0); + return; + } + + dom.setAttribute('data-preview-empty', hasVisibleContent ? 'false' : 'true'); + previewCheckTimer = null; + }, 0); + }; + + if (detailsSource) { + previewRoot?.render( + React.createElement( + 'details', + { + open: detailsSource.open, + className: `${className}__details`, + }, + React.createElement( + 'summary', + { + className: `${className}__details-summary`, + }, + React.createElement('span', { + className: `${className}__details-summary-content`, + dangerouslySetInnerHTML: { + __html: sanitizeDetailsSummaryHtml(detailsSource.summaryHtml), + }, + }), + ), + detailsSource.bodyMarkdown + ? React.createElement( + 'div', + { + className: `${className}__details-body`, + }, + React.createElement(MarkdownRenderer, { + content: detailsSource.bodyMarkdown, + basePath: this.options.basePath, + className: `${className}__markdown`, + }), + ) + : null, + ), + ); + + sourceFallback.textContent = markdown; + dom.setAttribute('data-preview-empty', 'false'); + syncPreviewVisibility(true); + return; + } + + previewRoot?.render( + React.createElement(MarkdownRenderer, { + content: markdown, + basePath: this.options.basePath, + className: `${className}__markdown`, + }), + ); + + sourceFallback.textContent = markdown; + dom.setAttribute('data-preview-empty', 'false'); + syncPreviewVisibility(); + }; + + const sync = () => { + const value = String(currentNode.attrs[valueAttr] ?? ''); + const editable = editor.isEditable; + const valueChanged = lastSyncedValue !== value; + const editableChanged = lastEditableState !== editable; + + dom.setAttribute('data-readonly', editable ? 'false' : 'true'); + + if (valueChanged && textarea.value !== value) { + textarea.value = value; + } + + textarea.readOnly = !editable; + if (!editable && isEditing) { + isEditing = false; + } + syncEditingState(); + + if (valueChanged || editableChanged) { + renderPreview(value); + } + + lastSyncedValue = value; + lastEditableState = editable; + }; + + const stopPropagation = (event: Event) => { + event.stopPropagation(); + }; + + const handleTextareaKeyDown = (event: KeyboardEvent) => { + if (isSelectAllShortcut(event)) { + event.preventDefault(); + textarea.select(); + } + + event.stopPropagation(); + }; + + const handlePreviewKeyDown = (event: KeyboardEvent) => { + if (isSelectAllShortcut(event)) { + event.preventDefault(); + selectElementContent(preview); + } + + event.stopPropagation(); + }; + + const handlePreviewClickCapture = (event: Event) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + if (target.closest('a')) { + event.stopPropagation(); + } + }; + + const handlePreviewMouseDown = (event: MouseEvent) => { + if (!editor.isEditable) { + focusElementWithoutScroll(preview); + } + + event.stopPropagation(); + }; + + const handlePreviewDoubleClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + if (target.closest('a, summary')) { + event.stopPropagation(); + return; + } + + event.preventDefault(); + event.stopPropagation(); + enterEditing(); + }; + + const preventDrag = (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const handleTextareaFocus = () => { + activeEditTargetService.setActiveTarget(textareaTargetId); + }; + + const handleTextareaBlur = () => { + window.setTimeout(() => { + const activeElement = typeof document !== 'undefined' ? document.activeElement : null; + if (activeElement instanceof HTMLElement && textarea.contains(activeElement)) { + return; + } + + activeEditTargetService.clearActiveTarget(textareaTargetId); + }, 0); + }; + + textarea.addEventListener('mousedown', stopPropagation); + textarea.addEventListener('click', stopPropagation); + textarea.addEventListener('keydown', handleTextareaKeyDown); + textarea.addEventListener('focus', handleTextareaFocus); + textarea.addEventListener('blur', exitEditing); + textarea.addEventListener('blur', handleTextareaBlur); + + preview.addEventListener('mousedown', handlePreviewMouseDown); + preview.addEventListener('click', handlePreviewClickCapture, true); + preview.addEventListener('click', stopPropagation); + preview.addEventListener('dblclick', handlePreviewDoubleClick); + preview.addEventListener('keydown', handlePreviewKeyDown); + + [dom, body, textarea, preview].forEach((element) => { + element.addEventListener('dragstart', preventDrag); + }); + + textarea.addEventListener('input', () => { + const nextValue = textarea.value; + lastSyncedValue = nextValue; + applyAttrs({ [valueAttr]: nextValue }); + void renderPreview(nextValue); + }); + + unbindEditTarget = activeEditTargetService.bindTarget({ + id: textareaTargetId, + kind: 'markdown-textarea', + focus: () => { + focusElementWithoutScroll(textarea); + }, + hasTextFocus: () => { + const activeElement = typeof document !== 'undefined' ? document.activeElement : null; + return activeElement === textarea; + }, + undo: () => executeTextareaAction(textarea, 'undo'), + redo: () => executeTextareaAction(textarea, 'redo'), + cut: () => executeTextareaAction(textarea, 'cut'), + copy: () => executeTextareaAction(textarea, 'copy'), + paste: () => executeTextareaAction(textarea, 'paste'), + selectAll: () => executeTextareaAction(textarea, 'selectAll'), + containsElement: (element) => element === textarea, + }); + + sync(); + + return { + dom, + update: (updatedNode) => { + if (updatedNode.type.name !== this.name) { + return false; + } + + currentNode = updatedNode; + sync(); + return true; + }, + stopEvent: (event) => { + if (event.type === 'dragstart') { + return true; + } + + const target = event.target; + return target instanceof HTMLElement && dom.contains(target) && ( + !!target.closest('textarea') || + !!target.closest(`.${className}__preview`) + ); + }, + ignoreMutation: (mutation) => { + const target = mutation.target; + return target instanceof globalThis.Node && dom.contains(target); + }, + destroy: () => { + activeEditTargetService.clearActiveTarget(textareaTargetId); + unbindEditTarget?.(); + unbindEditTarget = null; + if (previewCheckTimer !== null) { + window.clearTimeout(previewCheckTimer); + previewCheckTimer = null; + } + previewRoot?.unmount(); + previewRoot = null; + }, + }; + }; + }, + }); +} + +export const RenderOnlyBlock = createSourceBackedBlock( + 'renderOnlyBlock', + 'markdown', + 'm-editor-render-only-block', +); + +export const RawHtmlBlock = createSourceBackedBlock( + 'rawHtmlBlock', + 'html', + 'm-editor-raw-html-block', +); + +export const RawHtmlInline = Node.create({ + name: 'rawHtmlInline', + group: 'inline', + inline: true, + atom: true, + selectable: true, + draggable: false, + + addOptions() { + return { + label: 'HTML', + }; + }, + + addAttributes() { + return { + html: { + default: '', + }, + }; + }, + + parseHTML() { + return [{ + tag: 'span[data-type="raw-html-inline"]', + getAttrs: element => ({ + html: element.getAttribute('data-html') ?? '', + }), + }]; + }, + + renderHTML({ node }) { + return [ + 'span', + { + 'data-type': 'raw-html-inline', + 'data-html': String(node.attrs.html ?? ''), + }, + ]; + }, + + addNodeView() { + return ({ node }) => ({ + dom: createRawHtmlInlinePreviewNode( + String(node.attrs.html ?? ''), + this.options.label, + ), + }); + }, +}); diff --git a/src/web-ui/src/tools/editor/meditor/hooks/useMarkdown.ts b/src/web-ui/src/tools/editor/meditor/hooks/useMarkdown.ts deleted file mode 100644 index 4c5327e7..00000000 --- a/src/web-ui/src/tools/editor/meditor/hooks/useMarkdown.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useState, useEffect, useMemo, useRef } from 'react' -import { MarkdownParser } from '../utils/markdown' -import type { RenderOptions } from '../types' -import { createLogger } from '@/shared/utils/logger' -import { useI18n } from '@/infrastructure/i18n' - -const log = createLogger('useMarkdown') - -/** - * Markdown rendering hook (simplified). - */ -export function useMarkdown( - markdown: string, - options: RenderOptions = {} -) { - const { t } = useI18n('tools') - const [html, setHtml] = useState('') - const [isLoading, setIsLoading] = useState(false) - - const basePath = options.basePath - const parser = useMemo(() => new MarkdownParser({ ...options, basePath }), [basePath]) - - const lastMarkdownRef = useRef('') - - useEffect(() => { - if (markdown === lastMarkdownRef.current) { - return - } - - lastMarkdownRef.current = markdown - let cancelled = false - - const parse = async () => { - setIsLoading(true) - try { - const result = await parser.parse(markdown) - if (!cancelled) { - setHtml(result) - } - } catch (error) { - log.error('Markdown parsing failed', error) - if (!cancelled) { - setHtml(`

    ${t('editor.meditor.parseError')}

    `) - } - } finally { - if (!cancelled) { - setIsLoading(false) - } - } - } - - parse() - - return () => { - cancelled = true - } - }, [markdown, parser, t]) - - return { html, isLoading, parser } -} - diff --git a/src/web-ui/src/tools/editor/meditor/index.ts b/src/web-ui/src/tools/editor/meditor/index.ts index 9a9f2cf8..8eaaf91f 100644 --- a/src/web-ui/src/tools/editor/meditor/index.ts +++ b/src/web-ui/src/tools/editor/meditor/index.ts @@ -8,11 +8,9 @@ export type { TiptapEditorHandle } from './components/TiptapEditor' // Hooks export { useEditor } from './hooks/useEditor' -export { useMarkdown } from './hooks/useMarkdown' export { useEditorHistory } from './hooks/useEditorHistory' export type { UseEditorHistoryOptions, UseEditorHistoryReturn } from './hooks/useEditorHistory' -export { MarkdownParser } from './utils/markdown' export * from './utils/keyboardShortcuts' export * from './utils/tiptapMarkdown' @@ -24,6 +22,5 @@ export type { ToolbarButton, ToolbarConfig, Plugin, - UploadConfig, - RenderOptions + UploadConfig } from './types' diff --git a/src/web-ui/src/tools/editor/meditor/types/index.ts b/src/web-ui/src/tools/editor/meditor/types/index.ts index ff21aeea..139b2e02 100644 --- a/src/web-ui/src/tools/editor/meditor/types/index.ts +++ b/src/web-ui/src/tools/editor/meditor/types/index.ts @@ -121,20 +121,3 @@ export interface EditorInstance { /** Whether there are unsaved changes */ isDirty?: boolean } - -/** - * Markdown render options - */ -export interface RenderOptions { - math?: boolean - highlight?: boolean - emoji?: boolean - taskList?: boolean - table?: boolean - linkify?: boolean - /** - * Directory path of the Markdown file. - * Used to resolve relative image paths. - */ - basePath?: string -} diff --git a/src/web-ui/src/tools/editor/meditor/utils/markdown.ts b/src/web-ui/src/tools/editor/meditor/utils/markdown.ts deleted file mode 100644 index 6e60be2b..00000000 --- a/src/web-ui/src/tools/editor/meditor/utils/markdown.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { unified } from 'unified' -import remarkParse from 'remark-parse' -import remarkGfm from 'remark-gfm' -import remarkMath from 'remark-math' -import remarkRehype from 'remark-rehype' -import rehypeKatex from 'rehype-katex' -import rehypeStringify from 'rehype-stringify' -import rehypeHighlight from 'rehype-highlight' -import { rehypeMermaid } from './rehype-mermaid' -import { rehypeLocalImages } from './rehype-local-images' -import { createLogger } from '@/shared/utils/logger' -import type { RenderOptions } from '../types' - -/** - * Markdown parser - */ -export class MarkdownParser { - private options: RenderOptions - - constructor(options: RenderOptions = {}) { - this.options = { - math: true, - highlight: true, - emoji: true, - taskList: true, - table: true, - linkify: true, - ...options - } - } - - /** - * Convert Markdown to HTML - */ - async parse(markdown: string): Promise { - const processor = unified() - .use(remarkParse) - .use(remarkGfm) - .use(this.options.math ? remarkMath : () => {}) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(this.options.math ? rehypeKatex : () => {}) - .use(rehypeMermaid) - .use(rehypeLocalImages, { basePath: this.options.basePath }) - .use(this.options.highlight ? rehypeHighlight : () => {}) - .use(rehypeStringify, { allowDangerousHtml: true }) - - const result = await processor.process(markdown) - return String(result) - } - - /** - * Synchronous parse (used for live preview) - */ - parseSync(markdown: string): string { - try { - const processor = unified() - .use(remarkParse) - .use(remarkGfm) - .use(this.options.math ? remarkMath : () => {}) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(this.options.math ? rehypeKatex : () => {}) - .use(rehypeMermaid) - .use(rehypeLocalImages, { basePath: this.options.basePath }) - .use(this.options.highlight ? rehypeHighlight : () => {}) - .use(rehypeStringify, { allowDangerousHtml: true }) - - const result = processor.processSync(markdown) - return String(result) - } catch (error) { - createLogger('MarkdownParser').error('Sync parse failed', error); - return markdown - } - } - - /** - * Update options - */ - updateOptions(options: Partial) { - this.options = { ...this.options, ...options } - } -} - -/** - * Debounce helper - */ -export function debounce any>( - func: T, - wait: number -): (...args: Parameters) => void { - let timeout: ReturnType | null = null - - return function executedFunction(...args: Parameters) { - const later = () => { - timeout = null - func(...args) - } - - if (timeout) { - clearTimeout(timeout) - } - timeout = setTimeout(later, wait) - } -} - -/** - * Throttle helper - */ -export function throttle any>( - func: T, - wait: number -): (...args: Parameters) => void { - let timeout: ReturnType | null = null - let previous = 0 - - return function executedFunction(...args: Parameters) { - const now = Date.now() - const remaining = wait - (now - previous) - - if (remaining <= 0 || remaining > wait) { - if (timeout) { - clearTimeout(timeout) - timeout = null - } - previous = now - func(...args) - } else if (!timeout) { - timeout = setTimeout(() => { - previous = Date.now() - timeout = null - func(...args) - }, remaining) - } - } -} - diff --git a/src/web-ui/src/tools/editor/meditor/utils/rehype-mermaid.ts b/src/web-ui/src/tools/editor/meditor/utils/rehype-mermaid.ts deleted file mode 100644 index 9902ce66..00000000 --- a/src/web-ui/src/tools/editor/meditor/utils/rehype-mermaid.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Rehype plugin: Mermaid code blocks. - * Converts Mermaid code blocks into a renderable container. - */ -import { visit } from 'unist-util-visit'; -import { i18nService } from '@/infrastructure/i18n'; - -type Root = any; -type Element = any; - -export function rehypeMermaid() { - return (tree: Root) => { - const placeholderText = i18nService.t('tools:editor.meditor.mermaidRendering'); - visit(tree, 'element', (node: Element, index, parent) => { - if ( - node.tagName === 'pre' && - node.children && - node.children.length === 1 - ) { - const codeNode = node.children[0] as Element; - - if ( - codeNode.tagName === 'code' && - codeNode.properties && - Array.isArray(codeNode.properties.className) - ) { - const classes = codeNode.properties.className as string[]; - - if (classes.includes('language-mermaid')) { - const textNode = codeNode.children[0]; - const mermaidCode = textNode && 'value' in textNode ? textNode.value as string : ''; - - const mermaidContainer: Element = { - type: 'element', - tagName: 'div', - properties: { - className: ['mermaid-container'], - 'data-mermaid-code': mermaidCode - }, - children: [ - { - type: 'element', - tagName: 'div', - properties: { - className: ['mermaid-placeholder'] - }, - children: [ - { - type: 'text', - value: placeholderText - } - ] - } - ] - }; - - if (parent && typeof index === 'number') { - parent.children[index] = mermaidContainer; - } - } - } - } - }); - }; -} diff --git a/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.test.ts b/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.test.ts index 70e5b975..e76677fd 100644 --- a/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.test.ts +++ b/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + analyzeMarkdownEditability, canRoundTripMarkdownWithTiptap, getUnsupportedTiptapMarkdownFeatures, markdownToTiptapDoc, @@ -19,6 +20,7 @@ describe('tiptap markdown compatibility', () => { expect(canRoundTripMarkdownWithTiptap(markdown)).toBe(true); expect(getUnsupportedTiptapMarkdownFeatures(markdown)).toEqual([]); expect(tiptapDocToMarkdown(doc)).toBe(markdown); + expect(analyzeMarkdownEditability(markdown).containsRawHtmlBlocks).toBe(false); }); it('supports escaped markdown literals without losing semantics', () => { @@ -63,6 +65,18 @@ describe('tiptap markdown compatibility', () => { expect(tiptapDocToMarkdown(doc)).toBe(markdown); }); + it('preserves inline code wrapped by strong emphasis', () => { + const markdown = '可自行设置 `OPENSSL_DIR` 为 ZIP 内 **`x64`** 目录。'; + + const doc = markdownToTiptapDoc(markdown); + const serialized = tiptapDocToMarkdown(doc); + const analysis = analyzeMarkdownEditability(markdown); + + expect(serialized).toBe(markdown); + expect(analysis.mode).toBe('lossless'); + expect(analysis.semanticEqual).toBe(true); + }); + it('supports aligned html wrapper sections used by the project README header', () => { const markdown = [ '
    ', @@ -88,4 +102,232 @@ describe('tiptap markdown compatibility', () => { expect(canRoundTripMarkdownWithTiptap(markdown)).toBe(true); expect(getUnsupportedTiptapMarkdownFeatures(markdown)).toEqual([]); }); + + it('keeps canonicalizable nested lists in m-editor without requiring lossless round-trip', () => { + const markdown = [ + '- parent', + ' - child', + '', + 'Register in `agentic/tools/registry.rs`:', + '1. Implement `Tool` trait', + '2. Define input/output types', + ].join('\n'); + + const analysis = analyzeMarkdownEditability(markdown); + + expect(analysis.mode).toBe('canonicalizable'); + expect(analysis.semanticEqual).toBe(true); + expect(analysis.textEqual).toBe(false); + expect(analysis.hardIssues).toEqual([]); + expect(canRoundTripMarkdownWithTiptap(markdown)).toBe(false); + expect(getUnsupportedTiptapMarkdownFeatures(markdown)).toContain('roundTripMismatch'); + }); + + it('treats frontmatter as unsafe for the IR editor', () => { + const markdown = [ + '---', + 'title: Demo', + 'tags:', + ' - test', + '---', + '', + '# Body', + ].join('\n'); + + const analysis = analyzeMarkdownEditability(markdown); + + expect(analysis.mode).toBe('unsafe'); + expect(analysis.hardIssues).toContain('frontmatter'); + }); + + it('upgrades simple details regions into structured details nodes', () => { + const markdown = [ + '
    ', + 'Open me', + '', + 'Body', + '', + '
    ', + ].join('\n'); + + const analysis = analyzeMarkdownEditability(markdown); + const doc = markdownToTiptapDoc(markdown); + + expect(tiptapDocToMarkdown(doc)).toBe(markdown); + expect(analysis.mode).toBe('lossless'); + expect(analysis.containsRenderOnlyBlocks).toBe(false); + expect(analysis.containsRawHtmlBlocks).toBe(false); + expect(analysis.hardIssues).toEqual([]); + expect(doc.content?.[0]?.type).toBe('details'); + }); + + it('preserves blockquotes nested inside ordered list items', () => { + const markdown = [ + '1. Contribute good ideas/creativity (features, interactions, visuals, etc.) by opening issues', + ' > Product managers and UI designers are welcome to submit ideas quickly via PI. We will help refine them for development.', + '2. Improve the Agent system and overall quality', + ].join('\n'); + + const doc = markdownToTiptapDoc(markdown); + const serialized = tiptapDocToMarkdown(doc); + const analysis = analyzeMarkdownEditability(markdown); + + expect(serialized).toBe(markdown); + expect(analysis.mode).toBe('lossless'); + expect(analysis.semanticEqual).toBe(true); + }); + + it('preserves nested bullet lists inside ordered list items', () => { + const markdown = [ + '1. Parent item', + ' - child one', + ' - child two', + '2. Sibling item', + ].join('\n'); + + const doc = markdownToTiptapDoc(markdown); + const serialized = tiptapDocToMarkdown(doc); + const analysis = analyzeMarkdownEditability(markdown); + + expect(serialized).toBe(markdown); + expect(analysis.mode).toBe('lossless'); + expect(analysis.semanticEqual).toBe(true); + }); + + it('preserves html-rich markdown mixed with surrounding markdown blocks', () => { + const markdown = [ + '# Intro', + '', + 'Before the HTML block.', + '', + '
    ', + 'Expand', + '', + 'Protected **markdown** body', + '', + '
    ', + '', + 'After the HTML block.', + ].join('\n'); + + const doc = markdownToTiptapDoc(markdown); + const serialized = tiptapDocToMarkdown(doc); + const analysis = analyzeMarkdownEditability(markdown); + + expect(serialized).toBe(markdown); + expect(analysis.mode).toBe('lossless'); + expect(analysis.containsRawHtmlBlocks).toBe(false); + }); + + it('preserves inline raw html fragments inside markdown paragraphs', () => { + const markdown = 'Mix inline HTML into markdown.'; + + const doc = markdownToTiptapDoc(markdown); + const serialized = tiptapDocToMarkdown(doc); + const analysis = analyzeMarkdownEditability(markdown); + + expect(serialized).toBe(markdown); + expect(analysis.mode).toBe('lossless'); + expect(analysis.containsRawHtmlInlines).toBe(true); + expect(analysis.containsRawHtmlBlocks).toBe(false); + }); + + it('canonicalizes supported inline html tags into standard markdown syntax', () => { + const markdown = 'Mix bold, italics, code, link
    x.'; + + const doc = markdownToTiptapDoc(markdown); + const serialized = tiptapDocToMarkdown(doc); + const analysis = analyzeMarkdownEditability(markdown); + + expect(serialized).toBe('Mix **bold**, *italics*, `code`, [link](https://example.com) \n![x](/x.png).'); + expect(analysis.mode).toBe('canonicalizable'); + expect(analysis.containsRawHtmlBlocks).toBe(false); + expect(analysis.containsRawHtmlInlines).toBe(false); + expect(analysis.semanticEqual).toBe(true); + }); + + it('canonicalizes p align html blocks into aligned markdown content', () => { + const markdown = '

    Hello world
    x

    '; + + const doc = markdownToTiptapDoc(markdown); + const serialized = tiptapDocToMarkdown(doc); + const analysis = analyzeMarkdownEditability(markdown); + + expect(serialized).toBe('
    \n\nHello **world** \n![x](/x.png)\n\n
    '); + expect(analysis.mode).toBe('canonicalizable'); + expect(analysis.containsRawHtmlBlocks).toBe(false); + expect(analysis.containsRawHtmlInlines).toBe(false); + expect(analysis.semanticEqual).toBe(true); + }); + + it('upgrades rich but safe details summaries into editable details nodes', () => { + const markdown = [ + '
    ', + 'Open me', + '', + 'Body', + '', + '
    ', + ].join('\n'); + + const doc = markdownToTiptapDoc(markdown); + const serialized = tiptapDocToMarkdown(doc); + const analysis = analyzeMarkdownEditability(markdown); + + expect(serialized).toBe(markdown); + expect(analysis.mode).toBe('lossless'); + expect(analysis.containsRenderOnlyBlocks).toBe(false); + expect(analysis.containsRawHtmlBlocks).toBe(false); + expect(doc.content?.[0]?.type).toBe('details'); + }); + + it('upgrades linked details summaries into editable details nodes', () => { + const markdown = [ + '
    ', + 'You can also go to the latest GitHub Release and download the appropriate binary for your platform.', + '', + 'Each GitHub Release contains many executables, but in practice, you likely want one of these:', + '', + '- macOS', + ' - Apple Silicon/arm64: `codex-aarch64-apple-darwin.tar.gz`', + ' - x86_64 (older Mac hardware): `codex-x86_64-apple-darwin.tar.gz`', + '- Linux', + ' - x86_64: `codex-x86_64-unknown-linux-musl.tar.gz`', + ' - arm64: `codex-aarch64-unknown-linux-musl.tar.gz`', + '', + 'Each archive contains a single entry with the platform baked into the name (e.g., `codex-x86_64-unknown-linux-musl`), so you likely want to rename it to `codex` after extracting it.', + '', + '
    ', + ].join('\n'); + + const doc = markdownToTiptapDoc(markdown); + const serialized = tiptapDocToMarkdown(doc); + const analysis = analyzeMarkdownEditability(markdown); + + expect(serialized).toBe(markdown); + expect(analysis.mode).toBe('lossless'); + expect(analysis.containsRenderOnlyBlocks).toBe(false); + expect(analysis.containsRawHtmlBlocks).toBe(false); + expect(doc.content?.[0]?.type).toBe('details'); + }); + + it('keeps unsafe details content as source-only raw html', () => { + const markdown = [ + '
    ', + 'Open me', + '', + 'Body', + '', + '
    ', + ].join('\n'); + + const doc = markdownToTiptapDoc(markdown); + const serialized = tiptapDocToMarkdown(doc); + const analysis = analyzeMarkdownEditability(markdown); + + expect(serialized).toBe(markdown); + expect(analysis.mode).toBe('lossless'); + expect(analysis.containsRawHtmlBlocks).toBe(true); + expect(doc.content?.[0]?.type).toBe('rawHtmlBlock'); + }); }); diff --git a/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.ts b/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.ts index 98422f7e..fa89d784 100644 --- a/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.ts +++ b/src/web-ui/src/tools/editor/meditor/utils/tiptapMarkdown.ts @@ -1,6 +1,8 @@ import { unified } from 'unified'; import remarkGfm from 'remark-gfm'; import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import rehypeRaw from 'rehype-raw'; import type { JSONContent } from '@tiptap/core'; import { createBlockId } from './blockId'; @@ -17,6 +19,14 @@ type MdastNode = { checked?: boolean | null; align?: Array; children?: MdastNode[]; + position?: { + start?: { + offset?: number; + }; + end?: { + offset?: number; + }; + }; }; type Mark = { @@ -28,6 +38,20 @@ type TiptapMarkdownOptions = { preserveTrailingNewline?: boolean; }; +export type MarkdownEditabilityMode = 'lossless' | 'canonicalizable' | 'unsafe'; + +export interface MarkdownEditabilityAnalysis { + mode: MarkdownEditabilityMode; + canonicalMarkdown: string; + textEqual: boolean; + semanticEqual: boolean; + containsRenderOnlyBlocks: boolean; + containsRawHtmlBlocks: boolean; + containsRawHtmlInlines: boolean; + hardIssues: string[]; + softIssues: string[]; +} + export interface TiptapTopLevelMarkdownBlock { blockId?: string; markdown: string; @@ -45,6 +69,34 @@ type AlignmentState = { stack: AlignmentStackEntry[]; }; +type ComparableJsonValue = + | null + | boolean + | number + | string + | ComparableJsonValue[] + | { [key: string]: ComparableJsonValue }; + +type HtmlToken = + | { + kind: 'open' | 'close' | 'self'; + tagName: string; + attrs: Record; + raw: string; + } + | { + kind: 'text'; + value: string; + }; + +type HastNode = { + type?: string; + tagName?: string; + value?: string; + properties?: Record; + children?: HastNode[]; +}; + const TOP_LEVEL_BLOCK_TYPES = new Set([ 'paragraph', 'heading', @@ -55,8 +107,57 @@ const TOP_LEVEL_BLOCK_TYPES = new Set([ 'codeBlock', 'horizontalRule', 'markdownTable', + 'details', + 'renderOnlyBlock', + 'rawHtmlBlock', +]); + +const HTML_VOID_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', ]); +const HTML_BOLD_TAGS = new Set(['strong', 'b']); +const HTML_ITALIC_TAGS = new Set(['em', 'i']); +const SOURCE_ONLY_HTML_TAGS = new Set([ + 'script', + 'style', + 'iframe', + 'object', + 'embed', + 'applet', + 'form', + 'input', + 'button', + 'select', + 'option', + 'optgroup', + 'textarea', + 'video', + 'audio', + 'canvas', + 'svg', + 'math', +]); + +const markdownHtmlProcessor = unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(rehypeRaw); + function createTextNode(text: string, marks: Mark[] = []): JSONContent[] { if (!text) { return []; @@ -76,6 +177,25 @@ function createParagraph(content: JSONContent[] = []): JSONContent { }; } +function createRawHtmlBlock(html: string): JSONContent { + return { + type: 'rawHtmlBlock', + attrs: { + html, + }, + }; +} + +function createRenderOnlyBlock(markdown: string, kind: string): JSONContent { + return { + type: 'renderOnlyBlock', + attrs: { + markdown, + kind, + }, + }; +} + function withBlockAttrs(node: JSONContent, align: string | null, alignGroup: number | null): JSONContent { if (!node.type || !TOP_LEVEL_BLOCK_TYPES.has(node.type)) { return node; @@ -119,38 +239,292 @@ function flattenText(node: MdastNode | null | undefined): string { return (node.children ?? []).map(child => flattenText(child)).join(''); } -function convertInline(node: MdastNode, marks: Mark[] = []): JSONContent[] { - switch (node.type) { - case 'text': - return createTextNode(node.value ?? '', marks); - case 'inlineCode': - return createTextNode(node.value ?? '', [...marks, { type: 'code' }]); - case 'image': - return [{ +function parseHtmlAttributes(raw: string): Record { + const attributes: Record = {}; + const attrPattern = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g; + let match: RegExpExecArray | null; + + while ((match = attrPattern.exec(raw)) !== null) { + const [, name, doubleQuoted, singleQuoted, unquoted] = match; + if (!name) { + continue; + } + + attributes[name.toLowerCase()] = doubleQuoted ?? singleQuoted ?? unquoted ?? ''; + } + + return attributes; +} + +function parseSingleHtmlTagToken(raw: string): Exclude | null { + const match = raw.match(/^<\s*(\/)?\s*([A-Za-z][\w:-]*)([\s\S]*?)\s*(\/)?\s*>$/); + if (!match) { + return null; + } + + const [, closingSlash, tagNameRaw, attrSource = '', selfClosingSlash] = match; + const tagName = tagNameRaw.toLowerCase(); + const attrs = parseHtmlAttributes(attrSource); + + if (closingSlash) { + return { + kind: 'close', + tagName, + attrs: {}, + raw, + }; + } + + return { + kind: selfClosingSlash || HTML_VOID_TAGS.has(tagName) ? 'self' : 'open', + tagName, + attrs, + raw, + }; +} + +function tokenizeInlineHtmlFragment(fragment: string): HtmlToken[] { + const tokens: HtmlToken[] = []; + const tagPattern = /<\/?[^>]+?>/g; + let cursor = 0; + let match: RegExpExecArray | null; + + while ((match = tagPattern.exec(fragment)) !== null) { + if (match.index > cursor) { + tokens.push({ + kind: 'text', + value: fragment.slice(cursor, match.index), + }); + } + + const raw = match[0]; + const parsed = parseSingleHtmlTagToken(raw); + tokens.push(parsed ?? { kind: 'text', value: raw }); + cursor = match.index + raw.length; + } + + if (cursor < fragment.length) { + tokens.push({ + kind: 'text', + value: fragment.slice(cursor), + }); + } + + return tokens; +} + +function removeLastMatchingMark( + marks: Mark[], + predicate: (mark: Mark) => boolean, +): Mark[] | null { + for (let index = marks.length - 1; index >= 0; index -= 1) { + if (!predicate(marks[index])) { + continue; + } + + const nextMarks = [...marks]; + nextMarks.splice(index, 1); + return nextMarks; + } + + return null; +} + +function convertHtmlInlineTokens( + tokens: HtmlToken[], + baseMarks: Mark[] = [], + options: { strict?: boolean } = {}, +): { content: JSONContent[]; fullyStructured: boolean; activeMarks: Mark[] } { + let activeMarks = [...baseMarks]; + let fullyStructured = true; + const content: JSONContent[] = []; + + const appendRawHtml = (html: string) => { + fullyStructured = false; + content.push({ + type: 'rawHtmlInline', + attrs: { + html, + }, + ...(activeMarks.length > 0 ? { marks: activeMarks } : {}), + }); + }; + + const appendText = (text: string) => { + content.push(...createTextNode(text, activeMarks)); + }; + + for (const token of tokens) { + if (token.kind === 'text') { + appendText(token.value); + continue; + } + + if (token.kind === 'self' && token.tagName === 'br') { + content.push({ type: 'hardBreak' }); + continue; + } + + if (token.kind === 'self' && token.tagName === 'img') { + content.push({ type: 'markdownImage', attrs: { - src: node.url ?? '', - alt: node.alt ?? '', - title: node.title ?? null, + src: token.attrs.src ?? '', + alt: token.attrs.alt ?? '', + title: token.attrs.title ?? null, }, - ...(marks.length > 0 ? { marks } : {}), - }]; - case 'strong': - return (node.children ?? []).flatMap(child => convertInline(child, [...marks, { type: 'bold' }])); - case 'emphasis': - return (node.children ?? []).flatMap(child => convertInline(child, [...marks, { type: 'italic' }])); - case 'delete': - return (node.children ?? []).flatMap(child => convertInline(child, [...marks, { type: 'strike' }])); - case 'link': - return (node.children ?? []).flatMap(child => convertInline(child, [ - ...marks, - { type: 'link', attrs: { href: node.url ?? '' } }, - ])); - case 'break': - return [{ type: 'hardBreak' }]; - default: - return (node.children ?? []).flatMap(child => convertInline(child, marks)); + ...(activeMarks.length > 0 ? { marks: activeMarks } : {}), + }); + continue; + } + + if (token.kind === 'open' && HTML_BOLD_TAGS.has(token.tagName)) { + activeMarks = [...activeMarks, { type: 'bold' }]; + continue; + } + + if (token.kind === 'close' && HTML_BOLD_TAGS.has(token.tagName)) { + const nextMarks = removeLastMatchingMark(activeMarks, mark => mark.type === 'bold'); + if (!nextMarks) { + if (options.strict) { + return { content: [], fullyStructured: false, activeMarks }; + } + appendRawHtml(token.raw); + continue; + } + activeMarks = nextMarks; + continue; + } + + if (token.kind === 'open' && HTML_ITALIC_TAGS.has(token.tagName)) { + activeMarks = [...activeMarks, { type: 'italic' }]; + continue; + } + + if (token.kind === 'close' && HTML_ITALIC_TAGS.has(token.tagName)) { + const nextMarks = removeLastMatchingMark(activeMarks, mark => mark.type === 'italic'); + if (!nextMarks) { + if (options.strict) { + return { content: [], fullyStructured: false, activeMarks }; + } + appendRawHtml(token.raw); + continue; + } + activeMarks = nextMarks; + continue; + } + + if (token.tagName === 'code') { + if (token.kind === 'open') { + activeMarks = [...activeMarks, { type: 'code' }]; + continue; + } + + if (token.kind === 'close') { + const nextMarks = removeLastMatchingMark(activeMarks, mark => mark.type === 'code'); + if (!nextMarks) { + if (options.strict) { + return { content: [], fullyStructured: false, activeMarks }; + } + appendRawHtml(token.raw); + continue; + } + activeMarks = nextMarks; + continue; + } + } + + if (token.tagName === 'a') { + if (token.kind === 'open' && token.attrs.href) { + activeMarks = [...activeMarks, { type: 'link', attrs: { href: token.attrs.href } }]; + continue; + } + + if (token.kind === 'close') { + const nextMarks = removeLastMatchingMark(activeMarks, mark => mark.type === 'link'); + if (!nextMarks) { + if (options.strict) { + return { content: [], fullyStructured: false, activeMarks }; + } + appendRawHtml(token.raw); + continue; + } + activeMarks = nextMarks; + continue; + } + } + + if (options.strict) { + return { content: [], fullyStructured: false, activeMarks }; + } + + appendRawHtml(token.raw); + } + + if (activeMarks.length !== baseMarks.length) { + if (options.strict) { + return { content: [], fullyStructured: false, activeMarks }; + } + + fullyStructured = false; } + + return { content, fullyStructured, activeMarks }; +} + +function convertInlineNodes(nodes: MdastNode[], marks: Mark[] = []): JSONContent[] { + const content: JSONContent[] = []; + let activeMarks = [...marks]; + + nodes.forEach((node) => { + switch (node.type) { + case 'text': + content.push(...createTextNode(node.value ?? '', activeMarks)); + return; + case 'html': { + const htmlFragment = convertHtmlInlineTokens(tokenizeInlineHtmlFragment(node.value ?? ''), activeMarks); + content.push(...htmlFragment.content); + activeMarks = htmlFragment.activeMarks; + return; + } + case 'inlineCode': + content.push(...createTextNode(node.value ?? '', [...activeMarks, { type: 'code' }])); + return; + case 'image': + content.push({ + type: 'markdownImage', + attrs: { + src: node.url ?? '', + alt: node.alt ?? '', + title: node.title ?? null, + }, + ...(activeMarks.length > 0 ? { marks: activeMarks } : {}), + }); + return; + case 'strong': + content.push(...convertInlineNodes(node.children ?? [], [...activeMarks, { type: 'bold' }])); + return; + case 'emphasis': + content.push(...convertInlineNodes(node.children ?? [], [...activeMarks, { type: 'italic' }])); + return; + case 'delete': + content.push(...convertInlineNodes(node.children ?? [], [...activeMarks, { type: 'strike' }])); + return; + case 'link': + content.push(...convertInlineNodes(node.children ?? [], [ + ...activeMarks, + { type: 'link', attrs: { href: node.url ?? '' } }, + ])); + return; + case 'break': + content.push({ type: 'hardBreak' }); + return; + default: + content.push(...convertInlineNodes(node.children ?? [], activeMarks)); + } + }); + + return content; } function convertListItemContent(node: MdastNode): JSONContent[] { @@ -188,7 +562,7 @@ function convertList(node: MdastNode): JSONContent[] { } function convertTableCell(node: MdastNode, type: 'markdownTableHeader' | 'markdownTableCell'): JSONContent { - const inline = (node.children ?? []).flatMap(child => convertInline(child)); + const inline = convertInlineNodes(node.children ?? []); return { type, ...(inline.length > 0 ? { content: inline } : {}), @@ -216,11 +590,11 @@ function convertTable(node: MdastNode): JSONContent[] { function convertBlock(node: MdastNode): JSONContent[] { switch (node.type) { case 'paragraph': { - const inline = (node.children ?? []).flatMap(child => convertInline(child)); + const inline = convertInlineNodes(node.children ?? []); return [createParagraph(inline)]; } case 'heading': { - const inline = (node.children ?? []).flatMap(child => convertInline(child)); + const inline = convertInlineNodes(node.children ?? []); return [{ type: 'heading', attrs: { level: Math.min(Math.max(node.depth ?? 1, 1), 6) }, @@ -249,7 +623,7 @@ function convertBlock(node: MdastNode): JSONContent[] { case 'image': return [createParagraph(createTextNode(`![${flattenText(node)}](${node.url ?? ''})`))]; case 'html': - return []; + return node.value ? [createRawHtmlBlock(node.value)] : []; default: if (node.children?.length) { return node.children.flatMap(child => convertBlock(child)); @@ -259,57 +633,31 @@ function convertBlock(node: MdastNode): JSONContent[] { } } -function wrapTextWithMarks(text: string, marks?: Mark[]): string { - if (!text) { - return ''; - } - - const hasCodeMark = (marks ?? []).some(mark => mark.type === 'code'); - const escapedText = hasCodeMark - ? text - : text - .replace(/\\/g, '\\\\') - .replace(/([`*_[\]|])/g, '\\$1'); - - return (marks ?? []).reduce((result, mark) => { - switch (mark.type) { - case 'bold': - return `**${result}**`; - case 'italic': - return `*${result}*`; - case 'strike': - return `~~${result}~~`; - case 'code': - return `\`${result}\``; - case 'link': - return `[${result}](${String(mark.attrs?.href ?? '')})`; - default: - return result; - } - }, escapedText); +function escapeMarkdownPlainText(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/([`*[\]|])/g, '\\$1') + .replace(/_/g, (match, offset, input) => { + const previous = input[offset - 1] ?? ''; + const next = input[offset + 1] ?? ''; + const isWordBoundaryUnderscore = /[A-Za-z0-9]/.test(previous) && /[A-Za-z0-9]/.test(next); + return isWordBoundaryUnderscore ? match : `\\${match}`; + }); } -function wrapInlineMarkdownWithMarks(markdown: string, marks?: Mark[]): string { - if (!markdown) { - return ''; - } +function wrapInlineCodeText(text: string): string { + const runs = text.match(/`+/g) ?? []; + const longestRun = runs.reduce((max, run) => Math.max(max, run.length), 0); + const fence = '`'.repeat(longestRun + 1); + const needsPadding = text.startsWith('`') || text.endsWith('`'); + const normalizedText = needsPadding ? ` ${text} ` : text; + return `${fence}${normalizedText}${fence}`; +} - return (marks ?? []).reduce((result, mark) => { - switch (mark.type) { - case 'bold': - return `**${result}**`; - case 'italic': - return `*${result}*`; - case 'strike': - return `~~${result}~~`; - case 'code': - return `\`${result}\``; - case 'link': - return `[${result}](${String(mark.attrs?.href ?? '')})`; - default: - return result; - } - }, markdown); +function applyLinkMarks(markdown: string, marks: Mark[] = []): string { + return marks + .filter(mark => mark.type === 'link') + .reduce((result, mark) => `[${result}](${String(mark.attrs?.href ?? '')})`, markdown); } function escapeMarkdownImageText(value: string): string { @@ -324,14 +672,22 @@ function escapeMarkdownUrl(value: string): string { .replace(/([()])/g, '\\$1'); } -function renderMarkdownImage(node: JSONContent): string { +function escapeHtmlText(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function renderMarkdownImageBase(node: JSONContent): string { const alt = escapeMarkdownImageText(String(node.attrs?.alt ?? '')); const src = escapeMarkdownUrl(String(node.attrs?.src ?? '')); const title = typeof node.attrs?.title === 'string' && node.attrs.title ? ` "${node.attrs.title.replace(/"/g, '\\"')}"` : ''; - return wrapInlineMarkdownWithMarks(`![${alt}](${src}${title})`, node.marks as Mark[] | undefined); + return `![${alt}](${src}${title})`; } function walkMdast(node: MdastNode | null | undefined, visit: (current: MdastNode) => void): void { @@ -345,61 +701,696 @@ function walkMdast(node: MdastNode | null | undefined, visit: (current: MdastNod }); } -export function getUnsupportedTiptapMarkdownFeatures(markdown: string): string[] { +function parseMarkdownTree(markdown: string): MdastNode { + return unified() + .use(remarkParse) + .use(remarkGfm) + .parse(markdown) as MdastNode; +} + +function hasMarkdownFrontmatter(markdown: string): boolean { + return /^(---|\+\+\+)\r?\n[\s\S]*?\r?\n\1(?:\r?\n|$)/.test(markdown); +} + +function normalizeComparableJson( + value: ComparableJsonValue, + keysToStrip: Set, + currentKey?: string, +): ComparableJsonValue { + if (Array.isArray(value)) { + const normalizedArray = value.map(item => normalizeComparableJson(item, keysToStrip, currentKey)); + if (currentKey === 'marks') { + return [...normalizedArray].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + } + return normalizedArray; + } + + if (!value || typeof value !== 'object') { + return value; + } + + const result: Record = {}; + + Object.entries(value).forEach(([key, entryValue]) => { + if (keysToStrip.has(key)) { + return; + } + + if (key === 'attrs' && entryValue && typeof entryValue === 'object' && !Array.isArray(entryValue)) { + const cleanedAttrs = normalizeComparableJson(entryValue, keysToStrip, key); + if (cleanedAttrs && typeof cleanedAttrs === 'object' && !Array.isArray(cleanedAttrs) && Object.keys(cleanedAttrs).length === 0) { + return; + } + result[key] = cleanedAttrs; + return; + } + + result[key] = normalizeComparableJson(entryValue, keysToStrip, key); + }); + + return result; +} + +function normalizeTiptapDoc(doc: JSONContent): ComparableJsonValue { + return normalizeComparableJson(doc as ComparableJsonValue, new Set(['blockId', 'alignGroup'])); +} + +function walkTiptapDoc( + node: JSONContent | null | undefined, + visit: (current: JSONContent) => void, +): void { + if (!node) { + return; + } + + visit(node); + (node.content ?? []).forEach(child => { + walkTiptapDoc(child, visit); + }); +} + +function getNodeStartOffset(node: MdastNode | null | undefined): number | null { + const offset = node?.position?.start?.offset; + return typeof offset === 'number' ? offset : null; +} + +function getNodeEndOffset(node: MdastNode | null | undefined): number | null { + const offset = node?.position?.end?.offset; + return typeof offset === 'number' ? offset : null; +} + +function applyHtmlTagTokens(stack: string[], html: string): string[] { + const nextStack = [...stack]; + const tagPattern = /<\/?([A-Za-z][\w:-]*)(?:\s[^<>]*?)?\s*\/?>/g; + let match: RegExpExecArray | null; + + while ((match = tagPattern.exec(html)) !== null) { + const token = match[0]; + const tagName = match[1]?.toLowerCase(); + if (!tagName) { + continue; + } + + if (/^<\s*\/.*/.test(token)) { + for (let index = nextStack.length - 1; index >= 0; index -= 1) { + if (nextStack[index] === tagName) { + nextStack.splice(index, 1); + break; + } + } + continue; + } + + if (token.endsWith('/>') || HTML_VOID_TAGS.has(tagName)) { + continue; + } + + nextStack.push(tagName); + } + + return nextStack; +} + +function consumeRawHtmlRegion( + children: MdastNode[], + startIndex: number, + markdown: string, +): { node: JSONContent; nextIndex: number } | null { + const firstNode = children[startIndex]; + if (firstNode.type !== 'html' || !firstNode.value) { + return null; + } + + const startOffset = getNodeStartOffset(firstNode); + const initialEndOffset = getNodeEndOffset(firstNode); + if (startOffset === null || initialEndOffset === null) { + return { + node: createRawHtmlBlock(firstNode.value), + nextIndex: startIndex + 1, + }; + } + + let endOffset = initialEndOffset; + let stack = applyHtmlTagTokens([], firstNode.value); + + if (stack.length === 0) { + return { + node: createRawHtmlBlock(markdown.slice(startOffset, endOffset)), + nextIndex: startIndex + 1, + }; + } + + let cursor = startIndex + 1; + + while (cursor < children.length) { + const currentNode = children[cursor]; + const currentEndOffset = getNodeEndOffset(currentNode); + if (currentEndOffset === null) { + break; + } + + endOffset = currentEndOffset; + + if (currentNode.type === 'html' && currentNode.value) { + stack = applyHtmlTagTokens(stack, currentNode.value); + if (stack.length === 0) { + return { + node: createRawHtmlBlock(markdown.slice(startOffset, endOffset)), + nextIndex: cursor + 1, + }; + } + } + + cursor += 1; + } + + return { + node: createRawHtmlBlock(markdown.slice(startOffset, endOffset)), + nextIndex: cursor, + }; +} + +function parseSingleHtmlElement( + html: string, +): { tagName: string; attrs: Record; innerHtml: string } | null { + const trimmed = html.trim(); + const match = trimmed.match(/^<([A-Za-z][\w:-]*)([\s\S]*?)>([\s\S]*)<\/\1>$/); + if (!match) { + return null; + } + + const [, tagNameRaw, attrSource = '', innerHtml = ''] = match; + return { + tagName: tagNameRaw.toLowerCase(), + attrs: parseHtmlAttributes(attrSource), + innerHtml, + }; +} + +function convertStructuredHtmlBlock(html: string): JSONContent[] | null { + const tagToken = parseSingleHtmlTagToken(html.trim()); + if (tagToken && tagToken.kind === 'self' && tagToken.tagName === 'img') { + return [createParagraph([{ + type: 'markdownImage', + attrs: { + src: tagToken.attrs.src ?? '', + alt: tagToken.attrs.alt ?? '', + title: tagToken.attrs.title ?? null, + }, + }])]; + } + + const element = parseSingleHtmlElement(html); + if (!element) { + return null; + } + + if (element.tagName !== 'p') { + return null; + } + + const align = element.attrs.align?.toLowerCase() ?? null; + if (align && !['left', 'center', 'right'].includes(align)) { + return null; + } + + const inlineFragment = convertHtmlInlineTokens(tokenizeInlineHtmlFragment(element.innerHtml), [], { strict: true }); + if (!inlineFragment.fullyStructured) { + return null; + } + + return [{ + ...createParagraph(inlineFragment.content), + attrs: { + ...(align ? { align } : {}), + }, + }]; +} + +function normalizeDetailsBodyMarkdown(markdown: string): string { + return markdown + .replace(/^\s*\n/, '') + .replace(/\n\s*$/, ''); +} + +function parseMarkdownHtmlTree(markdown: string): HastNode | null { + try { + return markdownHtmlProcessor.runSync(markdownHtmlProcessor.parse(markdown)) as HastNode; + } catch { + return null; + } +} + +function isWhitespaceHastTextNode(node: HastNode | null | undefined): boolean { + return node?.type === 'text' && !(node.value ?? '').trim(); +} + +function getNonWhitespaceHastChildren(node: HastNode | null | undefined): HastNode[] { + return (node?.children ?? []).filter(child => !isWhitespaceHastTextNode(child)); +} + +function isHastElement(node: HastNode | null | undefined, tagName?: string): boolean { + return node?.type === 'element' && (!tagName || node.tagName === tagName); +} + +function getHastTextContent(node: HastNode | null | undefined): string { + if (!node) { + return ''; + } + + if (node.type === 'text') { + return node.value ?? ''; + } + + return (node.children ?? []).map(child => getHastTextContent(child)).join(''); +} + +function matchDetailsMarkdownRegion(markdown: string): { + attrSource: string; + bodyRaw: string; +} | null { + const trimmed = markdown.trim(); + const match = trimmed.match(/^]*)?>\s*[\s\S]*?<\/summary>\s*([\s\S]*?)\s*<\/details>$/); + if (!match) { + return null; + } + + return { + attrSource: match[1] ?? '', + bodyRaw: match[2] ?? '', + }; +} + +function parseDetailsAst(markdown: string): { + detailsNode: HastNode; + summaryNode: HastNode; +} | null { + const root = parseMarkdownHtmlTree(markdown); + const children = getNonWhitespaceHastChildren(root); + + if (children.length !== 1 || !isHastElement(children[0], 'details')) { + return null; + } + + const detailsNode = children[0]; + const detailsChildren = getNonWhitespaceHastChildren(detailsNode); + const summaryNode = detailsChildren[0]; + + if (!isHastElement(summaryNode, 'summary')) { + return null; + } + + return { + detailsNode, + summaryNode, + }; +} + +function isSafeUrlValue(value: string): boolean { + const normalized = value.trim().toLowerCase(); + return !normalized.startsWith('javascript:') && !normalized.startsWith('vbscript:'); +} + +function hasUnsafeHtmlPatterns(markdown: string): boolean { + const tokens = tokenizeInlineHtmlFragment(markdown); + + for (const token of tokens) { + if (token.kind === 'text') { + continue; + } + + if (SOURCE_ONLY_HTML_TAGS.has(token.tagName)) { + return true; + } + + for (const [name, value] of Object.entries(token.attrs)) { + if (name.startsWith('on')) { + return true; + } + + if ((name === 'href' || name === 'src') && !isSafeUrlValue(value)) { + return true; + } + } + } + + return false; +} + +function convertDetailsSummaryInlineChildren( + nodes: HastNode[], + marks: Mark[] = [], +): JSONContent[] | null { + const content: JSONContent[] = []; + + for (const node of nodes) { + if (node.type === 'text') { + content.push(...createTextNode(node.value ?? '', marks)); + continue; + } + + if (node.type !== 'element') { + return null; + } + + const childNodes = node.children ?? []; + + switch (node.tagName) { + case 'strong': + case 'b': { + const next = convertDetailsSummaryInlineChildren(childNodes, [...marks, { type: 'bold' }]); + if (!next) { + return null; + } + content.push(...next); + continue; + } + case 'em': + case 'i': { + const next = convertDetailsSummaryInlineChildren(childNodes, [...marks, { type: 'italic' }]); + if (!next) { + return null; + } + content.push(...next); + continue; + } + case 'code': { + const hasNestedElements = childNodes.some(child => child.type === 'element'); + if (hasNestedElements) { + return null; + } + + content.push(...createTextNode( + getHastTextContent(node), + [...marks, { type: 'code' }], + )); + continue; + } + case 'a': { + const href = typeof node.properties?.href === 'string' ? node.properties.href : ''; + if (!href || !isSafeUrlValue(href)) { + return null; + } + + const next = convertDetailsSummaryInlineChildren(childNodes, [ + ...marks, + { type: 'link', attrs: { href } }, + ]); + if (!next) { + return null; + } + content.push(...next); + continue; + } + case 'span': { + const next = convertDetailsSummaryInlineChildren(childNodes, marks); + if (!next) { + return null; + } + content.push(...next); + continue; + } + default: + return null; + } + } + + return content; +} + +function convertDetailsMarkdownRegion(markdown: string): JSONContent | null { + const matchedRegion = matchDetailsMarkdownRegion(markdown); + if (!matchedRegion) { + return null; + } + + const detailsAst = parseDetailsAst(markdown); + if (!detailsAst) { + return createRawHtmlBlock(markdown); + } + + const { attrSource, bodyRaw } = matchedRegion; + const { summaryNode } = detailsAst; + const attrs = parseHtmlAttributes(attrSource); + const attrNames = Object.keys(attrs); + if (attrNames.some(name => name !== 'open')) { + return null; + } + + if (hasUnsafeHtmlPatterns(markdown)) { + return createRawHtmlBlock(markdown); + } + + const summaryContent = convertDetailsSummaryInlineChildren(summaryNode.children ?? []); + if (summaryContent && renderInline(summaryContent).trim()) { + const bodyMarkdown = normalizeDetailsBodyMarkdown(bodyRaw); + const bodyTree = parseMarkdownTree(bodyMarkdown); + const bodyContent = convertRootMarkdownChildren(bodyTree.children ?? [], bodyMarkdown); + + return { + type: 'details', + attrs: { + open: Object.prototype.hasOwnProperty.call(attrs, 'open'), + }, + content: [ + { + type: 'detailsSummary', + ...(summaryContent.length > 0 ? { content: summaryContent } : {}), + }, + { + type: 'detailsContent', + content: bodyContent.length > 0 ? bodyContent : [createParagraph()], + }, + ], + }; + } + + if (!hasUnsafeHtmlPatterns(bodyRaw)) { + return createRenderOnlyBlock(markdown, 'details'); + } + + return createRawHtmlBlock(markdown); +} + +export function analyzeMarkdownEditability(markdown: string): MarkdownEditabilityAnalysis { if (!markdown) { - return []; + return { + mode: 'lossless', + canonicalMarkdown: '', + textEqual: true, + semanticEqual: true, + containsRenderOnlyBlocks: false, + containsRawHtmlBlocks: false, + containsRawHtmlInlines: false, + hardIssues: [], + softIssues: [], + }; } - const issues = new Set(); + const hardIssues = new Set(); + const softIssues = new Set(); + const doc = markdownToTiptapDoc(markdown); + let containsRenderOnlyBlocks = false; + let containsRawHtmlBlocks = false; + let containsRawHtmlInlines = false; + + walkTiptapDoc(doc, (node) => { + if (node.type === 'renderOnlyBlock') { + containsRenderOnlyBlocks = true; + } + + if (node.type === 'rawHtmlBlock') { + containsRawHtmlBlocks = true; + } + + if (node.type === 'rawHtmlInline') { + containsRawHtmlInlines = true; + } + }); + + const canonicalMarkdown = tiptapDocToMarkdown(doc, { + preserveTrailingNewline: markdown.endsWith('\n'), + }); + const reparsedCanonicalDoc = markdownToTiptapDoc(canonicalMarkdown); + const textEqual = canonicalMarkdown === markdown; + const semanticEqual = JSON.stringify(normalizeTiptapDoc(doc)) === + JSON.stringify(normalizeTiptapDoc(reparsedCanonicalDoc)); if (/^\n+/.test(markdown)) { - issues.add('leadingBlankLines'); + softIssues.add('leadingBlankLines'); } if (/\n{2,}$/.test(markdown)) { - issues.add('multipleTrailingBlankLines'); + softIssues.add('multipleTrailingBlankLines'); } - const tree = unified() - .use(remarkParse) - .use(remarkGfm) - .parse(markdown) as MdastNode; + if (hasMarkdownFrontmatter(markdown)) { + hardIssues.add('frontmatter'); + } + + const tree = parseMarkdownTree(markdown); walkMdast(tree, (node) => { if (node.type === 'footnoteDefinition' || node.type === 'footnoteReference') { - issues.add('footnote'); + hardIssues.add('footnote'); } }); - const roundTripped = tiptapDocToMarkdown(markdownToTiptapDoc(markdown), { - preserveTrailingNewline: markdown.endsWith('\n'), - }); - if (roundTripped !== markdown) { - issues.add('roundTripMismatch'); + if (!textEqual) { + softIssues.add('roundTripMismatch'); } - return Array.from(issues); + if (!semanticEqual) { + hardIssues.add('semanticMismatch'); + } + + const mode: MarkdownEditabilityMode = hardIssues.size > 0 + ? 'unsafe' + : textEqual + ? 'lossless' + : 'canonicalizable'; + + return { + mode, + canonicalMarkdown, + textEqual, + semanticEqual, + containsRenderOnlyBlocks, + containsRawHtmlBlocks, + containsRawHtmlInlines, + hardIssues: Array.from(hardIssues), + softIssues: Array.from(softIssues), + }; +} + +export function getUnsupportedTiptapMarkdownFeatures(markdown: string): string[] { + const analysis = analyzeMarkdownEditability(markdown); + return [...analysis.hardIssues, ...analysis.softIssues]; } export function canRoundTripMarkdownWithTiptap(markdown: string): boolean { - return getUnsupportedTiptapMarkdownFeatures(markdown).length === 0; + return analyzeMarkdownEditability(markdown).mode === 'lossless'; +} + +function getTextFormattingMarks(marks: Mark[] = []): string[] { + return ['bold', 'italic', 'strike'].filter(type => marks.some(mark => mark.type === type)); +} + +function openFormattingMark(type: string, value: string): string { + switch (type) { + case 'bold': + return `${value}**`; + case 'italic': + return `${value}*`; + case 'strike': + return `${value}~~`; + default: + return value; + } +} + +function closeFormattingMark(type: string, value: string): string { + switch (type) { + case 'bold': + return `${value}**`; + case 'italic': + return `${value}*`; + case 'strike': + return `${value}~~`; + default: + return value; + } } function renderInline(content: JSONContent[] = []): string { - return content.map((node: JSONContent) => { + let result = ''; + let activeFormatting: string[] = []; + + const syncFormatting = (nextFormatting: string[]) => { + while (activeFormatting.length > 0 && !nextFormatting.includes(activeFormatting[activeFormatting.length - 1])) { + const last = activeFormatting.pop(); + if (last) { + result = closeFormattingMark(last, result); + } + } + + nextFormatting.forEach((format) => { + if (!activeFormatting.includes(format)) { + result = openFormattingMark(format, result); + activeFormatting.push(format); + } + }); + }; + + content.forEach((node: JSONContent) => { + const marks = (node.marks as Mark[] | undefined) ?? []; + const formattingMarks = getTextFormattingMarks(marks); + syncFormatting(formattingMarks); + if (node.type === 'text') { - return wrapTextWithMarks(node.text ?? '', node.marks as Mark[] | undefined); + const hasCodeMark = marks.some(mark => mark.type === 'code'); + const baseText = hasCodeMark + ? wrapInlineCodeText(node.text ?? '') + : escapeMarkdownPlainText(node.text ?? ''); + + result += applyLinkMarks(baseText, marks); + return; } if (node.type === 'hardBreak') { - return ' \n'; + result += ' \n'; + return; + } + + if (node.type === 'rawHtmlInline') { + result += String(node.attrs?.html ?? ''); + return; } if (node.type === 'markdownImage') { - return renderMarkdownImage(node); + result += applyLinkMarks(renderMarkdownImageBase(node), marks); + return; + } + + result += renderInline(node.content ?? []); + }); + + syncFormatting([]); + return result; +} + +function renderInlineHtml(content: JSONContent[] = []): string { + return content.map((node: JSONContent) => { + if (node.type === 'text') { + const marks = (node.marks as Mark[] | undefined) ?? []; + let result = escapeHtmlText(node.text ?? ''); + + if (marks.some(mark => mark.type === 'code')) { + result = `${result}`; + } + if (marks.some(mark => mark.type === 'bold')) { + result = `${result}`; + } + if (marks.some(mark => mark.type === 'italic')) { + result = `${result}`; + } + if (marks.some(mark => mark.type === 'strike')) { + result = `${result}`; + } + + const linkMarks = marks.filter(mark => mark.type === 'link'); + linkMarks.forEach((mark) => { + result = `${result}`; + }); + + return result; + } + + if (node.type === 'hardBreak') { + return '
    '; } - return renderInline(node.content ?? []); + return renderInlineHtml(node.content ?? []); }).join(''); } @@ -438,7 +1429,7 @@ function parseAlignmentDirective(html: string, state: AlignmentState): boolean { return matched && html.slice(cursor).trim().length === 0; } -function convertRootMarkdownChildren(children: MdastNode[]): JSONContent[] { +function convertRootMarkdownChildren(children: MdastNode[], markdown: string): JSONContent[] { const alignmentState: AlignmentState = { activeAlign: null, activeGroupId: null, @@ -448,9 +1439,45 @@ function convertRootMarkdownChildren(children: MdastNode[]): JSONContent[] { const content: JSONContent[] = []; - children.forEach((child) => { + for (let index = 0; index < children.length; index += 1) { + const child = children[index]; + if (child.type === 'html' && child.value && parseAlignmentDirective(child.value, alignmentState)) { - return; + continue; + } + + if (child.type === 'html' && child.value) { + const structuredNodes = convertStructuredHtmlBlock(child.value); + if (structuredNodes) { + content.push(...structuredNodes.map(node => withBlockAttrs( + node, + alignmentState.activeAlign, + alignmentState.activeGroupId, + ))); + continue; + } + + const rawHtmlRegion = consumeRawHtmlRegion(children, index, markdown); + if (rawHtmlRegion) { + const detailsNode = convertDetailsMarkdownRegion(String(rawHtmlRegion.node.attrs?.html ?? '')); + if (detailsNode) { + content.push(withBlockAttrs( + detailsNode, + alignmentState.activeAlign, + alignmentState.activeGroupId, + )); + index = rawHtmlRegion.nextIndex - 1; + continue; + } + + content.push(withBlockAttrs( + rawHtmlRegion.node, + alignmentState.activeAlign, + alignmentState.activeGroupId, + )); + index = rawHtmlRegion.nextIndex - 1; + continue; + } } const nextNodes = convertBlock(child).map(node => withBlockAttrs( @@ -459,7 +1486,7 @@ function convertRootMarkdownChildren(children: MdastNode[]): JSONContent[] { alignmentState.activeGroupId, )); content.push(...nextNodes); - }); + } return content; } @@ -499,14 +1526,35 @@ function renderTableSeparator(alignments: unknown[], columnCount: number): strin return `| ${cells.join(' | ')} |`; } -function indentMarkdown(markdown: string, depth: number): string { - const indent = ' '.repeat(depth); +function prefixMarkdownLines(markdown: string, prefix: string): string { return markdown .split('\n') - .map((line: string) => (line ? `${indent}${line}` : line)) + .map((line: string) => (line ? `${prefix}${line}` : line)) .join('\n'); } +function isEmptyParagraphNode(node: JSONContent | null | undefined): boolean { + if (node?.type !== 'paragraph') { + return false; + } + + const content = node.content ?? []; + return content.length === 0 || content.every((child: JSONContent) => { + if (child.type !== 'text') { + return false; + } + + return (child.text ?? '').trim().length === 0; + }); +} + +function getDetailsChild( + node: JSONContent, + type: 'detailsSummary' | 'detailsContent', +): JSONContent | undefined { + return (node.content ?? []).find((child: JSONContent) => child.type === type); +} + function renderListItem( item: JSONContent, prefix: string, @@ -516,6 +1564,7 @@ function renderListItem( const children = item.content ?? []; const indent = ' '.repeat(depth); const marker = taskChecked === undefined ? prefix : `- [${taskChecked ? 'x' : ' '}] `; + const continuationIndent = `${indent}${' '.repeat(marker.length)}`; if (children.length === 0) { return `${indent}${marker}`; @@ -529,7 +1578,7 @@ function renderListItem( const lines: string[] = [`${indent}${marker}${firstRendered}`]; rest.forEach((child: JSONContent) => { - lines.push(indentMarkdown(renderBlock(child, depth + 1), depth + 1)); + lines.push(prefixMarkdownLines(renderBlock(child, 0), continuationIndent)); }); return lines.join('\n'); @@ -565,6 +1614,24 @@ function renderBlock(node: JSONContent, depth = 0): string { } case 'horizontalRule': return '---'; + case 'details': { + const summaryNode = getDetailsChild(node, 'detailsSummary'); + const detailsContentNode = getDetailsChild(node, 'detailsContent'); + const summary = renderInlineHtml(summaryNode?.content ?? []).trim() || 'Details'; + const bodyBlocks = (detailsContentNode?.content ?? []).filter( + (child: JSONContent) => !isEmptyParagraphNode(child), + ); + const body = bodyBlocks.map((child: JSONContent) => renderBlock(child, depth)).join('\n\n'); + const open = node.attrs?.open ? ' open' : ''; + + return body + ? `\n${summary}\n\n${body}\n\n` + : `\n${summary}\n\n`; + } + case 'renderOnlyBlock': + return String(node.attrs?.markdown ?? ''); + case 'rawHtmlBlock': + return String(node.attrs?.html ?? ''); case 'markdownTable': { const rows = node.content ?? []; if (rows.length === 0) { @@ -606,12 +1673,9 @@ function wrapAlignedMarkdownBlocks(markdownBlocks: string[], align: string | nul } export function markdownToTiptapDoc(markdown: string): JSONContent { - const tree = unified() - .use(remarkParse) - .use(remarkGfm) - .parse(markdown) as MdastNode; + const tree = parseMarkdownTree(markdown); - const content = withTopLevelBlockIds(convertRootMarkdownChildren(tree.children ?? [])); + const content = withTopLevelBlockIds(convertRootMarkdownChildren(tree.children ?? [], markdown)); return { type: 'doc', @@ -676,8 +1740,7 @@ export function tiptapDocToMarkdown( const markdown = chunks .filter(Boolean) .join('\n\n') - .replace(/<\/div>\n\n\n\n\n\n