From 1ef5350b3ffa324b016cf498b935b0cc53d9f17e Mon Sep 17 00:00:00 2001 From: landaiqing Date: Sat, 29 Nov 2025 22:54:38 +0800 Subject: [PATCH] :construction: Refactor markdown preview extension --- frontend/src/assets/styles/variables.css | 36 + .../codeblock/lang-parser/languages.ts | 25 +- .../editor/extensions/markdown/classes.ts | 10 +- .../extensions/markdown/github-markdown.css | 1229 ----------------- .../views/editor/extensions/markdown/index.ts | 39 +- .../markdown/plugins/code-block-enhanced.ts | 36 +- .../extensions/markdown/plugins/code-block.ts | 38 +- .../extensions/markdown/plugins/heading.ts | 4 +- .../extensions/markdown/plugins/html.ts | 95 +- .../extensions/markdown/plugins/image.ts | 26 +- .../markdown/plugins/inline-code.ts | 112 ++ .../extensions/markdown/plugins/link.ts | 100 +- .../extensions/markdown/plugins/list.ts | 1 - .../markdown/plugins/paste-rich-text.ts | 201 --- .../markdown/plugins/reveal-on-arrow.ts | 66 - .../editor/extensions/markdown/state/image.ts | 277 ++-- .../views/editor/extensions/markdown/util.ts | 60 - 17 files changed, 467 insertions(+), 1888 deletions(-) delete mode 100644 frontend/src/views/editor/extensions/markdown/github-markdown.css create mode 100644 frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts delete mode 100644 frontend/src/views/editor/extensions/markdown/plugins/paste-rich-text.ts delete mode 100644 frontend/src/views/editor/extensions/markdown/plugins/reveal-on-arrow.ts diff --git a/frontend/src/assets/styles/variables.css b/frontend/src/assets/styles/variables.css index eddcea4..43b85fb 100644 --- a/frontend/src/assets/styles/variables.css +++ b/frontend/src/assets/styles/variables.css @@ -49,6 +49,18 @@ --voidraft-loading-glow: 0 0 10px rgba(50, 255, 50, 0.5), 0 0 5px rgba(100, 255, 100, 0.5); --voidraft-loading-done-color: #66ff66; --voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(10, 16, 10, 0.5) 50%); + + /* Markdown 代码块样式 - 暗色主题 */ + --cm-codeblock-bg: rgba(46, 51, 69, 0.8); + --cm-codeblock-radius: 0.4rem; + --cm-codeblock-lang-color: oklch(65% 0.03 257); + --cm-codeblock-btn-bg: oklch(28% 0.02 253); + --cm-codeblock-btn-hover-bg: oklch(38% 0.035 257); + --cm-codeblock-btn-color: oklch(65% 0.03 257); + --cm-codeblock-btn-hover-color: oklch(85% 0.015 255); + + /* Markdown 内联代码样式 */ + --cm-inline-code-bg: oklch(28% 0.02 255); } /* 亮色主题 */ @@ -96,6 +108,18 @@ --voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2); --voidraft-loading-done-color: #008800; --voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%); + + /* Markdown 代码块样式 - 亮色主题 */ + --cm-codeblock-bg: oklch(92.9% 0.013 255.508); + --cm-codeblock-radius: 0.4rem; + --cm-codeblock-lang-color: oklch(37.2% 0.044 257.287); + --cm-codeblock-btn-bg: oklch(86.9% 0.022 252.894); + --cm-codeblock-btn-hover-bg: oklch(70.4% 0.04 256.788); + --cm-codeblock-btn-color: oklch(37.2% 0.044 257.287); + --cm-codeblock-btn-hover-color: oklch(20% 0.044 257); + + /* Markdown 内联代码样式 */ + --cm-inline-code-bg: oklch(92.9% 0.013 255.508); } /* 跟随系统的浅色偏好 */ @@ -144,5 +168,17 @@ --voidraft-loading-glow: 0 0 10px rgba(0, 160, 0, 0.3), 0 0 5px rgba(0, 120, 0, 0.2); --voidraft-loading-done-color: #008800; --voidraft-loading-overlay: linear-gradient(transparent 0%, rgba(220, 240, 220, 0.5) 50%); + + /* Markdown 代码块样式 - 亮色主题 */ + --cm-codeblock-bg: oklch(92.9% 0.013 255.508); + --cm-codeblock-radius: 0.4rem; + --cm-codeblock-lang-color: oklch(37.2% 0.044 257.287); + --cm-codeblock-btn-bg: oklch(86.9% 0.022 252.894); + --cm-codeblock-btn-hover-bg: oklch(70.4% 0.04 256.788); + --cm-codeblock-btn-color: oklch(37.2% 0.044 257.287); + --cm-codeblock-btn-hover-color: oklch(20% 0.044 257); + + /* Markdown 内联代码样式 */ + --cm-inline-code-bg: oklch(92.9% 0.013 255.508); } } diff --git a/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts b/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts index f63fc01..d92d63f 100644 --- a/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts +++ b/frontend/src/views/editor/extensions/codeblock/lang-parser/languages.ts @@ -5,9 +5,9 @@ import {jsonLanguage} from "@codemirror/lang-json"; import {pythonLanguage} from "@codemirror/lang-python"; import {javascriptLanguage, typescriptLanguage} from "@codemirror/lang-javascript"; -import {htmlLanguage} from "@codemirror/lang-html"; +import {html, htmlLanguage} from "@codemirror/lang-html"; import {StandardSQL} from "@codemirror/lang-sql"; -import {markdownLanguage} from "@codemirror/lang-markdown"; +import {markdown, markdownLanguage} from "@codemirror/lang-markdown"; import {javaLanguage} from "@codemirror/lang-java"; import {phpLanguage} from "@codemirror/lang-php"; import {cssLanguage} from "@codemirror/lang-css"; @@ -22,9 +22,9 @@ import {wastLanguage} from "@codemirror/lang-wast"; import {sassLanguage} from "@codemirror/lang-sass"; import {lessLanguage} from "@codemirror/lang-less"; import {angularLanguage} from "@codemirror/lang-angular"; -import { svelteLanguage } from "@replit/codemirror-lang-svelte"; -import { httpLanguage } from "@/views/editor/extensions/httpclient/language/http-language"; -import { mermaidLanguage } from '@/views/editor/language/mermaid'; +import {svelteLanguage} from "@replit/codemirror-lang-svelte"; +import {httpLanguage} from "@/views/editor/extensions/httpclient/language/http-language"; +import {mermaidLanguage} from '@/views/editor/language/mermaid'; import {StreamLanguage} from "@codemirror/language"; import {ruby} from "@codemirror/legacy-modes/mode/ruby"; import {shell} from "@codemirror/legacy-modes/mode/shell"; @@ -64,6 +64,7 @@ import dartPrettierPlugin from "@/common/prettier/plugins/dart"; import luaPrettierPlugin from "@/common/prettier/plugins/lua"; import webPrettierPlugin from "@/common/prettier/plugins/web"; import * as prettierPluginEstree from "prettier/plugins/estree"; +import {languages} from "@codemirror/language-data"; /** * 语言信息类 @@ -110,7 +111,19 @@ export const LANGUAGES: LanguageInfo[] = [ parser: "sql", plugins: [sqlPrettierPlugin] }), - new LanguageInfo("md", "Markdown", markdownLanguage.parser, ["md"], { + new LanguageInfo("md", "Markdown", markdown({ + base: markdownLanguage, + extensions: [], + completeHTMLTags: true, + pasteURLAsLink: true, + htmlTagLanguage: html({ + matchClosingTags: true, + autoCloseTags: true + }), + addKeymap: true, + codeLanguages: languages, + + }).language.parser, ["md"], { parser: "markdown", plugins: [markdownPrettierPlugin] }), diff --git a/frontend/src/views/editor/extensions/markdown/classes.ts b/frontend/src/views/editor/extensions/markdown/classes.ts index 4604cfa..152c67f 100644 --- a/frontend/src/views/editor/extensions/markdown/classes.ts +++ b/frontend/src/views/editor/extensions/markdown/classes.ts @@ -64,11 +64,5 @@ export const blockquote = { emoji = { /** Emoji widget */ widget: 'cm-emoji' - }, - /** Classes for horizontal rule decorations. */ - horizontalRule = { - /** Horizontal rule container */ - container: 'cm-horizontal-rule-container', - /** Horizontal rule element */ - rule: 'cm-horizontal-rule' - }; + } + diff --git a/frontend/src/views/editor/extensions/markdown/github-markdown.css b/frontend/src/views/editor/extensions/markdown/github-markdown.css deleted file mode 100644 index d95cc85..0000000 --- a/frontend/src/views/editor/extensions/markdown/github-markdown.css +++ /dev/null @@ -1,1229 +0,0 @@ -.markdown-body { - --base-size-4: 0.25rem; - --base-size-8: 0.5rem; - --base-size-16: 1rem; - --base-size-24: 1.5rem; - --base-size-40: 2.5rem; - --base-text-weight-normal: 400; - --base-text-weight-medium: 500; - --base-text-weight-semibold: 600; - --fontStack-monospace: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace; - --fgColor-accent: Highlight; -} - -/* 暗色主题变量 */ -.markdown-body[data-theme="dark"] { - /* dark */ - color-scheme: dark; - --focus-outlineColor: #1f6feb; - --fgColor-default: #f0f6fc; - --fgColor-muted: #9198a1; - --fgColor-accent: #4493f8; - --fgColor-success: #3fb950; - --fgColor-attention: #d29922; - --fgColor-danger: #f85149; - --fgColor-done: #ab7df8; - --bgColor-default: #0d1117; - --bgColor-muted: #151b23; - --bgColor-neutral-muted: #656c7633; - --bgColor-attention-muted: #bb800926; - --borderColor-default: #3d444d; - --borderColor-muted: #3d444db3; - --borderColor-neutral-muted: #3d444db3; - --borderColor-accent-emphasis: #1f6feb; - --borderColor-success-emphasis: #238636; - --borderColor-attention-emphasis: #9e6a03; - --borderColor-danger-emphasis: #da3633; - --borderColor-done-emphasis: #8957e5; - --color-prettylights-syntax-comment: #9198a1; - --color-prettylights-syntax-constant: #79c0ff; - --color-prettylights-syntax-constant-other-reference-link: #a5d6ff; - --color-prettylights-syntax-entity: #d2a8ff; - --color-prettylights-syntax-storage-modifier-import: #f0f6fc; - --color-prettylights-syntax-entity-tag: #7ee787; - --color-prettylights-syntax-keyword: #ff7b72; - --color-prettylights-syntax-string: #a5d6ff; - --color-prettylights-syntax-variable: #ffa657; - --color-prettylights-syntax-brackethighlighter-unmatched: #f85149; - --color-prettylights-syntax-brackethighlighter-angle: #9198a1; - --color-prettylights-syntax-invalid-illegal-text: #f0f6fc; - --color-prettylights-syntax-invalid-illegal-bg: #8e1519; - --color-prettylights-syntax-carriage-return-text: #f0f6fc; - --color-prettylights-syntax-carriage-return-bg: #b62324; - --color-prettylights-syntax-string-regexp: #7ee787; - --color-prettylights-syntax-markup-list: #f2cc60; - --color-prettylights-syntax-markup-heading: #1f6feb; - --color-prettylights-syntax-markup-italic: #f0f6fc; - --color-prettylights-syntax-markup-bold: #f0f6fc; - --color-prettylights-syntax-markup-deleted-text: #ffdcd7; - --color-prettylights-syntax-markup-deleted-bg: #67060c; - --color-prettylights-syntax-markup-inserted-text: #aff5b4; - --color-prettylights-syntax-markup-inserted-bg: #033a16; - --color-prettylights-syntax-markup-changed-text: #ffdfb6; - --color-prettylights-syntax-markup-changed-bg: #5a1e02; - --color-prettylights-syntax-markup-ignored-text: #f0f6fc; - --color-prettylights-syntax-markup-ignored-bg: #1158c7; - --color-prettylights-syntax-meta-diff-range: #d2a8ff; - --color-prettylights-syntax-sublimelinter-gutter-mark: #3d444d; - } - -/* 亮色主题变量 */ -.markdown-body[data-theme="light"] { - /* light */ - color-scheme: light; - --focus-outlineColor: #0969da; - --fgColor-default: #1f2328; - --fgColor-muted: #59636e; - --fgColor-accent: #0969da; - --fgColor-success: #1a7f37; - --fgColor-attention: #9a6700; - --fgColor-danger: #d1242f; - --fgColor-done: #8250df; - --bgColor-default: #ffffff; - --bgColor-muted: #f6f8fa; - --bgColor-neutral-muted: #818b981f; - --bgColor-attention-muted: #fff8c5; - --borderColor-default: #d1d9e0; - --borderColor-muted: #d1d9e0b3; - --borderColor-neutral-muted: #d1d9e0b3; - --borderColor-accent-emphasis: #0969da; - --borderColor-success-emphasis: #1a7f37; - --borderColor-attention-emphasis: #9a6700; - --borderColor-danger-emphasis: #cf222e; - --borderColor-done-emphasis: #8250df; - --color-prettylights-syntax-comment: #59636e; - --color-prettylights-syntax-constant: #0550ae; - --color-prettylights-syntax-constant-other-reference-link: #0a3069; - --color-prettylights-syntax-entity: #6639ba; - --color-prettylights-syntax-storage-modifier-import: #1f2328; - --color-prettylights-syntax-entity-tag: #0550ae; - --color-prettylights-syntax-keyword: #cf222e; - --color-prettylights-syntax-string: #0a3069; - --color-prettylights-syntax-variable: #953800; - --color-prettylights-syntax-brackethighlighter-unmatched: #82071e; - --color-prettylights-syntax-brackethighlighter-angle: #59636e; - --color-prettylights-syntax-invalid-illegal-text: #f6f8fa; - --color-prettylights-syntax-invalid-illegal-bg: #82071e; - --color-prettylights-syntax-carriage-return-text: #f6f8fa; - --color-prettylights-syntax-carriage-return-bg: #cf222e; - --color-prettylights-syntax-string-regexp: #116329; - --color-prettylights-syntax-markup-list: #3b2300; - --color-prettylights-syntax-markup-heading: #0550ae; - --color-prettylights-syntax-markup-italic: #1f2328; - --color-prettylights-syntax-markup-bold: #1f2328; - --color-prettylights-syntax-markup-deleted-text: #82071e; - --color-prettylights-syntax-markup-deleted-bg: #ffebe9; - --color-prettylights-syntax-markup-inserted-text: #116329; - --color-prettylights-syntax-markup-inserted-bg: #dafbe1; - --color-prettylights-syntax-markup-changed-text: #953800; - --color-prettylights-syntax-markup-changed-bg: #ffd8b5; - --color-prettylights-syntax-markup-ignored-text: #d1d9e0; - --color-prettylights-syntax-markup-ignored-bg: #0550ae; - --color-prettylights-syntax-meta-diff-range: #8250df; - --color-prettylights-syntax-sublimelinter-gutter-mark: #818b98; - } - - -.markdown-body { - -ms-text-size-adjust: 100%; - -webkit-text-size-adjust: 100%; - margin: 0; - color: var(--fgColor-default); - background-color: var(--bgColor-default); - font-family: -apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"; - font-size: 16px; - line-height: 1.5; - word-wrap: break-word; -} - -.markdown-body .octicon { - display: inline-block; - fill: currentColor; - vertical-align: text-bottom; -} - -.markdown-body h1:hover .anchor .octicon-link:before, -.markdown-body h2:hover .anchor .octicon-link:before, -.markdown-body h3:hover .anchor .octicon-link:before, -.markdown-body h4:hover .anchor .octicon-link:before, -.markdown-body h5:hover .anchor .octicon-link:before, -.markdown-body h6:hover .anchor .octicon-link:before { - width: 16px; - height: 16px; - content: ' '; - display: inline-block; - background-color: currentColor; - -webkit-mask-image: url("data:image/svg+xml,"); - mask-image: url("data:image/svg+xml,"); -} - -.markdown-body details, -.markdown-body figcaption, -.markdown-body figure { - display: block; -} - -.markdown-body summary { - display: list-item; -} - -.markdown-body [hidden] { - display: none !important; -} - -.markdown-body a { - background-color: transparent; - color: var(--fgColor-accent); - text-decoration: none; -} - -.markdown-body abbr[title] { - border-bottom: none; - -webkit-text-decoration: underline dotted; - text-decoration: underline dotted; -} - -.markdown-body b, -.markdown-body strong { - font-weight: var(--base-text-weight-semibold, 600); -} - -.markdown-body dfn { - font-style: italic; -} - -.markdown-body h1 { - margin: .67em 0; - font-weight: var(--base-text-weight-semibold, 600); - padding-bottom: .3em; - font-size: 2em; - border-bottom: 1px solid var(--borderColor-muted); -} - -.markdown-body mark { - background-color: var(--bgColor-attention-muted); - color: var(--fgColor-default); -} - -.markdown-body small { - font-size: 90%; -} - -.markdown-body sub, -.markdown-body sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -.markdown-body sub { - bottom: -0.25em; -} - -.markdown-body sup { - top: -0.5em; -} - -.markdown-body img { - border-style: none; - max-width: 100%; - box-sizing: content-box; -} - -.markdown-body code, -.markdown-body kbd, -.markdown-body pre, -.markdown-body samp { - font-family: monospace; - font-size: 1em; -} - -.markdown-body figure { - margin: 1em var(--base-size-40); -} - -.markdown-body hr { - box-sizing: content-box; - overflow: hidden; - background: transparent; - border-bottom: 1px solid var(--borderColor-muted); - height: .25em; - padding: 0; - margin: var(--base-size-24) 0; - background-color: var(--borderColor-default); - border: 0; -} - -.markdown-body input { - font: inherit; - margin: 0; - overflow: visible; - font-family: inherit; - font-size: inherit; - line-height: inherit; -} - -.markdown-body [type=button], -.markdown-body [type=reset], -.markdown-body [type=submit] { - -webkit-appearance: button; - appearance: button; -} - -.markdown-body [type=checkbox], -.markdown-body [type=radio] { - box-sizing: border-box; - padding: 0; -} - -.markdown-body [type=number]::-webkit-inner-spin-button, -.markdown-body [type=number]::-webkit-outer-spin-button { - height: auto; -} - -.markdown-body [type=search]::-webkit-search-cancel-button, -.markdown-body [type=search]::-webkit-search-decoration { - -webkit-appearance: none; - appearance: none; -} - -.markdown-body ::-webkit-input-placeholder { - color: inherit; - opacity: .54; -} - -.markdown-body ::-webkit-file-upload-button { - -webkit-appearance: button; - appearance: button; - font: inherit; -} - -.markdown-body a:hover { - text-decoration: underline; -} - -.markdown-body ::placeholder { - color: var(--fgColor-muted); - opacity: 1; -} - -.markdown-body hr::before { - display: table; - content: ""; -} - -.markdown-body hr::after { - display: table; - clear: both; - content: ""; -} - -.markdown-body table { - border-spacing: 0; - border-collapse: collapse; - display: block; - width: max-content; - max-width: 100%; - overflow: auto; - font-variant: tabular-nums; -} - -.markdown-body td, -.markdown-body th { - padding: 0; -} - -.markdown-body details summary { - cursor: pointer; -} - -.markdown-body a:focus, -.markdown-body [role=button]:focus, -.markdown-body input[type=radio]:focus, -.markdown-body input[type=checkbox]:focus { - outline: 2px solid var(--focus-outlineColor); - outline-offset: -2px; - box-shadow: none; -} - -.markdown-body a:focus:not(:focus-visible), -.markdown-body [role=button]:focus:not(:focus-visible), -.markdown-body input[type=radio]:focus:not(:focus-visible), -.markdown-body input[type=checkbox]:focus:not(:focus-visible) { - outline: solid 1px transparent; -} - -.markdown-body a:focus-visible, -.markdown-body [role=button]:focus-visible, -.markdown-body input[type=radio]:focus-visible, -.markdown-body input[type=checkbox]:focus-visible { - outline: 2px solid var(--focus-outlineColor); - outline-offset: -2px; - box-shadow: none; -} - -.markdown-body a:not([class]):focus, -.markdown-body a:not([class]):focus-visible, -.markdown-body input[type=radio]:focus, -.markdown-body input[type=radio]:focus-visible, -.markdown-body input[type=checkbox]:focus, -.markdown-body input[type=checkbox]:focus-visible { - outline-offset: 0; -} - -.markdown-body kbd { - display: inline-block; - padding: var(--base-size-4); - font: 11px var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); - line-height: 10px; - color: var(--fgColor-default); - vertical-align: middle; - background-color: var(--bgColor-muted); - border: solid 1px var(--borderColor-neutral-muted); - border-bottom-color: var(--borderColor-neutral-muted); - border-radius: 6px; - box-shadow: inset 0 -1px 0 var(--borderColor-neutral-muted); -} - -.markdown-body h1, -.markdown-body h2, -.markdown-body h3, -.markdown-body h4, -.markdown-body h5, -.markdown-body h6 { - margin-top: var(--base-size-24); - margin-bottom: var(--base-size-16); - font-weight: var(--base-text-weight-semibold, 600); - line-height: 1.25; -} - -.markdown-body h2 { - font-weight: var(--base-text-weight-semibold, 600); - padding-bottom: .3em; - font-size: 1.5em; - border-bottom: 1px solid var(--borderColor-muted); -} - -.markdown-body h3 { - font-weight: var(--base-text-weight-semibold, 600); - font-size: 1.25em; -} - -.markdown-body h4 { - font-weight: var(--base-text-weight-semibold, 600); - font-size: 1em; -} - -.markdown-body h5 { - font-weight: var(--base-text-weight-semibold, 600); - font-size: .875em; -} - -.markdown-body h6 { - font-weight: var(--base-text-weight-semibold, 600); - font-size: .85em; - color: var(--fgColor-muted); -} - -.markdown-body p { - margin-top: 0; - margin-bottom: 10px; -} - -.markdown-body blockquote { - margin: 0; - padding: 0 1em; - color: var(--fgColor-muted); - border-left: .25em solid var(--borderColor-default); -} - -.markdown-body ul, -.markdown-body ol { - margin-top: 0; - margin-bottom: 0; - padding-left: 2em; -} - -.markdown-body ol ol, -.markdown-body ul ol { - list-style-type: lower-roman; -} - -.markdown-body ul ul ol, -.markdown-body ul ol ol, -.markdown-body ol ul ol, -.markdown-body ol ol ol { - list-style-type: lower-alpha; -} - -.markdown-body dd { - margin-left: 0; -} - -.markdown-body tt, -.markdown-body code, -.markdown-body samp { - font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); - font-size: 12px; -} - -.markdown-body pre { - margin-top: 0; - margin-bottom: 0; - font-family: var(--fontStack-monospace, ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace); - font-size: 12px; - word-wrap: normal; -} - -.markdown-body .octicon { - display: inline-block; - overflow: visible !important; - vertical-align: text-bottom; - fill: currentColor; -} - -.markdown-body input::-webkit-outer-spin-button, -.markdown-body input::-webkit-inner-spin-button { - margin: 0; - appearance: none; -} - -.markdown-body .mr-2 { - margin-right: var(--base-size-8, 8px) !important; -} - -.markdown-body::before { - display: table; - content: ""; -} - -.markdown-body::after { - display: table; - clear: both; - content: ""; -} - -.markdown-body>*:first-child { - margin-top: 0 !important; -} - -.markdown-body>*:last-child { - margin-bottom: 0 !important; -} - -.markdown-body a:not([href]) { - color: inherit; - text-decoration: none; -} - -.markdown-body .absent { - color: var(--fgColor-danger); -} - -.markdown-body .anchor { - float: left; - padding-right: var(--base-size-4); - margin-left: -20px; - line-height: 1; -} - -.markdown-body .anchor:focus { - outline: none; -} - -.markdown-body p, -.markdown-body blockquote, -.markdown-body ul, -.markdown-body ol, -.markdown-body dl, -.markdown-body table, -.markdown-body pre, -.markdown-body details { - margin-top: 0; - margin-bottom: var(--base-size-16); -} - -.markdown-body blockquote>:first-child { - margin-top: 0; -} - -.markdown-body blockquote>:last-child { - margin-bottom: 0; -} - -.markdown-body h1 .octicon-link, -.markdown-body h2 .octicon-link, -.markdown-body h3 .octicon-link, -.markdown-body h4 .octicon-link, -.markdown-body h5 .octicon-link, -.markdown-body h6 .octicon-link { - color: var(--fgColor-default); - vertical-align: middle; - visibility: hidden; -} - -.markdown-body h1:hover .anchor, -.markdown-body h2:hover .anchor, -.markdown-body h3:hover .anchor, -.markdown-body h4:hover .anchor, -.markdown-body h5:hover .anchor, -.markdown-body h6:hover .anchor { - text-decoration: none; -} - -.markdown-body h1:hover .anchor .octicon-link, -.markdown-body h2:hover .anchor .octicon-link, -.markdown-body h3:hover .anchor .octicon-link, -.markdown-body h4:hover .anchor .octicon-link, -.markdown-body h5:hover .anchor .octicon-link, -.markdown-body h6:hover .anchor .octicon-link { - visibility: visible; -} - -.markdown-body h1 tt, -.markdown-body h1 code, -.markdown-body h2 tt, -.markdown-body h2 code, -.markdown-body h3 tt, -.markdown-body h3 code, -.markdown-body h4 tt, -.markdown-body h4 code, -.markdown-body h5 tt, -.markdown-body h5 code, -.markdown-body h6 tt, -.markdown-body h6 code { - padding: 0 .2em; - font-size: inherit; -} - -.markdown-body summary h1, -.markdown-body summary h2, -.markdown-body summary h3, -.markdown-body summary h4, -.markdown-body summary h5, -.markdown-body summary h6 { - display: inline-block; -} - -.markdown-body summary h1 .anchor, -.markdown-body summary h2 .anchor, -.markdown-body summary h3 .anchor, -.markdown-body summary h4 .anchor, -.markdown-body summary h5 .anchor, -.markdown-body summary h6 .anchor { - margin-left: -40px; -} - -.markdown-body summary h1, -.markdown-body summary h2 { - padding-bottom: 0; - border-bottom: 0; -} - -.markdown-body ul.no-list, -.markdown-body ol.no-list { - padding: 0; - list-style-type: none; -} - -.markdown-body ol[type="a s"] { - list-style-type: lower-alpha; -} - -.markdown-body ol[type="A s"] { - list-style-type: upper-alpha; -} - -.markdown-body ol[type="i s"] { - list-style-type: lower-roman; -} - -.markdown-body ol[type="I s"] { - list-style-type: upper-roman; -} - -.markdown-body ol[type="1"] { - list-style-type: decimal; -} - -.markdown-body div>ol:not([type]) { - list-style-type: decimal; -} - -.markdown-body ul ul, -.markdown-body ul ol, -.markdown-body ol ol, -.markdown-body ol ul { - margin-top: 0; - margin-bottom: 0; -} - -.markdown-body li>p { - margin-top: var(--base-size-16); -} - -.markdown-body li+li { - margin-top: .25em; -} - -.markdown-body dl { - padding: 0; -} - -.markdown-body dl dt { - padding: 0; - margin-top: var(--base-size-16); - font-size: 1em; - font-style: italic; - font-weight: var(--base-text-weight-semibold, 600); -} - -.markdown-body dl dd { - padding: 0 var(--base-size-16); - margin-bottom: var(--base-size-16); -} - -.markdown-body table th { - font-weight: var(--base-text-weight-semibold, 600); -} - -.markdown-body table th, -.markdown-body table td { - padding: 6px 13px; - border: 1px solid var(--borderColor-default); -} - -.markdown-body table td>:last-child { - margin-bottom: 0; -} - -.markdown-body table tr { - background-color: var(--bgColor-default); - border-top: 1px solid var(--borderColor-muted); -} - -.markdown-body table tr:nth-child(2n) { - background-color: var(--bgColor-muted); -} - -.markdown-body table img { - background-color: transparent; -} - -.markdown-body img[align=right] { - padding-left: 20px; -} - -.markdown-body img[align=left] { - padding-right: 20px; -} - -.markdown-body .emoji { - max-width: none; - vertical-align: text-top; - background-color: transparent; -} - -.markdown-body span.frame { - display: block; - overflow: hidden; -} - -.markdown-body span.frame>span { - display: block; - float: left; - width: auto; - padding: 7px; - margin: 13px 0 0; - overflow: hidden; - border: 1px solid var(--borderColor-default); -} - -.markdown-body span.frame span img { - display: block; - float: left; -} - -.markdown-body span.frame span span { - display: block; - padding: 5px 0 0; - clear: both; - color: var(--fgColor-default); -} - -.markdown-body span.align-center { - display: block; - overflow: hidden; - clear: both; -} - -.markdown-body span.align-center>span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: center; -} - -.markdown-body span.align-center span img { - margin: 0 auto; - text-align: center; -} - -.markdown-body span.align-right { - display: block; - overflow: hidden; - clear: both; -} - -.markdown-body span.align-right>span { - display: block; - margin: 13px 0 0; - overflow: hidden; - text-align: right; -} - -.markdown-body span.align-right span img { - margin: 0; - text-align: right; -} - -.markdown-body span.float-left { - display: block; - float: left; - margin-right: 13px; - overflow: hidden; -} - -.markdown-body span.float-left span { - margin: 13px 0 0; -} - -.markdown-body span.float-right { - display: block; - float: right; - margin-left: 13px; - overflow: hidden; -} - -.markdown-body span.float-right>span { - display: block; - margin: 13px auto 0; - overflow: hidden; - text-align: right; -} - -.markdown-body code, -.markdown-body tt { - padding: .2em .4em; - margin: 0; - font-size: 85%; - white-space: break-spaces; - background-color: var(--bgColor-neutral-muted); - border-radius: 6px; -} - -.markdown-body code br, -.markdown-body tt br { - display: none; -} - -.markdown-body del code { - text-decoration: inherit; -} - -.markdown-body samp { - font-size: 85%; -} - -.markdown-body pre code { - font-size: 100%; -} - -.markdown-body pre>code { - padding: 0; - margin: 0; - word-break: normal; - white-space: pre; - background: transparent; - border: 0; -} - -.markdown-body .highlight { - margin-bottom: var(--base-size-16); -} - -.markdown-body .highlight pre { - margin-bottom: 0; - word-break: normal; -} - -.markdown-body .highlight pre, -.markdown-body pre { - padding: var(--base-size-16); - overflow: auto; - font-size: 85%; - line-height: 1.45; - color: var(--fgColor-default); - background-color: var(--bgColor-muted); - border-radius: 6px; -} - -.markdown-body pre code, -.markdown-body pre tt { - display: inline; - max-width: fit-content; - padding: 0; - margin: 0; - overflow: visible; - line-height: inherit; - word-wrap: normal; - background-color: transparent; - border: 0; -} - -.markdown-body .csv-data td, -.markdown-body .csv-data th { - padding: 5px; - overflow: hidden; - font-size: 12px; - line-height: 1; - text-align: left; - white-space: nowrap; -} - -.markdown-body .csv-data .blob-num { - padding: 10px var(--base-size-8) 9px; - text-align: right; - background: var(--bgColor-default); - border: 0; -} - -.markdown-body .csv-data tr { - border-top: 0; -} - -.markdown-body .csv-data th { - font-weight: var(--base-text-weight-semibold, 600); - background: var(--bgColor-muted); - border-top: 0; -} - -.markdown-body [data-footnote-ref]::before { - content: "["; -} - -.markdown-body [data-footnote-ref]::after { - content: "]"; -} - -.markdown-body .footnotes { - font-size: 12px; - color: var(--fgColor-muted); - border-top: 1px solid var(--borderColor-default); -} - -.markdown-body .footnotes ol { - padding-left: var(--base-size-16); -} - -.markdown-body .footnotes ol ul { - display: inline-block; - padding-left: var(--base-size-16); - margin-top: var(--base-size-16); -} - -.markdown-body .footnotes li { - position: relative; -} - -.markdown-body .footnotes li:target::before { - position: absolute; - top: calc(var(--base-size-8)*-1); - right: calc(var(--base-size-8)*-1); - bottom: calc(var(--base-size-8)*-1); - left: calc(var(--base-size-24)*-1); - pointer-events: none; - content: ""; - border: 2px solid var(--borderColor-accent-emphasis); - border-radius: 6px; -} - -.markdown-body .footnotes li:target { - color: var(--fgColor-default); -} - -.markdown-body .footnotes .data-footnote-backref g-emoji { - font-family: monospace; -} - -.markdown-body body:has(:modal) { - padding-right: var(--dialog-scrollgutter) !important; -} - -.markdown-body .pl-c { - color: var(--color-prettylights-syntax-comment); -} - -.markdown-body .pl-c1, -.markdown-body .pl-s .pl-v { - color: var(--color-prettylights-syntax-constant); -} - -.markdown-body .pl-e, -.markdown-body .pl-en { - color: var(--color-prettylights-syntax-entity); -} - -.markdown-body .pl-smi, -.markdown-body .pl-s .pl-s1 { - color: var(--color-prettylights-syntax-storage-modifier-import); -} - -.markdown-body .pl-ent { - color: var(--color-prettylights-syntax-entity-tag); -} - -.markdown-body .pl-k { - color: var(--color-prettylights-syntax-keyword); -} - -.markdown-body .pl-s, -.markdown-body .pl-pds, -.markdown-body .pl-s .pl-pse .pl-s1, -.markdown-body .pl-sr, -.markdown-body .pl-sr .pl-cce, -.markdown-body .pl-sr .pl-sre, -.markdown-body .pl-sr .pl-sra { - color: var(--color-prettylights-syntax-string); -} - -.markdown-body .pl-v, -.markdown-body .pl-smw { - color: var(--color-prettylights-syntax-variable); -} - -.markdown-body .pl-bu { - color: var(--color-prettylights-syntax-brackethighlighter-unmatched); -} - -.markdown-body .pl-ii { - color: var(--color-prettylights-syntax-invalid-illegal-text); - background-color: var(--color-prettylights-syntax-invalid-illegal-bg); -} - -.markdown-body .pl-c2 { - color: var(--color-prettylights-syntax-carriage-return-text); - background-color: var(--color-prettylights-syntax-carriage-return-bg); -} - -.markdown-body .pl-sr .pl-cce { - font-weight: bold; - color: var(--color-prettylights-syntax-string-regexp); -} - -.markdown-body .pl-ml { - color: var(--color-prettylights-syntax-markup-list); -} - -.markdown-body .pl-mh, -.markdown-body .pl-mh .pl-en, -.markdown-body .pl-ms { - font-weight: bold; - color: var(--color-prettylights-syntax-markup-heading); -} - -.markdown-body .pl-mi { - font-style: italic; - color: var(--color-prettylights-syntax-markup-italic); -} - -.markdown-body .pl-mb { - font-weight: bold; - color: var(--color-prettylights-syntax-markup-bold); -} - -.markdown-body .pl-md { - color: var(--color-prettylights-syntax-markup-deleted-text); - background-color: var(--color-prettylights-syntax-markup-deleted-bg); -} - -.markdown-body .pl-mi1 { - color: var(--color-prettylights-syntax-markup-inserted-text); - background-color: var(--color-prettylights-syntax-markup-inserted-bg); -} - -.markdown-body .pl-mc { - color: var(--color-prettylights-syntax-markup-changed-text); - background-color: var(--color-prettylights-syntax-markup-changed-bg); -} - -.markdown-body .pl-mi2 { - color: var(--color-prettylights-syntax-markup-ignored-text); - background-color: var(--color-prettylights-syntax-markup-ignored-bg); -} - -.markdown-body .pl-mdr { - font-weight: bold; - color: var(--color-prettylights-syntax-meta-diff-range); -} - -.markdown-body .pl-ba { - color: var(--color-prettylights-syntax-brackethighlighter-angle); -} - -.markdown-body .pl-sg { - color: var(--color-prettylights-syntax-sublimelinter-gutter-mark); -} - -.markdown-body .pl-corl { - text-decoration: underline; - color: var(--color-prettylights-syntax-constant-other-reference-link); -} - -.markdown-body [role=button]:focus:not(:focus-visible), -.markdown-body [role=tabpanel][tabindex="0"]:focus:not(:focus-visible), -.markdown-body button:focus:not(:focus-visible), -.markdown-body summary:focus:not(:focus-visible), -.markdown-body a:focus:not(:focus-visible) { - outline: none; - box-shadow: none; -} - -.markdown-body [tabindex="0"]:focus:not(:focus-visible), -.markdown-body details-dialog:focus:not(:focus-visible) { - outline: none; -} - -.markdown-body g-emoji { - display: inline-block; - min-width: 1ch; - font-family: "Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol"; - font-size: 1em; - font-style: normal !important; - font-weight: var(--base-text-weight-normal, 400); - line-height: 1; - vertical-align: -0.075em; -} - -.markdown-body g-emoji img { - width: 1em; - height: 1em; -} - -.markdown-body .task-list-item { - list-style-type: none; -} - -.markdown-body .task-list-item label { - font-weight: var(--base-text-weight-normal, 400); -} - -.markdown-body .task-list-item.enabled label { - cursor: pointer; -} - -.markdown-body .task-list-item+.task-list-item { - margin-top: var(--base-size-4); -} - -.markdown-body .task-list-item .handle { - display: none; -} - -.markdown-body .task-list-item-checkbox { - margin: 0 .2em .25em -1.4em; - vertical-align: middle; -} - -.markdown-body ul:dir(rtl) .task-list-item-checkbox { - margin: 0 -1.6em .25em .2em; -} - -.markdown-body ol:dir(rtl) .task-list-item-checkbox { - margin: 0 -1.6em .25em .2em; -} - -.markdown-body .contains-task-list:hover .task-list-item-convert-container, -.markdown-body .contains-task-list:focus-within .task-list-item-convert-container { - display: block; - width: auto; - height: 24px; - overflow: visible; - clip: auto; -} - -.markdown-body ::-webkit-calendar-picker-indicator { - filter: invert(50%); -} - -.markdown-body .markdown-alert { - padding: var(--base-size-8) var(--base-size-16); - margin-bottom: var(--base-size-16); - color: inherit; - border-left: .25em solid var(--borderColor-default); -} - -.markdown-body .markdown-alert>:first-child { - margin-top: 0; -} - -.markdown-body .markdown-alert>:last-child { - margin-bottom: 0; -} - -.markdown-body .markdown-alert .markdown-alert-title { - display: flex; - font-weight: var(--base-text-weight-medium, 500); - align-items: center; - line-height: 1; -} - -.markdown-body .markdown-alert.markdown-alert-note { - border-left-color: var(--borderColor-accent-emphasis); -} - -.markdown-body .markdown-alert.markdown-alert-note .markdown-alert-title { - color: var(--fgColor-accent); -} - -.markdown-body .markdown-alert.markdown-alert-important { - border-left-color: var(--borderColor-done-emphasis); -} - -.markdown-body .markdown-alert.markdown-alert-important .markdown-alert-title { - color: var(--fgColor-done); -} - -.markdown-body .markdown-alert.markdown-alert-warning { - border-left-color: var(--borderColor-attention-emphasis); -} - -.markdown-body .markdown-alert.markdown-alert-warning .markdown-alert-title { - color: var(--fgColor-attention); -} - -.markdown-body .markdown-alert.markdown-alert-tip { - border-left-color: var(--borderColor-success-emphasis); -} - -.markdown-body .markdown-alert.markdown-alert-tip .markdown-alert-title { - color: var(--fgColor-success); -} - -.markdown-body .markdown-alert.markdown-alert-caution { - border-left-color: var(--borderColor-danger-emphasis); -} - -.markdown-body .markdown-alert.markdown-alert-caution .markdown-alert-title { - color: var(--fgColor-danger); -} - -.markdown-body>*:first-child>.heading-element:first-child { - margin-top: 0 !important; -} - -.markdown-body .highlight pre:has(+.zeroclipboard-container) { - min-height: 52px; -} - diff --git a/frontend/src/views/editor/extensions/markdown/index.ts b/frontend/src/views/editor/extensions/markdown/index.ts index f04e4bb..fc46a97 100644 --- a/frontend/src/views/editor/extensions/markdown/index.ts +++ b/frontend/src/views/editor/extensions/markdown/index.ts @@ -8,49 +8,17 @@ import { image } from './plugins/image'; import { links } from './plugins/link'; import { lists } from './plugins/list'; import { headingSlugField } from './state/heading-slug'; -import { imagePreview } from './state/image'; - -// New enhanced features import { codeblockEnhanced } from './plugins/code-block-enhanced'; import { emoji } from './plugins/emoji'; import { horizontalRule } from './plugins/horizontal-rule'; -import { revealOnArrow } from './plugins/reveal-on-arrow'; -import { pasteRichText } from './plugins/paste-rich-text'; - -// State fields -export { headingSlugField } from './state/heading-slug'; -export { imagePreview } from './state/image'; - -// Core Extensions -export { blockquote } from './plugins/blockquote'; -export { codeblock } from './plugins/code-block'; -export { frontmatter } from './plugins/frontmatter'; -export { headings } from './plugins/heading'; -export { hideMarks } from './plugins/hide-mark'; -export { image } from './plugins/image'; -export { htmlBlock, htmlBlockExtension } from './plugins/html'; -export { links } from './plugins/link'; -export { lists } from './plugins/list'; - -// Enhanced Extensions -export { codeblockEnhanced } from './plugins/code-block-enhanced'; -export { emoji, addEmoji, getEmojiNames } from './plugins/emoji'; -export { horizontalRule } from './plugins/horizontal-rule'; -export { revealOnArrow } from './plugins/reveal-on-arrow'; -export { pasteRichText } from './plugins/paste-rich-text'; - -// Classes -export * as classes from './classes'; +import { inlineCode } from './plugins/inline-code'; /** - * markdown extensions (includes all ProseMark-inspired features). - * NOTE: All decorations avoid using block: true to prevent interfering - * with the codeblock system's boundary calculations. + * markdown extensions */ export const markdownExtensions: Extension = [ headingSlugField, - imagePreview, blockquote(), codeblock(), headings(), @@ -63,8 +31,7 @@ export const markdownExtensions: Extension = [ codeblockEnhanced(), emoji(), horizontalRule(), - revealOnArrow(), - pasteRichText() + inlineCode(), ]; export default markdownExtensions; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/code-block-enhanced.ts b/frontend/src/views/editor/extensions/markdown/plugins/code-block-enhanced.ts index c6e9f46..97c406b 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/code-block-enhanced.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/code-block-enhanced.ts @@ -53,7 +53,7 @@ class CodeBlockInfoWidget extends WidgetType { // Copy button const copyButton = document.createElement('button'); copyButton.className = 'cm-code-block-copy-btn'; - copyButton.title = '复制代码'; + copyButton.title = 'Copy'; copyButton.innerHTML = ` @@ -162,46 +162,46 @@ const codeBlockEnhancedPlugin = ViewPlugin.fromClass(CodeBlockEnhancedPlugin, { /** * Enhanced theme for code blocks. + * Uses CSS variables from variables.css for consistent theming. */ const enhancedTheme = EditorView.baseTheme({ '.cm-code-block-info': { float: 'right', display: 'flex', alignItems: 'center', - gap: '0.5rem', - padding: '0.2rem 0.4rem', - fontSize: '0.75rem', - opacity: '0.7', - transition: 'opacity 0.2s' + gap: '0.4rem', + padding: '0.15rem 0.3rem', + opacity: '0.6', + transition: 'opacity 0.15s ease' }, '.cm-code-block-info:hover': { opacity: '1' }, '.cm-code-block-lang': { - fontFamily: 'monospace', + fontFamily: 'var(--voidraft-font-mono)', fontSize: '0.7rem', - fontWeight: '600', - color: 'var(--cm-fg-muted, #888)' + fontWeight: '500', + letterSpacing: '0.02em', + color: 'var(--cm-codeblock-lang-color)' }, '.cm-code-block-copy-btn': { display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', - background: 'transparent', - padding: '0.2rem', - borderRadius: '0.25rem', + backgroundColor: 'transparent', + borderRadius: '0.2rem', cursor: 'pointer', - color: 'var(--cm-fg-muted, #888)', - transition: 'background-color 0.2s, color 0.2s' + color: 'var(--cm-codeblock-btn-color)', + transition: 'background-color 0.15s ease, color 0.15s ease' }, '.cm-code-block-copy-btn:hover': { - backgroundColor: 'var(--cm-bg-hover, rgba(0, 0, 0, 0.1))', - color: 'var(--cm-fg, inherit)' + // backgroundColor: 'var(--cm-codeblock-btn-hover-bg)', + color: 'var(--cm-codeblock-btn-hover-color)' }, '.cm-code-block-copy-btn svg': { - width: '16px', - height: '16px' + width: '14px', + height: '14px' } }); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts b/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts index 404cfbd..fc4b6cb 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts @@ -56,25 +56,26 @@ function buildCodeBlockDecorations(view: EditorView): DecorationSet { for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) { const line = view.state.doc.line(lineNum); - // Determine line position class - let positionClass = ''; - if (lineNum === startLine.number) { - positionClass = classes.widgetBegin; - } else if (lineNum === endLine.number) { - positionClass = classes.widgetEnd; - } + // Determine line position class(es) + const isFirst = lineNum === startLine.number; + const isLast = lineNum === endLine.number; + + // Build class list - a single line block needs both begin and end classes + const positionClasses: string[] = []; + if (isFirst) positionClasses.push(classes.widgetBegin); + if (isLast) positionClasses.push(classes.widgetEnd); decorations.push( Decoration.line({ - class: `${classes.widget} ${positionClass}`.trim() + class: `${classes.widget} ${positionClasses.join(' ')}`.trim() }).range(line.from) ); } // Hide code markers when cursor is outside the block if (!cursorInBlock) { - const codeBlock = node.toTree(); - codeBlock.iterate({ + const codeBlock = node.toTree(); + codeBlock.iterate({ enter: ({ type: childType, from: childFrom, to: childTo }) => { if (childType.name === 'CodeInfo' || childType.name === 'CodeMark') { decorations.push( @@ -83,12 +84,12 @@ function buildCodeBlockDecorations(view: EditorView): DecorationSet { nodeFrom + childTo ) ); - } - } - }); + } } + }); + } } - }); + }); } // Use Decoration.set with sort=true to handle unsorted ranges @@ -139,15 +140,18 @@ const codeBlockPlugin = ViewPlugin.fromClass(CodeBlockPlugin, { /** * Base theme for code blocks. + * Uses CSS variables from variables.css for consistent theming. */ const baseTheme = EditorView.baseTheme({ [`.${classes.widget}`]: { - backgroundColor: 'var(--cm-codeblock-bg, rgba(128, 128, 128, 0.1))' + backgroundColor: 'var(--cm-codeblock-bg)', }, [`.${classes.widgetBegin}`]: { - borderRadius: '5px 5px 0 0' + borderTopLeftRadius: 'var(--cm-codeblock-radius)', + borderTopRightRadius: 'var(--cm-codeblock-radius)' }, [`.${classes.widgetEnd}`]: { - borderRadius: '0 0 5px 5px' + borderBottomLeftRadius: 'var(--cm-codeblock-radius)', + borderBottomRightRadius: 'var(--cm-codeblock-radius)' } }); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/heading.ts b/frontend/src/views/editor/extensions/markdown/plugins/heading.ts index a7bc7f9..a8f89f0 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/heading.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/heading.ts @@ -16,9 +16,9 @@ function isSelectionInRange(state: EditorState, from: number, to: number): boole return state.selection.ranges.some( (range) => from <= range.to && to >= range.from ); -} + } -/** + /** * Build heading decorations. * Hides # marks when cursor is not on the heading line. */ diff --git a/frontend/src/views/editor/extensions/markdown/plugins/html.ts b/frontend/src/views/editor/extensions/markdown/plugins/html.ts index 9abe544..f11e3d7 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/html.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/html.ts @@ -22,9 +22,7 @@ function extractHTMLBlocks(state: EditorState) { if (name !== 'HTMLBlock') return; if (isCursorInRange(state, [from, to])) return; const html = state.sliceDoc(from, to); - const content = DOMPurify.sanitize(html, { - FORBID_ATTR: ['style'] - }); + const content = DOMPurify.sanitize(html); blocks.push({ from, @@ -36,14 +34,26 @@ function extractHTMLBlocks(state: EditorState) { return blocks; } +// Decoration to hide the original HTML source code +const hideDecoration = Decoration.replace({}); + function blockToDecoration(blocks: EmbedBlockData[]): Range[] { - return blocks.map((block) => - Decoration.widget({ - widget: new HTMLBlockWidget(block), - // NOTE: NOT using block: true to avoid affecting codeblock boundaries - side: 1 - }).range(block.to) - ); + const decorations: Range[] = []; + + for (const block of blocks) { + // Hide the original HTML source code + decorations.push(hideDecoration.range(block.from, block.to)); + + // Add the preview widget at the end + decorations.push( + Decoration.widget({ + widget: new HTMLBlockWidget(block), + side: 1 + }).range(block.to) + ); + } + + return decorations; } export const htmlBlock = StateField.define({ @@ -69,12 +79,38 @@ class HTMLBlockWidget extends WidgetType { super(); } - toDOM(): HTMLElement { - const dom = document.createElement('span'); - dom.className = 'cm-html-block-widget'; + toDOM(view: EditorView): HTMLElement { + const wrapper = document.createElement('span'); + wrapper.className = 'cm-html-block-widget'; + + // Content container + const content = document.createElement('span'); + content.className = 'cm-html-block-content'; // This is sanitized! - dom.innerHTML = this.data.content; - return dom; + content.innerHTML = this.data.content; + + // Edit button + const editBtn = document.createElement('button'); + editBtn.className = 'cm-html-block-edit-btn'; + editBtn.innerHTML = ` + + + `; + editBtn.title = 'Edit HTML'; + + editBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + view.dispatch({ + selection: { anchor: this.data.from } + }); + view.focus(); + }); + + wrapper.appendChild(content); + wrapper.appendChild(editBtn); + + return wrapper; } eq(widget: HTMLBlockWidget): boolean { @@ -87,9 +123,36 @@ class HTMLBlockWidget extends WidgetType { */ const baseTheme = EditorView.baseTheme({ '.cm-html-block-widget': { - display: 'inline-block', + display: 'block', + position: 'relative', width: '100%', overflow: 'auto' + }, + '.cm-html-block-content': { + display: 'block' + }, + '.cm-html-block-edit-btn': { + position: 'absolute', + top: '4px', + right: '4px', + padding: '4px', + border: 'none', + borderRadius: '4px', + background: 'rgba(128, 128, 128, 0.2)', + color: 'inherit', + cursor: 'pointer', + opacity: '0', + transition: 'opacity 0.2s, background 0.2s', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: '10' + }, + '.cm-html-block-widget:hover .cm-html-block-edit-btn': { + opacity: '1' + }, + '.cm-html-block-edit-btn:hover': { + background: 'rgba(128, 128, 128, 0.4)' } }); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/image.ts b/frontend/src/views/editor/extensions/markdown/plugins/image.ts index 040ea8f..53ced6b 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/image.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/image.ts @@ -14,7 +14,11 @@ import { invisibleDecoration } from '../util'; -function hideNodes(view: EditorView) { +/** + * Build decorations to hide image markdown syntax. + * Only hides when cursor is outside the image range. + */ +function hideImageNodes(view: EditorView) { const widgets = new Array>(); iterateTreeInVisibleRanges(view, { enter(node) { @@ -29,32 +33,31 @@ function hideNodes(view: EditorView) { return Decoration.set(widgets, true); } +/** + * Plugin to hide image markdown syntax when cursor is outside. + */ const hideImageNodePlugin = ViewPlugin.fromClass( class { decorations: DecorationSet; constructor(view: EditorView) { - this.decorations = hideNodes(view); + this.decorations = hideImageNodes(view); } update(update: ViewUpdate) { - if (update.docChanged || update.selectionSet) - this.decorations = hideNodes(update.view); + if (update.docChanged || update.selectionSet || update.viewportChanged) { + this.decorations = hideImageNodes(update.view); + } } }, { decorations: (v) => v.decorations } ); /** - * Ixora Image plugin. - * - * This plugin allows to - * - Add a preview of an image in the document. - * - * @returns The image plugin. + * Image plugin. */ export const image = (): Extension => [ - imagePreview, + imagePreview(), hideImageNodePlugin, baseTheme ]; @@ -64,7 +67,6 @@ const baseTheme = EditorView.baseTheme({ display: 'block', objectFit: 'contain', maxWidth: '100%', - paddingLeft: '4px', maxHeight: '100%', userSelect: 'none' } diff --git a/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts b/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts new file mode 100644 index 0000000..2ef2e04 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/inline-code.ts @@ -0,0 +1,112 @@ +import { Extension, Range } from '@codemirror/state'; +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate +} from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; +import { isCursorInRange } from '../util'; + +/** + * Inline code styling plugin. + * + * This plugin adds visual styling to inline code (`code`): + * - Background color + * - Border radius + * - Padding effect via marks + */ +export const inlineCode = (): Extension => [inlineCodePlugin, baseTheme]; + +/** + * Build inline code decorations. + */ +function buildInlineCodeDecorations(view: EditorView): DecorationSet { + const decorations: Range[] = []; + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from: nodeFrom, to: nodeTo }) => { + if (type.name !== 'InlineCode') return; + + // Get the actual code content (excluding backticks) + const text = view.state.doc.sliceString(nodeFrom, nodeTo); + + // Find backtick positions + let codeStart = nodeFrom; + let codeEnd = nodeTo; + + // Skip opening backticks + let i = 0; + while (i < text.length && text[i] === '`') { + codeStart++; + i++; + } + + // Skip closing backticks + let j = text.length - 1; + while (j >= 0 && text[j] === '`') { + codeEnd--; + j--; + } + + // Only add decoration if there's actual content + if (codeStart < codeEnd) { + const cursorInCode = isCursorInRange(view.state, [nodeFrom, nodeTo]); + + // Add mark decoration for the code content + decorations.push( + Decoration.mark({ + class: cursorInCode ? 'cm-inline-code cm-inline-code-active' : 'cm-inline-code' + }).range(codeStart, codeEnd) + ); + } + } + }); + } + + return Decoration.set(decorations, true); +} + +/** + * Inline code plugin class. + */ +class InlineCodePlugin { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = buildInlineCodeDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = buildInlineCodeDecorations(update.view); + } + } +} + +const inlineCodePlugin = ViewPlugin.fromClass(InlineCodePlugin, { + decorations: (v) => v.decorations +}); + +/** + * Base theme for inline code. + * Uses CSS variables from variables.css for consistent theming. + */ +const baseTheme = EditorView.baseTheme({ + '.cm-inline-code': { + backgroundColor: 'var(--cm-inline-code-bg)', + borderRadius: '0.25rem', + padding: '0.1rem 0.3rem', + fontFamily: 'var(--voidraft-font-mono)', + fontSize: '0.9em' + }, + '.cm-inline-code-active': { + // Slightly different style when cursor is inside + backgroundColor: 'var(--cm-inline-code-bg)' + } +}); + diff --git a/frontend/src/views/editor/extensions/markdown/plugins/link.ts b/frontend/src/views/editor/extensions/markdown/plugins/link.ts index cd078fb..4cb0235 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/link.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/link.ts @@ -5,12 +5,9 @@ import { DecorationSet, EditorView, ViewPlugin, - ViewUpdate, - WidgetType + ViewUpdate } from '@codemirror/view'; -import { headingSlugField } from '../state/heading-slug'; import { checkRangeOverlap, isCursorInRange, invisibleDecoration } from '../util'; -import { link as classes } from '../classes'; /** * Pattern for auto-link markers (< and >). @@ -18,7 +15,7 @@ import { link as classes } from '../classes'; const AUTO_LINK_MARK_RE = /^<|>$/g; /** - * Parent node types that should not have link widgets. + * Parent node types that should not process. */ const BLACKLISTED_PARENTS = new Set(['Image']); @@ -26,69 +23,14 @@ const BLACKLISTED_PARENTS = new Set(['Image']); * Links plugin. * * Features: - * - Adds interactive link icon for navigation - * - Supports internal anchor links (#heading) * - Hides link markup when cursor is outside + * - Link icons and click events are handled by hyperlink extension */ -export const links = () => [goToLinkPlugin, baseTheme]; - -/** - * Link widget for external/internal navigation. - */ -export class GoToLinkWidget extends WidgetType { - constructor( - readonly link: string, - readonly title?: string - ) { - super(); - } - - eq(other: GoToLinkWidget): boolean { - return other.link === this.link && other.title === this.title; - } - - toDOM(view: EditorView): HTMLElement { - const anchor = document.createElement('a'); - anchor.classList.add(classes.widget); - anchor.textContent = '🔗'; - - if (this.link.startsWith('#')) { - // Handle internal anchor links - anchor.href = 'javascript:void(0)'; - anchor.addEventListener('click', (e) => { - e.preventDefault(); - const slugs = view.state.field(headingSlugField); - const targetSlug = this.link.slice(1); - const pos = slugs.find((h) => h.slug === targetSlug)?.pos; - - if (typeof pos !== 'undefined') { - view.dispatch({ - selection: { anchor: pos }, - scrollIntoView: true - }); - } - }); - } else { - // External links - anchor.href = this.link; - anchor.target = '_blank'; - anchor.rel = 'noopener noreferrer'; - } - - if (this.title) { - anchor.title = this.title; - } - - return anchor; - } - - ignoreEvent(): boolean { - return false; - } -} +export const links = () => [goToLinkPlugin]; /** * Build link decorations. + * Only hides markdown syntax marks, no icons added. * Uses array + Decoration.set() for automatic sorting. */ function buildLinkDecorations(view: EditorView): DecorationSet { @@ -126,30 +68,15 @@ function buildLinkDecorations(view: EditorView): DecorationSet { } // Get link content - let linkContent = view.state.sliceDoc(nodeFrom, nodeTo); + const linkContent = view.state.sliceDoc(nodeFrom, nodeTo); // Handle auto-links with < > markers if (AUTO_LINK_MARK_RE.test(linkContent)) { - linkContent = linkContent.replace(AUTO_LINK_MARK_RE, ''); - if (!isCursorInRange(view.state, [node.from, node.to])) { decorations.push(invisibleDecoration.range(nodeFrom, nodeFrom + 1)); decorations.push(invisibleDecoration.range(nodeTo - 1, nodeTo)); } } - - // Get link title content - const linkTitleContent = linkTitle - ? view.state.sliceDoc(linkTitle.from, linkTitle.to) - : undefined; - - // Add link widget - decorations.push( - Decoration.widget({ - widget: new GoToLinkWidget(linkContent, linkTitleContent), - side: 1 - }).range(nodeTo) - ); } }); } @@ -198,18 +125,3 @@ class LinkPlugin { export const goToLinkPlugin = ViewPlugin.fromClass(LinkPlugin, { decorations: (v) => v.decorations }); - -/** - * Base theme for links. - */ -const baseTheme = EditorView.baseTheme({ - [`.${classes.widget}`]: { - cursor: 'pointer', - textDecoration: 'none', - opacity: '0.7', - transition: 'opacity 0.2s' - }, - [`.${classes.widget}:hover`]: { - opacity: '1' - } -}); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/list.ts b/frontend/src/views/editor/extensions/markdown/plugins/list.ts index a639937..3681977 100644 --- a/frontend/src/views/editor/extensions/markdown/plugins/list.ts +++ b/frontend/src/views/editor/extensions/markdown/plugins/list.ts @@ -270,7 +270,6 @@ const baseTheme = EditorView.baseTheme({ [`.${classes.taskCheckbox} input`]: { cursor: 'pointer', margin: '0', - marginRight: '0.35em', width: '1em', height: '1em', position: 'relative', diff --git a/frontend/src/views/editor/extensions/markdown/plugins/paste-rich-text.ts b/frontend/src/views/editor/extensions/markdown/plugins/paste-rich-text.ts deleted file mode 100644 index 8654cd9..0000000 --- a/frontend/src/views/editor/extensions/markdown/plugins/paste-rich-text.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { Extension } from '@codemirror/state'; -import { EditorView, keymap, type Command } from '@codemirror/view'; - -/** - * Paste rich text as markdown. - * - * This plugin: - * - Intercepts paste events containing HTML - * - Converts HTML to Markdown format - * - Supports common formatting: bold, italic, links, lists, etc. - * - Provides Ctrl/Cmd+Shift+V for plain text paste - */ -export const pasteRichText = (): Extension => [ - pasteRichTextHandler, - pastePlainTextKeymap -]; - -/** - * Convert HTML to Markdown. - * Simplified implementation for common HTML elements. - */ -function htmlToMarkdown(html: string): string { - // Create a temporary DOM element to parse HTML - const temp = document.createElement('div'); - temp.innerHTML = html; - - return convertNode(temp); -} - -/** - * Convert a DOM node to Markdown recursively. - */ -function convertNode(node: Node): string { - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent || ''; - } - - if (node.nodeType !== Node.ELEMENT_NODE) { - return ''; - } - - const element = node as HTMLElement; - const children = Array.from(element.childNodes) - .map(convertNode) - .join(''); - - switch (element.tagName.toLowerCase()) { - case 'strong': - case 'b': - return `**${children}**`; - - case 'em': - case 'i': - return `*${children}*`; - - case 'code': - return `\`${children}\``; - - case 'pre': - return `\n\`\`\`\n${children}\n\`\`\`\n`; - - case 'a': { - const href = element.getAttribute('href') || ''; - return `[${children}](${href})`; - } - - case 'img': { - const src = element.getAttribute('src') || ''; - const alt = element.getAttribute('alt') || ''; - return `![${alt}](${src})`; - } - - case 'h1': - return `\n# ${children}\n`; - case 'h2': - return `\n## ${children}\n`; - case 'h3': - return `\n### ${children}\n`; - case 'h4': - return `\n#### ${children}\n`; - case 'h5': - return `\n##### ${children}\n`; - case 'h6': - return `\n###### ${children}\n`; - - case 'p': - return `\n${children}\n`; - - case 'br': - return '\n'; - - case 'hr': - return '\n---\n'; - - case 'blockquote': - return children - .split('\n') - .map((line) => `> ${line}`) - .join('\n'); - - case 'ul': - case 'ol': { - const items = Array.from(element.children) - .filter((child) => child.tagName.toLowerCase() === 'li') - .map((li, index) => { - const content = convertNode(li).trim(); - const marker = - element.tagName.toLowerCase() === 'ul' - ? '-' - : `${index + 1}.`; - return `${marker} ${content}`; - }) - .join('\n'); - return `\n${items}\n`; - } - - case 'li': - return children; - - case 'table': - case 'thead': - case 'tbody': - case 'tr': - case 'th': - case 'td': - // Simple table handling - just extract text - return children; - - case 'div': - case 'span': - case 'article': - case 'section': - return children; - - default: - return children; - } -} - -/** - * Handler for paste events with HTML content. - */ -const pasteRichTextHandler = EditorView.domEventHandlers({ - paste(event, view) { - const html = event.clipboardData?.getData('text/html'); - if (!html) { - // No HTML content, let default paste handler work - return false; - } - - event.preventDefault(); - - // Convert HTML to Markdown - const markdown = htmlToMarkdown(html); - - // Insert the markdown at cursor position - const from = view.state.selection.main.from; - const to = view.state.selection.main.to; - const newPos = from + markdown.length; - - view.dispatch({ - changes: { from, to, insert: markdown }, - selection: { anchor: newPos }, - scrollIntoView: true - }); - - return true; - } -}); - -/** - * Plain text paste command (Ctrl/Cmd+Shift+V). - */ -const pastePlainTextCommand: Command = (view: EditorView) => { - navigator.clipboard - .readText() - .then((text) => { - const from = view.state.selection.main.from; - const to = view.state.selection.main.to; - const newPos = from + text.length; - - view.dispatch({ - changes: { from, to, insert: text }, - selection: { anchor: newPos }, - scrollIntoView: true - }); - }) - .catch((err: unknown) => { - console.error('Failed to paste plain text:', err); - }); - - return true; -}; - -/** - * Keymap for plain text paste. - */ -const pastePlainTextKeymap = keymap.of([ - { key: 'Mod-Shift-v', run: pastePlainTextCommand } -]); - diff --git a/frontend/src/views/editor/extensions/markdown/plugins/reveal-on-arrow.ts b/frontend/src/views/editor/extensions/markdown/plugins/reveal-on-arrow.ts deleted file mode 100644 index f81bfaf..0000000 --- a/frontend/src/views/editor/extensions/markdown/plugins/reveal-on-arrow.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { Extension, EditorSelection } from '@codemirror/state'; -import { EditorView, keymap } from '@codemirror/view'; - -/** - * Reveal block on arrow key navigation. - * - * This plugin: - * - Detects when arrow keys are pressed - * - Helps navigate through folded/hidden content - * - Provides better UX when navigating markdown documents - * - * Note: This is a simplified implementation that works with the - * standard CodeMirror navigation. For more advanced behavior, - * consider using the cursor position to detect nearby decorations. - */ -export const revealOnArrow = (): Extension => [revealOnArrowKeymap]; - -/** - * Check if we should adjust cursor position for better navigation. - * This is a basic implementation that lets CodeMirror handle most navigation. - */ -function maybeReveal( - view: EditorView, - direction: 'up' | 'down' -): boolean { - const { state } = view; - const cursorAt = state.selection.main.head; - const doc = state.doc; - - // Basic navigation enhancement - // Let CodeMirror handle the navigation naturally - // This hook is here for future enhancements if needed - - if (direction === 'down') { - // Moving down: check if we're at the end of a line - const line = doc.lineAt(cursorAt); - if (cursorAt === line.to && line.number < doc.lines) { - // Let CodeMirror handle moving to next line - return false; - } - } else { - // Moving up: check if we're at the start of a line - const line = doc.lineAt(cursorAt); - if (cursorAt === line.from && line.number > 1) { - // Let CodeMirror handle moving to previous line - return false; - } - } - - return false; -} - -/** - * Keymap for revealing blocks on arrow navigation. - */ -const revealOnArrowKeymap = keymap.of([ - { - key: 'ArrowUp', - run: (view) => maybeReveal(view, 'up') - }, - { - key: 'ArrowDown', - run: (view) => maybeReveal(view, 'down') - } -]); - diff --git a/frontend/src/views/editor/extensions/markdown/state/image.ts b/frontend/src/views/editor/extensions/markdown/state/image.ts index 2cfca03..7e43d2b 100644 --- a/frontend/src/views/editor/extensions/markdown/state/image.ts +++ b/frontend/src/views/editor/extensions/markdown/state/image.ts @@ -1,23 +1,21 @@ import { syntaxTree } from '@codemirror/language'; -import { - StateField, - EditorState, - StateEffect, - TransactionSpec -} from '@codemirror/state'; +import { Extension, Range } from '@codemirror/state'; import { DecorationSet, Decoration, WidgetType, - EditorView + EditorView, + ViewPlugin, + ViewUpdate } from '@codemirror/view'; +import { isCursorInRange } from '../util'; import { image as classes } from '../classes'; /** - * Representation of the data held by the image URL state field. + * Representation of image data extracted from the syntax tree. */ export interface ImageInfo { - /** The source of the image. */ + /** The source URL of the image. */ src: string; /** The starting position of the image element in the document. */ from: number; @@ -25,113 +23,74 @@ export interface ImageInfo { to: number; /** The alt text of the image. */ alt: string; - /** If image has already loaded. */ - loaded?: true; } -/** - * The current state of the image preview widget. - * Used to indicate to render a placeholder or the actual image. - */ -export enum WidgetState { - INITIAL, - LOADED -} - -/** - * The state effect to dispatch when a image loads, regardless of the result. - */ -export const imageLoadedEffect = StateEffect.define(); - -/** State field to store image preview decorations. */ -export const imagePreview = StateField.define({ - create(state) { - const images = extractImages(state); - const decorations = images.map((img) => - // NOTE: NOT using block: true to avoid affecting codeblock boundaries - Decoration.widget({ - widget: new ImagePreviewWidget(img, WidgetState.INITIAL), - info: img, - src: img.src, - side: 1 - }).range(img.to) - ); - return Decoration.set(decorations, true); - }, - - update(value, tx) { - const loadedImages = tx.effects.filter((effect) => - effect.is(imageLoadedEffect) - ) as StateEffect[]; - - if (tx.docChanged || loadedImages.length > 0) { - const images = extractImages(tx.state); - const previous = value.iter(); - const previousSpecs = new Array(); - while (previous.value !== null) { - previousSpecs.push(previous.value.spec.info); - previous.next(); - } - const decorations = images.map((img) => { - const hasImageLoaded = Boolean( - loadedImages.find( - (effect) => effect.value.src === img.src - ) || - previousSpecs.find((spec) => spec.src === img.src) - ?.loaded - ); - return Decoration.widget({ - widget: new ImagePreviewWidget( - img, - hasImageLoaded - ? WidgetState.LOADED - : WidgetState.INITIAL - ), - // NOTE: NOT using block: true to avoid affecting codeblock boundaries - // Always use inline widget - src: img.src, - side: 1, - // This is important to keep track of loaded images - info: { ...img, loaded: hasImageLoaded } - }).range(img.to); - }); - return Decoration.set(decorations, true); - } - return value.map(tx.changes); - }, - - provide(field) { - return EditorView.decorations.from(field); - } -}); - /** * Capture everything in square brackets of a markdown image, after * the exclamation mark. */ -const imageTextRE = /(?:!\[)(.*?)(?:\])/; +const IMAGE_TEXT_RE = /(?:!\[)(.*?)(?:\])/; -function extractImages(state: EditorState): ImageInfo[] { - const imageUrls: ImageInfo[] = []; - syntaxTree(state).iterate({ - enter: ({ name, node, from, to }) => { - if (name !== 'Image') return; - const altMatch = state.sliceDoc(from, to).match(imageTextRE); - const alt: string = altMatch?.pop() ?? ''; - const urlNode = node.getChild('URL'); - if (urlNode) { - const url: string = state.sliceDoc(urlNode.from, urlNode.to); - imageUrls.push({ src: url, from, to, alt }); +/** + * Extract images from the syntax tree. + */ +function extractImages(view: EditorView): ImageInfo[] { + const images: ImageInfo[] = []; + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ name, node, from: nodeFrom, to: nodeTo }) => { + if (name !== 'Image') return; + const altMatch = view.state.sliceDoc(nodeFrom, nodeTo).match(IMAGE_TEXT_RE); + const alt: string = altMatch?.pop() ?? ''; + const urlNode = node.getChild('URL'); + if (urlNode) { + const url: string = view.state.sliceDoc(urlNode.from, urlNode.to); + images.push({ src: url, from: nodeFrom, to: nodeTo, alt }); + } } - } - }); - return imageUrls; + }); + } + + return images; } +/** + * Build image preview decorations. + * Only shows preview when cursor is outside the image syntax. + */ +function buildImageDecorations(view: EditorView, loadedImages: Set): DecorationSet { + const decorations: Range[] = []; + const images = extractImages(view); + + for (const img of images) { + const cursorInImage = isCursorInRange(view.state, [img.from, img.to]); + + // Only show preview when cursor is outside + if (!cursorInImage) { + const isLoaded = loadedImages.has(img.src); + decorations.push( + Decoration.widget({ + widget: new ImagePreviewWidget(img, isLoaded, loadedImages), + side: 1 + }).range(img.to) + ); + } + } + + return Decoration.set(decorations, true); +} + +/** + * Image preview widget that displays the actual image. + */ class ImagePreviewWidget extends WidgetType { constructor( - public readonly info: ImageInfo, - public readonly state: WidgetState + private readonly info: ImageInfo, + private readonly isLoaded: boolean, + private readonly loadedImages: Set ) { super(); } @@ -145,32 +104,106 @@ class ImagePreviewWidget extends WidgetType { img.src = this.info.src; img.alt = this.info.alt; - img.addEventListener('load', () => { - const tx: TransactionSpec = {}; - if (this.state === WidgetState.INITIAL) { - tx.effects = [ - // Indicate image has loaded by setting the loaded value - imageLoadedEffect.of({ ...this.info, loaded: true }) - ]; - } - // After this is dispatched, this widget will be updated, - // and since the image is already loaded, this will not change - // its height dynamically, hence prevent all sorts of weird - // mess related to other parts of the editor. - view.dispatch(tx); - }); + if (!this.isLoaded) { + img.addEventListener('load', () => { + this.loadedImages.add(this.info.src); + view.dispatch({}); + }); + } - if (this.state === WidgetState.LOADED) { + if (this.isLoaded) { + wrapper.appendChild(img); + } else { + const placeholder = document.createElement('span'); + placeholder.className = 'cm-image-loading'; + placeholder.textContent = '🖼️'; + wrapper.appendChild(placeholder); + img.style.display = 'none'; wrapper.appendChild(img); } - // Return wrapper (empty for initial state, with img for loaded state) + return wrapper; } eq(widget: ImagePreviewWidget): boolean { return ( - JSON.stringify(widget.info) === JSON.stringify(this.info) && - widget.state === this.state + widget.info.src === this.info.src && + widget.info.from === this.info.from && + widget.info.to === this.info.to && + widget.isLoaded === this.isLoaded ); } + + ignoreEvent(): boolean { + return false; + } } + +/** + * Image preview plugin class. + */ +class ImagePreviewPlugin { + decorations: DecorationSet; + private loadedImages: Set = new Set(); + private lastSelectionRanges: string = ''; + + constructor(view: EditorView) { + this.decorations = buildImageDecorations(view, this.loadedImages); + this.lastSelectionRanges = this.serializeSelection(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildImageDecorations(update.view, this.loadedImages); + this.lastSelectionRanges = this.serializeSelection(update.view); + return; + } + + if (update.selectionSet) { + const newRanges = this.serializeSelection(update.view); + if (newRanges !== this.lastSelectionRanges) { + this.decorations = buildImageDecorations(update.view, this.loadedImages); + this.lastSelectionRanges = newRanges; + } + return; + } + + if (!update.docChanged && !update.selectionSet && !update.viewportChanged) { + this.decorations = buildImageDecorations(update.view, this.loadedImages); + } + } + + private serializeSelection(view: EditorView): string { + return view.state.selection.ranges + .map((r) => `${r.from}:${r.to}`) + .join(','); + } +} + +/** + * Image preview extension. + * Only handles displaying image preview widget. + */ +export const imagePreview = (): Extension => [ + ViewPlugin.fromClass(ImagePreviewPlugin, { + decorations: (v) => v.decorations + }), + baseTheme +]; + +const baseTheme = EditorView.baseTheme({ + '.cm-image-preview-wrapper': { + display: 'block', + margin: '0.5rem 0' + }, + [`.${classes.widget}`]: { + maxWidth: '100%', + height: 'auto', + borderRadius: '0.25rem' + }, + '.cm-image-loading': { + display: 'inline-block', + color: 'var(--cm-foreground)', + opacity: '0.6' + } +}); diff --git a/frontend/src/views/editor/extensions/markdown/util.ts b/frontend/src/views/editor/extensions/markdown/util.ts index 83c0fee..c46ac7f 100644 --- a/frontend/src/views/editor/extensions/markdown/util.ts +++ b/frontend/src/views/editor/extensions/markdown/util.ts @@ -247,19 +247,7 @@ export function stateWORDAt( */ export const invisibleDecoration = Decoration.replace({}); -/** - * Decoration to hide inline content (font-size: 0). - */ -export const hideInlineDecoration = Decoration.mark({ - class: 'cm-hidden-token' -}); -/** - * Decoration to make content transparent but preserve space. - */ -export const hideInlineKeepSpaceDecoration = Decoration.mark({ - class: 'cm-transparent-token' -}); // ============================================================================ // Slug Generation @@ -301,52 +289,4 @@ export class Slugger { } } -// ============================================================================ -// Performance Utilities -// ============================================================================ -/** - * Create a debounced version of a function. - * - * @param fn - Function to debounce - * @param delay - Delay in milliseconds - * @returns Debounced function - */ -export function debounce void>( - fn: T, - delay: number -): T { - let timeoutId: ReturnType | null = null; - - return ((...args: unknown[]) => { - if (timeoutId) { - clearTimeout(timeoutId); - } - timeoutId = setTimeout(() => { - fn(...args); - timeoutId = null; - }, delay); - }) as T; -} - -/** - * Create a throttled version of a function. - * - * @param fn - Function to throttle - * @param limit - Minimum time between calls in milliseconds - * @returns Throttled function - */ -export function throttle void>( - fn: T, - limit: number -): T { - let lastCall = 0; - - return ((...args: unknown[]) => { - const now = Date.now(); - if (now - lastCall >= limit) { - lastCall = now; - fn(...args); - } - }) as T; -}