diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cfc8b9a..731af51 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -46,17 +46,14 @@ "@replit/codemirror-lang-svelte": "^6.0.0", "@toml-tools/lexer": "^1.0.0", "@toml-tools/parser": "^1.0.0", - "@types/markdown-it": "^14.1.2", "codemirror": "^6.0.2", "codemirror-lang-elixir": "^4.0.0", "colors-named": "^1.0.2", "colors-named-hex": "^1.0.2", "groovy-beautify": "^0.0.17", - "highlight.js": "^11.11.1", "hsl-matcher": "^1.2.4", "java-parser": "^3.0.1", "linguist-languages": "^9.1.0", - "markdown-it": "^14.1.0", "mermaid": "^11.12.1", "npm": "^11.6.3", "php-parser": "^3.2.5", @@ -2920,6 +2917,18 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2989,6 +2998,15 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@types/node": { "version": "24.10.1", "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.10.1.tgz", @@ -3828,6 +3846,7 @@ "version": "2.0.1", "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "devOptional": true, "license": "Python-2.0" }, "node_modules/asn1.js": { @@ -6198,15 +6217,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmmirror.com/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/hmac-drbg": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz", @@ -6715,6 +6725,8 @@ "resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "uc.micro": "^2.0.0" } @@ -6792,6 +6804,8 @@ "resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -6864,7 +6878,9 @@ "version": "2.0.0", "resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz", "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/merge2": { "version": "1.4.1", @@ -10065,6 +10081,8 @@ "resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=6" } @@ -11050,7 +11068,9 @@ "version": "2.1.0", "resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/ufo": { "version": "1.6.1", diff --git a/frontend/package.json b/frontend/package.json index a2e8ea0..61ad5eb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -60,17 +60,14 @@ "@replit/codemirror-lang-svelte": "^6.0.0", "@toml-tools/lexer": "^1.0.0", "@toml-tools/parser": "^1.0.0", - "@types/markdown-it": "^14.1.2", "codemirror": "^6.0.2", "codemirror-lang-elixir": "^4.0.0", "colors-named": "^1.0.2", "colors-named-hex": "^1.0.2", "groovy-beautify": "^0.0.17", - "highlight.js": "^11.11.1", "hsl-matcher": "^1.2.4", "java-parser": "^3.0.1", "linguist-languages": "^9.1.0", - "markdown-it": "^14.1.0", "mermaid": "^11.12.1", "npm": "^11.6.3", "php-parser": "^3.2.5", diff --git a/frontend/src/components/toolbar/Toolbar.vue b/frontend/src/components/toolbar/Toolbar.vue index bc41b08..4821b5d 100644 --- a/frontend/src/components/toolbar/Toolbar.vue +++ b/frontend/src/components/toolbar/Toolbar.vue @@ -13,8 +13,6 @@ import {getActiveNoteBlock} from '@/views/editor/extensions/codeblock/state'; import {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages'; import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode'; import {createDebounce} from '@/common/utils/debounce'; -import {toggleMarkdownPreview} from '@/views/editor/extensions/markdownPreview'; -import {markdownPreviewManager} from "@/views/editor/extensions/markdownPreview/manager"; const editorStore = useEditorStore(); const configStore = useConfigStore(); @@ -25,7 +23,6 @@ const {t} = useI18n(); const router = useRouter(); const canFormatCurrentBlock = ref(false); -const canPreviewMarkdown = ref(false); const isLoaded = shallowRef(false); const { documentStats } = toRefs(editorStore); @@ -36,10 +33,6 @@ const isCurrentWindowOnTop = computed(() => { return config.value.general.alwaysOnTop || systemStore.isWindowOnTop; }); -// 当前文档的预览是否打开 -const isCurrentBlockPreviewing = computed(() => { - return markdownPreviewManager.isVisible(); -}); // 切换窗口置顶状态 const toggleAlwaysOnTop = async () => { @@ -68,22 +61,12 @@ const formatCurrentBlock = () => { formatBlockContent(editorStore.editorView); }; -// 切换 Markdown 预览 -const { debouncedFn: debouncedTogglePreview } = createDebounce(() => { - if (!canPreviewMarkdown.value || !editorStore.editorView) return; - toggleMarkdownPreview(editorStore.editorView as any); -}, { delay: 200 }); - -const togglePreview = () => { - debouncedTogglePreview(); -}; // 统一更新按钮状态 const updateButtonStates = () => { const view: any = editorStore.editorView; if (!view) { canFormatCurrentBlock.value = false; - canPreviewMarkdown.value = false; return; } @@ -94,7 +77,6 @@ const updateButtonStates = () => { // 提前返回,减少不必要的计算 if (!activeBlock) { canFormatCurrentBlock.value = false; - canPreviewMarkdown.value = false; return; } @@ -102,11 +84,9 @@ const updateButtonStates = () => { const language = getLanguage(languageName as any); canFormatCurrentBlock.value = Boolean(language?.prettier); - canPreviewMarkdown.value = languageName.toLowerCase() === 'md'; } catch (error) { console.warn('Error checking block capabilities:', error); canFormatCurrentBlock.value = false; - canPreviewMarkdown.value = false; } }; @@ -160,7 +140,6 @@ watch( cleanupListeners = setupEditorListeners(newView); } else { canFormatCurrentBlock.value = false; - canPreviewMarkdown.value = false; } }); }, @@ -254,21 +233,6 @@ const statsData = computed(() => ({ - -
- - - - -
-
{ const httpExtension = createHttpClientExtension(); - // Markdown预览扩展 - const previewExtension = markdownPreviewExtension(); // 再次检查操作有效性 if (!operationManager.isOperationValid(operationId, documentId)) { @@ -298,7 +296,7 @@ export const useEditorStore = defineStore('editor', () => { codeBlockExtension, ...dynamicExtensions, ...httpExtension, - previewExtension + markdownExtensions ]; // 创建编辑器状态 diff --git a/frontend/src/views/editor/Editor.vue b/frontend/src/views/editor/Editor.vue index adcdeaf..028f614 100644 --- a/frontend/src/views/editor/Editor.vue +++ b/frontend/src/views/editor/Editor.vue @@ -1,18 +1,17 @@ @@ -47,21 +45,17 @@ onBeforeUnmount(() => {
- + - -
- -
- - -
+ +
+ - + - + - +
@@ -74,15 +68,6 @@ onBeforeUnmount(() => { flex-direction: column; position: relative; - .editor-wrapper { - width: 100%; - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - position: relative; - } - .editor { width: 100%; height: 100%; diff --git a/frontend/src/views/editor/extensions/markdown/classes.ts b/frontend/src/views/editor/extensions/markdown/classes.ts new file mode 100644 index 0000000..4604cfa --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/classes.ts @@ -0,0 +1,74 @@ +/** + * A single source of truth for all the classes used for decorations in Ixora. + * These are kept together here to simplify changing/adding classes later + * and serve as a reference. + * + * Exports under this file don't need to follow any particular naming schema, + * naming which can give an intuition on what the class is for is preferred. + */ + +/** Classes for blockquote decorations. */ +export const blockquote = { + /** Blockquote widget */ + widget: 'cm-blockquote', + /** Replace decoration for the quote mark */ + mark: 'cm-blockquote-border' + }, + /** Classes for codeblock decorations. */ + codeblock = { + /** Codeblock widget */ + widget: 'cm-codeblock', + /** First line of the codeblock widget */ + widgetBegin: 'cm-codeblock-begin', + /** Last line of the codeblock widget */ + widgetEnd: 'cm-codeblock-end' + }, + /** Classes for heading decorations. */ + heading = { + /** Heading decoration class */ + heading: 'cm-heading', + /** Heading levels (h1, h2, etc) */ + level: (level: number) => `cm-heading-${level}`, + /** Heading slug */ + slug: (slug: string) => `cm-heading-slug-${slug}` + }, + /** Classes for link (URL) widgets. */ + link = { + /** URL widget */ + widget: 'cm-link' + }, + /** Classes for list widgets. */ + list = { + /** List bullet */ + bullet: 'cm-list-bullet', + /** List task checkbox */ + taskCheckbox: 'cm-task-marker-checkbox', + /** Task list item with checkbox checked */ + taskChecked: 'cm-task-checked' + }, + /** Classes for image widgets. */ + image = { + /** Image preview */ + widget: 'cm-image' + }, + /** Classes for enhanced code block decorations. */ + codeblockEnhanced = { + /** Code block info container */ + info: 'cm-code-block-info', + /** Language label */ + lang: 'cm-code-block-lang', + /** Copy button */ + copyBtn: 'cm-code-block-copy-btn' + }, + /** Classes for emoji decorations. */ + 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/markdownPreview/github-markdown.css b/frontend/src/views/editor/extensions/markdown/github-markdown.css similarity index 100% rename from frontend/src/views/editor/extensions/markdownPreview/github-markdown.css rename to frontend/src/views/editor/extensions/markdown/github-markdown.css diff --git a/frontend/src/views/editor/extensions/markdown/index.ts b/frontend/src/views/editor/extensions/markdown/index.ts new file mode 100644 index 0000000..e0f9e2f --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/index.ts @@ -0,0 +1,71 @@ +import { Extension } from '@codemirror/state'; +import { blockquote } from './plugins/blockquote'; +import { codeblock } from './plugins/code-block'; +import { headings } from './plugins/heading'; +import { hideMarks } from './plugins/hide-mark'; +import { htmlBlock } from './plugins/html'; +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 { softIndent } from './plugins/soft-indent'; +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 } 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 { softIndent } from './plugins/soft-indent'; +export { revealOnArrow } from './plugins/reveal-on-arrow'; +export { pasteRichText } from './plugins/paste-rich-text'; + +// Classes +export * as classes from './classes'; + + +/** + * markdown extensions (includes all ProseMark-inspired features). + */ +export const markdownExtensions: Extension = [ + headingSlugField, + imagePreview, + blockquote(), + codeblock(), + headings(), + hideMarks(), + lists(), + links(), + image(), + htmlBlock, + // Enhanced features + codeblockEnhanced(), + emoji(), + horizontalRule(), + softIndent(), + revealOnArrow(), + pasteRichText() +]; + +export default markdownExtensions; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts b/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts new file mode 100644 index 0000000..063597f --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/blockquote.ts @@ -0,0 +1,128 @@ +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate, + WidgetType +} from '@codemirror/view'; +import { Range } from '@codemirror/state'; +import { + iterateTreeInVisibleRanges, + editorLines, + isCursorInRange, + checkRangeSubset +} from '../util'; +import { blockquote as classes } from '../classes'; + +const quoteMarkRE = /^(\s*>+)/gm; + +class BlockQuoteBorderWidget extends WidgetType { + toDOM(): HTMLElement { + const dom = document.createElement('span'); + dom.classList.add(classes.mark); + return dom; + } +} + +/** + * Plugin to add style blockquotes. + */ +class BlockQuotePlugin { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = this.styleBlockquote(view); + } + update(update: ViewUpdate) { + if ( + update.docChanged || + update.viewportChanged || + update.selectionSet + ) { + this.decorations = this.styleBlockquote(update.view); + } + } + /** + * + * @param view - The editor view + * @returns The blockquote decorations to add to the editor + */ + private styleBlockquote(view: EditorView): DecorationSet { + const widgets: Range[] = []; + iterateTreeInVisibleRanges(view, { + enter: ({ name, from, to }) => { + if (name !== 'Blockquote') return; + const lines = editorLines(view, from, to); + + lines.forEach((line) => { + const lineDec = Decoration.line({ + class: classes.widget + }); + widgets.push(lineDec.range(line.from)); + }); + + if ( + lines.every( + (line) => + !isCursorInRange(view.state, [line.from, line.to]) + ) + ) { + const marks = Array.from( + view.state.sliceDoc(from, to).matchAll(quoteMarkRE) + ) + .map((x) => from + x.index) + .map((i) => + Decoration.replace({ + widget: new BlockQuoteBorderWidget() + }).range(i, i + 1) + ); + lines.forEach((line) => { + if ( + !marks.some((mark) => + checkRangeSubset( + [line.from, line.to], + [mark.from, mark.to] + ) + ) + ) + marks.push( + Decoration.widget({ + widget: new BlockQuoteBorderWidget() + }).range(line.from) + ); + }); + + widgets.push(...marks); + } + } + }); + return Decoration.set(widgets, true); + } +} + +const blockQuotePlugin = ViewPlugin.fromClass(BlockQuotePlugin, { + decorations: (v) => v.decorations +}); + +/** + * Default styles for blockquotes. + */ +const baseTheme = EditorView.baseTheme({ + ['.' + classes.mark]: { + 'border-left': '4px solid #ccc' + }, + ['.' + classes.widget]: { + color: '#555' + } +}); + +/** + * Ixora blockquote plugin. + * + * This plugin allows to: + * - Decorate blockquote marks in the editor + * - Add default styling to blockquote marks + */ +export function blockquote() { + return [blockQuotePlugin, baseTheme]; +} 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 new file mode 100644 index 0000000..c6e9f46 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/code-block-enhanced.ts @@ -0,0 +1,207 @@ +import { Extension } from '@codemirror/state'; +import { + ViewPlugin, + DecorationSet, + Decoration, + EditorView, + ViewUpdate, + WidgetType +} from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; +import { isCursorInRange } from '../util'; + +/** + * Enhanced code block plugin with copy button and language label. + * + * This plugin adds: + * - Language label display in the top-right corner + * - Copy to clipboard button + * - Enhanced visual styling for code blocks + */ +export const codeblockEnhanced = (): Extension => [ + codeBlockEnhancedPlugin, + enhancedTheme +]; + +/** + * Widget for code block info bar (language + copy button). + */ +class CodeBlockInfoWidget extends WidgetType { + constructor( + readonly language: string, + readonly code: string + ) { + super(); + } + + eq(other: CodeBlockInfoWidget) { + return other.language === this.language && other.code === this.code; + } + + toDOM(): HTMLElement { + const container = document.createElement('div'); + container.className = 'cm-code-block-info'; + + // Language label + if (this.language) { + const langLabel = document.createElement('span'); + langLabel.className = 'cm-code-block-lang'; + langLabel.textContent = this.language.toUpperCase(); + container.appendChild(langLabel); + } + + // Copy button + const copyButton = document.createElement('button'); + copyButton.className = 'cm-code-block-copy-btn'; + copyButton.title = '复制代码'; + copyButton.innerHTML = ` + + + + + `; + + copyButton.onclick = async (e) => { + e.preventDefault(); + e.stopPropagation(); + try { + await navigator.clipboard.writeText(this.code); + // Visual feedback + copyButton.innerHTML = ` + + + + `; + setTimeout(() => { + copyButton.innerHTML = ` + + + + + `; + }, 2000); + } catch (err) { + console.error('Failed to copy code:', err); + } + }; + + container.appendChild(copyButton); + return container; + } + + ignoreEvent(): boolean { + return true; + } +} + +/** + * Plugin to add enhanced code block features. + */ +class CodeBlockEnhancedPlugin { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = this.buildDecorations(update.view); + } + } + + private buildDecorations(view: EditorView): DecorationSet { + const widgets: Array> = []; + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: (node) => { + if (node.name !== 'FencedCode') return; + + // Skip if cursor is in this code block + if (isCursorInRange(view.state, [node.from, node.to])) return; + + // Extract language + let language = ''; + const codeInfoNode = node.node.getChild('CodeInfo'); + if (codeInfoNode) { + language = view.state.doc + .sliceString(codeInfoNode.from, codeInfoNode.to) + .trim(); + } + + // Extract code content (excluding fence markers) + const firstLine = view.state.doc.lineAt(node.from); + const lastLine = view.state.doc.lineAt(node.to); + const codeStart = firstLine.to + 1; + const codeEnd = lastLine.from - 1; + const code = view.state.doc.sliceString(codeStart, codeEnd); + + // Add info widget at the first line + const infoWidget = Decoration.widget({ + widget: new CodeBlockInfoWidget(language, code), + side: 1 + }); + widgets.push(infoWidget.range(firstLine.to)); + } + }); + } + + return Decoration.set(widgets, true); + } +} + +const codeBlockEnhancedPlugin = ViewPlugin.fromClass(CodeBlockEnhancedPlugin, { + decorations: (v) => v.decorations +}); + +/** + * Enhanced theme for code blocks. + */ +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' + }, + '.cm-code-block-info:hover': { + opacity: '1' + }, + '.cm-code-block-lang': { + fontFamily: 'monospace', + fontSize: '0.7rem', + fontWeight: '600', + color: 'var(--cm-fg-muted, #888)' + }, + '.cm-code-block-copy-btn': { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + border: 'none', + background: 'transparent', + padding: '0.2rem', + borderRadius: '0.25rem', + cursor: 'pointer', + color: 'var(--cm-fg-muted, #888)', + transition: 'background-color 0.2s, color 0.2s' + }, + '.cm-code-block-copy-btn:hover': { + backgroundColor: 'var(--cm-bg-hover, rgba(0, 0, 0, 0.1))', + color: 'var(--cm-fg, inherit)' + }, + '.cm-code-block-copy-btn svg': { + width: '16px', + height: '16px' + } +}); + diff --git a/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts b/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts new file mode 100644 index 0000000..b8d77fd --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/code-block.ts @@ -0,0 +1,97 @@ +import { Extension } from '@codemirror/state'; +import { + ViewPlugin, + DecorationSet, + Decoration, + EditorView, + ViewUpdate +} from '@codemirror/view'; +import { + isCursorInRange, + invisibleDecoration, + iterateTreeInVisibleRanges, + editorLines +} from '../util'; +import { codeblock as classes } from '../classes'; + +/** + * Ixora code block plugin. + * + * This plugin allows to: + * - Add default styling to code blocks + * - Customize visibility of code block markers and language + */ +export const codeblock = (): Extension => [codeBlockPlugin, baseTheme]; + +const codeBlockPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = decorateCodeBlocks(view); + } + update(update: ViewUpdate) { + if ( + update.docChanged || + update.viewportChanged || + update.selectionSet + ) + this.decorations = decorateCodeBlocks(update.view); + } + }, + { decorations: (v) => v.decorations } +); + +function decorateCodeBlocks(view: EditorView) { + const widgets: Array> = []; + iterateTreeInVisibleRanges(view, { + enter: ({ type, from, to, node }) => { + if (!['FencedCode', 'CodeBlock'].includes(type.name)) return; + editorLines(view, from, to).forEach((block, i) => { + const lineDec = Decoration.line({ + class: [ + classes.widget, + i === 0 + ? classes.widgetBegin + : block.to === to + ? classes.widgetEnd + : '' + ].join(' ') + }); + widgets.push(lineDec.range(block.from)); + }); + if (isCursorInRange(view.state, [from, to])) return; + const codeBlock = node.toTree(); + codeBlock.iterate({ + enter: ({ type, from: nodeFrom, to: nodeTo }) => { + switch (type.name) { + case 'CodeInfo': + case 'CodeMark': + // eslint-disable-next-line no-case-declarations + const decRange = invisibleDecoration.range( + from + nodeFrom, + from + nodeTo + ); + widgets.push(decRange); + break; + } + } + }); + } + }); + return Decoration.set(widgets, true); +} + +/** + * Base theme for code block plugin. + */ +const baseTheme = EditorView.baseTheme({ + ['.' + classes.widget]: { + backgroundColor: '#CCC7' + }, + ['.' + classes.widgetBegin]: { + borderRadius: '5px 5px 0 0' + }, + ['.' + classes.widgetEnd]: { + borderRadius: '0 0 5px 5px' + } +}); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts b/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts new file mode 100644 index 0000000..ee4e4b5 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/emoji.ts @@ -0,0 +1,239 @@ +import { Extension } from '@codemirror/state'; +import { + ViewPlugin, + DecorationSet, + Decoration, + EditorView, + ViewUpdate, + WidgetType +} from '@codemirror/view'; +import { isCursorInRange, iterateTreeInVisibleRanges } from '../util'; + +/** + * Emoji plugin that converts :emoji_name: to actual emoji characters. + * + * This plugin: + * - Detects emoji patterns like :smile:, :heart:, etc. + * - Replaces them with actual emoji characters + * - Shows the original text when cursor is nearby + */ +export const emoji = (): Extension => [emojiPlugin, baseTheme]; + +/** + * Common emoji mappings. + * Extended from common emoji shortcodes. + */ +const EMOJI_MAP: { [key: string]: string } = { + // Smileys & Emotion + smile: '😄', + smiley: '😃', + grin: '😁', + laughing: '😆', + satisfied: '😆', + sweat_smile: '😅', + rofl: '🤣', + joy: '😂', + slightly_smiling_face: '🙂', + upside_down_face: '🙃', + wink: '😉', + blush: '😊', + innocent: '😇', + smiling_face_with_three_hearts: '🥰', + heart_eyes: '😍', + star_struck: '🤩', + kissing_heart: '😘', + kissing: '😗', + relaxed: '☺️', + kissing_closed_eyes: '😚', + kissing_smiling_eyes: '😙', + smiling_face_with_tear: '🥲', + yum: '😋', + stuck_out_tongue: '😛', + stuck_out_tongue_winking_eye: '😜', + zany_face: '🤪', + stuck_out_tongue_closed_eyes: '😝', + money_mouth_face: '🤑', + hugs: '🤗', + hand_over_mouth: '🤭', + shushing_face: '🤫', + thinking: '🤔', + zipper_mouth_face: '🤐', + raised_eyebrow: '🤨', + neutral_face: '😐', + expressionless: '😑', + no_mouth: '😶', + smirk: '😏', + unamused: '😒', + roll_eyes: '🙄', + grimacing: '😬', + lying_face: '🤥', + relieved: '😌', + pensive: '😔', + sleepy: '😪', + drooling_face: '🤤', + sleeping: '😴', + + // Hearts + heart: '❤️', + orange_heart: '🧡', + yellow_heart: '💛', + green_heart: '💚', + blue_heart: '💙', + purple_heart: '💜', + brown_heart: '🤎', + black_heart: '🖤', + white_heart: '🤍', + + // Gestures + '+1': '👍', + thumbsup: '👍', + '-1': '👎', + thumbsdown: '👎', + fist: '✊', + facepunch: '👊', + punch: '👊', + wave: '👋', + clap: '👏', + raised_hands: '🙌', + pray: '🙏', + handshake: '🤝', + + // Nature + sun: '☀️', + moon: '🌙', + star: '⭐', + fire: '🔥', + zap: '⚡', + sparkles: '✨', + tada: '🎉', + rocket: '🚀', + trophy: '🏆', + + // Symbols + check: '✔️', + x: '❌', + warning: '⚠️', + bulb: '💡', + question: '❓', + exclamation: '❗', + heavy_check_mark: '✔️', + + // Common + eyes: '👀', + eye: '👁️', + brain: '🧠', + muscle: '💪', + ok_hand: '👌', + point_right: '👉', + point_left: '👈', + point_up: '☝️', + point_down: '👇', +}; + +/** + * Widget to display emoji character. + */ +class EmojiWidget extends WidgetType { + constructor(readonly emoji: string) { + super(); + } + + eq(other: EmojiWidget) { + return other.emoji === this.emoji; + } + + toDOM(): HTMLElement { + const span = document.createElement('span'); + span.className = 'cm-emoji'; + span.textContent = this.emoji; + span.title = ':' + Object.keys(EMOJI_MAP).find( + key => EMOJI_MAP[key] === this.emoji + ) + ':'; + return span; + } +} + +/** + * Plugin to render emoji. + */ +class EmojiPlugin { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.decorations = this.buildDecorations(update.view); + } + } + + private buildDecorations(view: EditorView): DecorationSet { + const widgets: Array> = []; + const doc = view.state.doc; + + for (const { from, to } of view.visibleRanges) { + // Use regex to find :emoji: patterns + const text = doc.sliceString(from, to); + const emojiRegex = /:([a-z0-9_+\-]+):/g; + let match; + + while ((match = emojiRegex.exec(text)) !== null) { + const matchStart = from + match.index; + const matchEnd = matchStart + match[0].length; + + // Skip if cursor is in this range + if (isCursorInRange(view.state, [matchStart, matchEnd])) { + continue; + } + + const emojiName = match[1]; + const emojiChar = EMOJI_MAP[emojiName]; + + if (emojiChar) { + // Replace the :emoji: with the actual emoji + const widget = Decoration.replace({ + widget: new EmojiWidget(emojiChar) + }); + widgets.push(widget.range(matchStart, matchEnd)); + } + } + } + + return Decoration.set(widgets, true); + } +} + +const emojiPlugin = ViewPlugin.fromClass(EmojiPlugin, { + decorations: (v) => v.decorations +}); + +/** + * Base theme for emoji. + */ +const baseTheme = EditorView.baseTheme({ + '.cm-emoji': { + fontSize: '1.2em', + lineHeight: '1', + verticalAlign: 'middle', + cursor: 'default' + } +}); + +/** + * Add custom emoji to the map. + * @param name - Emoji name (without colons) + * @param emoji - Emoji character + */ +export function addEmoji(name: string, emoji: string): void { + EMOJI_MAP[name] = emoji; +} + +/** + * Get all available emoji names. + */ +export function getEmojiNames(): string[] { + return Object.keys(EMOJI_MAP); +} + diff --git a/frontend/src/views/editor/extensions/markdown/plugins/frontmatter.ts b/frontend/src/views/editor/extensions/markdown/plugins/frontmatter.ts new file mode 100644 index 0000000..f8699d1 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/frontmatter.ts @@ -0,0 +1,66 @@ +import { parseMixed } from '@lezer/common'; +import { yaml } from '@codemirror/legacy-modes/mode/yaml'; +import { Element, MarkdownExtension } from '@lezer/markdown'; +import { foldInside, foldNodeProp, StreamLanguage } from '@codemirror/language'; +import { styleTags, tags } from '@lezer/highlight'; + +// A frontmatter fence usually consists of a seperator used three times. +// These can be: ---, +++, ===, etc. +// FIXME: make this configurable +const frontMatterFence = /^---\s*$/m; + +/** + * Ixora frontmatter plugin for Markdown. + */ +export const frontmatter: MarkdownExtension = { + defineNodes: [{ name: 'Frontmatter', block: true }, 'FrontmatterMark'], + props: [ + styleTags({ + Frontmatter: [tags.documentMeta, tags.monospace], + FrontmatterMark: tags.processingInstruction + }), + foldNodeProp.add({ + Frontmatter: foldInside, + // Marks don't need to be folded + FrontmatterMark: () => null + }) + ], + wrap: parseMixed((node) => { + const { parser } = StreamLanguage.define(yaml); + if (node.type.name === 'Frontmatter') { + return { + parser, + overlay: [{ from: node.from + 4, to: node.to - 4 }] + }; + } else { + return null; + } + }), + parseBlock: [ + { + name: 'Fronmatter', + before: 'HorizontalRule', + parse: (cx, line) => { + let end: number = 0; + const children = new Array(); + if (cx.lineStart === 0 && frontMatterFence.test(line.text)) { + // 4 is the length of the frontmatter fence (---\n). + children.push(cx.elt('FrontmatterMark', 0, 4)); + while (cx.nextLine()) { + if (frontMatterFence.test(line.text)) { + end = cx.lineStart + 4; + break; + } + } + if (end > 0) { + children.push(cx.elt('FrontmatterMark', end - 4, end)); + cx.addElement(cx.elt('Frontmatter', 0, end, children)); + } + return true; + } else { + return false; + } + } + } + ] +}; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/heading.ts b/frontend/src/views/editor/extensions/markdown/plugins/heading.ts new file mode 100644 index 0000000..437d4b8 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/heading.ts @@ -0,0 +1,134 @@ +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate +} from '@codemirror/view'; +import { checkRangeOverlap, iterateTreeInVisibleRanges } from '../util'; +import { headingSlugField } from '../state/heading-slug'; +import { heading as classes } from '../classes'; + +/** + * Ixora headings plugin. + * + * This plugin allows to: + * - Size headings according to their heading level + * - Add default styling to headings + */ +export const headings = () => [ + headingDecorationsPlugin, + hideHeaderMarkPlugin, + baseTheme +]; + +class HideHeaderMarkPlugin { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = this.hideHeaderMark(view); + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) + this.decorations = this.hideHeaderMark(update.view); + } + /** + * Function to decide if to insert a decoration to hide the header mark + * @param view - Editor view + * @returns The `Decoration`s that hide the header marks + */ + private hideHeaderMark(view: EditorView) { + const widgets: Array> = []; + const ranges = view.state.selection.ranges; + iterateTreeInVisibleRanges(view, { + enter: ({ type, from, to }) => { + // Get the active line + const line = view.lineBlockAt(from); + // If any cursor overlaps with the heading line, skip + const cursorOverlaps = ranges.some(({ from, to }) => + checkRangeOverlap([from, to], [line.from, line.to]) + ); + if (cursorOverlaps) return; + if ( + type.name === 'HeaderMark' && + // Setext heading's horizontal lines are not hidden. + /[#]/.test(view.state.sliceDoc(from, to)) + ) { + const dec = Decoration.replace({}); + widgets.push(dec.range(from, to + 1)); + } + } + }); + return Decoration.set(widgets, true); + } +} + +/** + * Plugin to hide the header mark. + * + * The header mark will not be hidden when: + * - The cursor is on the active line + * - The mark is on a line which is in the current selection + */ +const hideHeaderMarkPlugin = ViewPlugin.fromClass(HideHeaderMarkPlugin, { + decorations: (v) => v.decorations +}); + +class HeadingDecorationsPlugin { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = this.decorateHeadings(view); + } + update(update: ViewUpdate) { + if ( + update.docChanged || + update.viewportChanged || + update.selectionSet + ) { + this.decorations = this.decorateHeadings(update.view); + } + } + private decorateHeadings(view: EditorView) { + const widgets: Array> = []; + iterateTreeInVisibleRanges(view, { + enter: ({ name, from }) => { + // To capture ATXHeading and SetextHeading + if (!name.includes('Heading')) return; + const slug = view.state + .field(headingSlugField) + .find((s) => s.pos === from)?.slug; + const match = /[1-6]$/.exec(name); + if (!match) return; + const level = parseInt(match[0]); + const dec = Decoration.line({ + class: [ + classes.heading, + classes.level(level), + slug ? classes.slug(slug) : '' + ].join(' ') + }); + widgets.push(dec.range(view.state.doc.lineAt(from).from)); + } + }); + return Decoration.set(widgets, true); + } +} + +const headingDecorationsPlugin = ViewPlugin.fromClass( + HeadingDecorationsPlugin, + { decorations: (v) => v.decorations } +); + +/** + * Base theme for headings. + */ +const baseTheme = EditorView.baseTheme({ + '.cm-heading': { + fontWeight: 'bold' + }, + ['.' + classes.level(1)]: { fontSize: '2.2rem' }, + ['.' + classes.level(2)]: { fontSize: '1.8rem' }, + ['.' + classes.level(3)]: { fontSize: '1.4rem' }, + ['.' + classes.level(4)]: { fontSize: '1.2rem' }, + ['.' + classes.level(5)]: { fontSize: '1rem' }, + ['.' + classes.level(6)]: { fontSize: '0.8rem' } +}); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts b/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts new file mode 100644 index 0000000..49596f8 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/hide-mark.ts @@ -0,0 +1,88 @@ +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate +} from '@codemirror/view'; +import { + checkRangeOverlap, + invisibleDecoration, + isCursorInRange, + iterateTreeInVisibleRanges +} from '../util'; + +/** + * These types contain markers as child elements that can be hidden. + */ +export const typesWithMarks = [ + 'Emphasis', + 'StrongEmphasis', + 'InlineCode', + 'Strikethrough' +]; +/** + * The elements which are used as marks. + */ +export const markTypes = ['EmphasisMark', 'CodeMark', 'StrikethroughMark']; + +/** + * Plugin to hide marks when the they are not in the editor selection. + */ +class HideMarkPlugin { + decorations: DecorationSet; + constructor(view: EditorView) { + this.decorations = this.compute(view); + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) + this.decorations = this.compute(update.view); + } + compute(view: EditorView): DecorationSet { + const widgets: Array> = []; + let parentRange: [number, number]; + iterateTreeInVisibleRanges(view, { + enter: ({ type, from, to, node }) => { + if (typesWithMarks.includes(type.name)) { + // There can be a possibility that the current node is a + // child eg. a bold node in a emphasis node, so check + // for that or else save the node range + if ( + parentRange && + checkRangeOverlap([from, to], parentRange) + ) + return; + else parentRange = [from, to]; + if (isCursorInRange(view.state, [from, to])) return; + const innerTree = node.toTree(); + innerTree.iterate({ + enter({ type, from: markFrom, to: markTo }) { + // Check for mark types and push the replace + // decoration + if (!markTypes.includes(type.name)) return; + widgets.push( + invisibleDecoration.range( + from + markFrom, + from + markTo + ) + ); + } + }); + } + } + }); + return Decoration.set(widgets, true); + } +} + +/** + * Ixora hide marks plugin. + * + * This plugin allows to: + * - Hide marks when they are not in the editor selection. + */ +export const hideMarks = () => [ + ViewPlugin.fromClass(HideMarkPlugin, { + decorations: (v) => v.decorations + }) +]; diff --git a/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts b/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts new file mode 100644 index 0000000..e8f30da --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/horizontal-rule.ts @@ -0,0 +1,107 @@ +import { Extension, StateField, EditorState } from '@codemirror/state'; +import { + DecorationSet, + Decoration, + EditorView, + WidgetType +} from '@codemirror/view'; +import { isCursorInRange } from '../util'; +import { syntaxTree } from '@codemirror/language'; + +/** + * Horizontal rule plugin that renders beautiful horizontal lines. + * + * This plugin: + * - Replaces markdown horizontal rules (---, ***, ___) with styled
elements + * - Shows the original text when cursor is on the line + */ +export const horizontalRule = (): Extension => [ + horizontalRuleField, + baseTheme +]; + +/** + * Widget to display a horizontal rule. + */ +class HorizontalRuleWidget extends WidgetType { + toDOM(): HTMLElement { + const container = document.createElement('div'); + container.className = 'cm-horizontal-rule-container'; + const hr = document.createElement('hr'); + hr.className = 'cm-horizontal-rule'; + container.appendChild(hr); + return container; + } + + eq(_other: HorizontalRuleWidget) { + return true; + } + + ignoreEvent(): boolean { + return false; + } +} + +/** + * Build horizontal rule decorations. + */ +function buildHorizontalRuleDecorations(state: EditorState): DecorationSet { + const widgets: Array> = []; + + syntaxTree(state).iterate({ + enter: ({ type, from, to }) => { + if (type.name !== 'HorizontalRule') return; + + // Skip if cursor is on this line + if (isCursorInRange(state, [from, to])) return; + + // Replace the entire horizontal rule with a styled widget + const widget = Decoration.replace({ + widget: new HorizontalRuleWidget(), + block: true + }); + widgets.push(widget.range(from, to)); + } + }); + + return Decoration.set(widgets, true); +} + +/** + * StateField for horizontal rule decorations (must use StateField for block decorations). + */ +const horizontalRuleField = StateField.define({ + create(state) { + return buildHorizontalRuleDecorations(state); + }, + update(value, tx) { + if (tx.docChanged || tx.selection) { + return buildHorizontalRuleDecorations(tx.state); + } + return value.map(tx.changes); + }, + provide(field) { + return EditorView.decorations.from(field); + } +}); + +/** + * Base theme for horizontal rules. + */ +const baseTheme = EditorView.baseTheme({ + '.cm-horizontal-rule-container': { + display: 'flex', + alignItems: 'center', + padding: '0.5rem 0', + margin: '0.5rem 0', + userSelect: 'none' + }, + '.cm-horizontal-rule': { + width: '100%', + height: '1px', + border: 'none', + borderTop: '2px solid var(--cm-hr-color, rgba(128, 128, 128, 0.3))', + margin: '0' + } +}); + diff --git a/frontend/src/views/editor/extensions/markdown/plugins/html.ts b/frontend/src/views/editor/extensions/markdown/plugins/html.ts new file mode 100644 index 0000000..6653d3a --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/html.ts @@ -0,0 +1,82 @@ +import { syntaxTree } from '@codemirror/language'; +import { EditorState, StateField } from '@codemirror/state'; +import { + Decoration, + DecorationSet, + EditorView, + WidgetType +} from '@codemirror/view'; +import DOMPurify from 'dompurify'; +import { isCursorInRange } from '../util'; + +interface EmbedBlockData { + from: number; + to: number; + content: string; +} + +function extractHTMLBlocks(state: EditorState) { + const blocks = new Array(); + syntaxTree(state).iterate({ + enter({ from, to, name }) { + if (name !== 'HTMLBlock') return; + if (isCursorInRange(state, [from, to])) return; + const html = state.sliceDoc(from, to); + const content = DOMPurify.sanitize(html, { + FORBID_ATTR: ['style'] + }); + + blocks.push({ + from, + to, + content + }); + } + }); + return blocks; +} + +function blockToDecoration(blocks: EmbedBlockData[]) { + return blocks.map((block) => + Decoration.widget({ + widget: new HTMLBlockWidget(block), + block: true, + side: 1 + }).range(block.to) + ); +} + +export const htmlBlock = StateField.define({ + create(state) { + return Decoration.set(blockToDecoration(extractHTMLBlocks(state))); + }, + update(value, tx) { + if (tx.docChanged || tx.selection) { + return Decoration.set( + blockToDecoration(extractHTMLBlocks(tx.state)) + ); + } + return value.map(tx.changes); + }, + provide(field) { + return EditorView.decorations.from(field); + } +}); + +class HTMLBlockWidget extends WidgetType { + constructor(public data: EmbedBlockData, public isInline?: true) { + super(); + } + toDOM(): HTMLElement { + const dom = document.createElement('div'); + dom.style.display = this.isInline ? 'inline' : 'block'; + // Contain child margins + dom.style.overflow = 'auto'; + // This is sanitized! + dom.innerHTML = this.data.content; + return dom; + } + eq(widget: HTMLBlockWidget): boolean { + return JSON.stringify(widget.data) === JSON.stringify(this.data); + } +} diff --git a/frontend/src/views/editor/extensions/markdown/plugins/image.ts b/frontend/src/views/editor/extensions/markdown/plugins/image.ts new file mode 100644 index 0000000..040ea8f --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/image.ts @@ -0,0 +1,71 @@ +import { Extension, Range } from '@codemirror/state'; +import { EditorView } from 'codemirror'; +import { imagePreview } from '../state/image'; +import { image as classes } from '../classes'; +import { + Decoration, + DecorationSet, + ViewPlugin, + ViewUpdate +} from '@codemirror/view'; +import { + iterateTreeInVisibleRanges, + isCursorInRange, + invisibleDecoration +} from '../util'; + +function hideNodes(view: EditorView) { + const widgets = new Array>(); + iterateTreeInVisibleRanges(view, { + enter(node) { + if ( + node.name === 'Image' && + !isCursorInRange(view.state, [node.from, node.to]) + ) { + widgets.push(invisibleDecoration.range(node.from, node.to)); + } + } + }); + return Decoration.set(widgets, true); +} + +const hideImageNodePlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = hideNodes(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.selectionSet) + this.decorations = hideNodes(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. + */ +export const image = (): Extension => [ + imagePreview, + hideImageNodePlugin, + baseTheme +]; + +const baseTheme = EditorView.baseTheme({ + ['.' + classes.widget]: { + display: 'block', + objectFit: 'contain', + maxWidth: '100%', + paddingLeft: '4px', + maxHeight: '100%', + userSelect: 'none' + } +}); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/link.ts b/frontend/src/views/editor/extensions/markdown/plugins/link.ts new file mode 100644 index 0000000..700f443 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/link.ts @@ -0,0 +1,154 @@ +import { syntaxTree } from '@codemirror/language'; +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate, + WidgetType +} from '@codemirror/view'; +import { headingSlugField } from '../state/heading-slug'; +import { + checkRangeOverlap, + invisibleDecoration, + isCursorInRange +} from '../util'; +import { link as classes } from '../classes'; + +const autoLinkMarkRE = /^<|>$/g; + +/** + * Ixora Links plugin. + * + * This plugin allows to: + * - Add an interactive link icon to a URL which can navigate to the URL. + */ +export const links = () => [goToLinkPlugin, baseTheme]; + +export class GoToLinkWidget extends WidgetType { + constructor(readonly link: string, readonly title?: string) { + super(); + } + toDOM(view: EditorView): HTMLElement { + const anchor = document.createElement('a'); + if (this.link.startsWith('#')) { + // Handle links within the markdown document. + const slugs = view.state.field(headingSlugField); + anchor.addEventListener('click', () => { + const pos = slugs.find( + (h) => h.slug === this.link.slice(1) + )?.pos; + // pos could be zero, so instead check if its undefined + if (typeof pos !== 'undefined') { + const tr = view.state.update({ + selection: { anchor: pos }, + scrollIntoView: true + }); + view.dispatch(tr); + } + }); + } else anchor.href = this.link; + anchor.target = '_blank'; + anchor.classList.add(classes.widget); + anchor.textContent = '🔗'; + if (this.title) anchor.title = this.title; + return anchor; + } +} + +function getLinkAnchor(view: EditorView) { + const widgets: Array> = []; + + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: ({ type, from, to, node }) => { + if (type.name !== 'URL') return; + const parent = node.parent; + // FIXME: make this configurable + const blackListedParents = ['Image']; + if (parent && !blackListedParents.includes(parent.name)) { + const marks = parent.getChildren('LinkMark'); + const linkTitle = parent.getChild('LinkTitle'); + const ranges = view.state.selection.ranges; + let cursorOverlaps = ranges.some(({ from, to }) => + checkRangeOverlap([from, to], [parent.from, parent.to]) + ); + if (!cursorOverlaps && marks.length > 0) { + widgets.push( + ...marks.map(({ from, to }) => + invisibleDecoration.range(from, to) + ), + invisibleDecoration.range(from, to) + ); + if (linkTitle) + widgets.push( + invisibleDecoration.range( + linkTitle.from, + linkTitle.to + ) + ); + } + + let linkContent = view.state.sliceDoc(from, to); + if (autoLinkMarkRE.test(linkContent)) { + // Remove '<' and '>' from link and content + linkContent = linkContent.replace(autoLinkMarkRE, ''); + cursorOverlaps = isCursorInRange(view.state, [ + node.from, + node.to + ]); + if (!cursorOverlaps) { + widgets.push( + invisibleDecoration.range(from, from + 1), + invisibleDecoration.range(to - 1, to) + ); + } + } + const linkTitleContent = linkTitle + ? view.state.sliceDoc(linkTitle.from, linkTitle.to) + : undefined; + const dec = Decoration.widget({ + widget: new GoToLinkWidget( + linkContent, + linkTitleContent + ), + side: 1 + }); + widgets.push(dec.range(to, to)); + } + } + }); + } + + return Decoration.set(widgets, true); +} + +export const goToLinkPlugin = ViewPlugin.fromClass( + class { + decorations: DecorationSet = Decoration.none; + constructor(view: EditorView) { + this.decorations = getLinkAnchor(view); + } + update(update: ViewUpdate) { + if ( + update.docChanged || + update.viewportChanged || + update.selectionSet + ) + this.decorations = getLinkAnchor(update.view); + } + }, + { decorations: (v) => v.decorations } +); + +/** + * Base theme for the links plugin. + */ +const baseTheme = EditorView.baseTheme({ + ['.' + classes.widget]: { + cursor: 'pointer', + textDecoration: 'underline' + } +}); diff --git a/frontend/src/views/editor/extensions/markdown/plugins/list.ts b/frontend/src/views/editor/extensions/markdown/plugins/list.ts new file mode 100644 index 0000000..f040557 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/list.ts @@ -0,0 +1,177 @@ +import { + Decoration, + DecorationSet, + EditorView, + ViewPlugin, + ViewUpdate, + WidgetType +} from '@codemirror/view'; +import { isCursorInRange, iterateTreeInVisibleRanges } from '../util'; +import { ChangeSpec, Range } from '@codemirror/state'; +import { NodeType, SyntaxNodeRef } from '@lezer/common'; +import { list as classes } from '../classes'; + +const bulletListMarkerRE = /^[-+*]/; + +/** + * Ixora Lists plugin. + * + * This plugin allows to: + * - Customize list mark + * - Add an interactive checkbox for task lists + */ +export const lists = () => [listBulletPlugin, taskListPlugin, baseTheme]; + +/** + * Plugin to add custom list bullet mark. + */ +class ListBulletPlugin { + decorations: DecorationSet = Decoration.none; + constructor(view: EditorView) { + this.decorations = this.decorateLists(view); + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) + this.decorations = this.decorateLists(update.view); + } + private decorateLists(view: EditorView) { + const widgets: Array> = []; + iterateTreeInVisibleRanges(view, { + enter: ({ type, from, to }) => { + if (isCursorInRange(view.state, [from, to])) return; + if (type.name === 'ListMark') { + const listMark = view.state.sliceDoc(from, to); + if (bulletListMarkerRE.test(listMark)) { + const dec = Decoration.replace({ + widget: new ListBulletWidget(listMark) + }); + widgets.push(dec.range(from, to)); + } + } + } + }); + return Decoration.set(widgets, true); + } +} +const listBulletPlugin = ViewPlugin.fromClass(ListBulletPlugin, { + decorations: (v) => v.decorations +}); + +/** + * Widget to render list bullet mark. + */ +class ListBulletWidget extends WidgetType { + constructor(readonly bullet: string) { + super(); + } + toDOM(): HTMLElement { + const listBullet = document.createElement('span'); + listBullet.textContent = this.bullet; + listBullet.className = 'cm-list-bullet'; + return listBullet; + } +} + +/** + * Plugin to add checkboxes in task lists. + */ +class TaskListsPlugin { + decorations: DecorationSet = Decoration.none; + constructor(view: EditorView) { + this.decorations = this.addCheckboxes(view); + } + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) + this.decorations = this.addCheckboxes(update.view); + } + addCheckboxes(view: EditorView) { + const widgets: Range[] = []; + iterateTreeInVisibleRanges(view, { + enter: this.iterateTree(view, widgets) + }); + return Decoration.set(widgets, true); + } + + private iterateTree(view: EditorView, widgets: Range[]) { + return ({ type, from, to, node }: SyntaxNodeRef) => { + if (type.name !== 'Task') return; + let checked = false; + // Iterate inside the task node to find the checkbox + node.toTree().iterate({ + enter: (ref) => iterateInner(ref.type, ref.from, ref.to) + }); + if (checked) + widgets.push( + Decoration.mark({ + tagName: 'span', + class: 'cm-task-checked' + }).range(from, to) + ); + + function iterateInner(type: NodeType, nfrom: number, nto: number) { + if (type.name !== 'TaskMarker') return; + if (isCursorInRange(view.state, [from + nfrom, from + nto])) + return; + const checkbox = view.state.sliceDoc(from + nfrom, from + nto); + // Checkbox is checked if it has a 'x' in between the [] + if ('xX'.includes(checkbox[1])) checked = true; + const dec = Decoration.replace({ + widget: new CheckboxWidget(checked, from + nfrom + 1) + }); + widgets.push(dec.range(from + nfrom, from + nto)); + } + }; + } +} + +/** + * Widget to render checkbox for a task list item. + */ +class CheckboxWidget extends WidgetType { + constructor(public checked: boolean, readonly pos: number) { + super(); + } + toDOM(view: EditorView): HTMLElement { + const wrap = document.createElement('span'); + wrap.classList.add(classes.taskCheckbox); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = this.checked; + checkbox.addEventListener('click', ({ target }) => { + const change: ChangeSpec = { + from: this.pos, + to: this.pos + 1, + insert: this.checked ? ' ' : 'x' + }; + view.dispatch({ changes: change }); + this.checked = !this.checked; + (target as HTMLInputElement).checked = this.checked; + }); + wrap.appendChild(checkbox); + return wrap; + } +} + +const taskListPlugin = ViewPlugin.fromClass(TaskListsPlugin, { + decorations: (v) => v.decorations +}); + +/** + * Base theme for the lists plugin. + */ +const baseTheme = EditorView.baseTheme({ + ['.' + classes.bullet]: { + position: 'relative', + visibility: 'hidden' + }, + ['.' + classes.taskChecked]: { + textDecoration: 'line-through !important' + }, + ['.' + classes.bullet + ':after']: { + visibility: 'visible', + position: 'absolute', + top: 0, + left: 0, + content: "'\\2022'" /* U+2022 BULLET */ + } +}); 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 new file mode 100644 index 0000000..8654cd9 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/paste-rich-text.ts @@ -0,0 +1,201 @@ +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 new file mode 100644 index 0000000..f81bfaf --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/reveal-on-arrow.ts @@ -0,0 +1,66 @@ +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/plugins/soft-indent.ts b/frontend/src/views/editor/extensions/markdown/plugins/soft-indent.ts new file mode 100644 index 0000000..e9ccc84 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/plugins/soft-indent.ts @@ -0,0 +1,161 @@ +import { + Annotation, + Line, + RangeSet, + RangeSetBuilder, + Extension +} from '@codemirror/state'; +import { + Decoration, + EditorView, + ViewPlugin, + ViewUpdate, + type DecorationSet +} from '@codemirror/view'; + +/** + * Soft indent plugin for better visual alignment of list items and blockquotes. + * + * This plugin: + * - Measures the width of list markers, blockquote markers, etc. + * - Applies padding to align the content properly + * - Updates dynamically as content changes + */ +export const softIndent = (): Extension => [softIndentPlugin]; + +interface IndentData { + line: Line; + indentWidth: number; +} + +/** + * Pattern to match content that needs soft indentation: + * - Blockquote markers (> ) + * - List markers (-, *, +, 1., etc.) + * - Task markers ([x] or [ ]) + */ +const softIndentPattern = /^(> )*(\s*)?(([-*+]|\d+[.)])\s)?(\[.\]\s)?/; + +const softIndentRefresh = Annotation.define(); + +/** + * Plugin to apply soft indentation. + */ +class SoftIndentPlugin { + decorations: DecorationSet = Decoration.none; + + constructor(view: EditorView) { + this.requestMeasure(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.requestMeasure(update.view); + } + + if (update.transactions.some((tr) => tr.annotation(softIndentRefresh))) { + this.requestMeasure(update.view); + } + } + + requestMeasure(view: EditorView) { + // Needs to run via requestMeasure since it measures and updates the DOM + view.requestMeasure({ + read: (view) => this.measureIndents(view), + write: (indents, view) => { + this.applyIndents(indents, view); + } + }); + } + + /** + * Measure the indent width for each line that needs soft indentation. + */ + measureIndents(view: EditorView): IndentData[] { + const indents: IndentData[] = []; + + // Loop through all visible lines + for (const { from, to } of view.visibleRanges) { + const start = view.state.doc.lineAt(from); + const end = view.state.doc.lineAt(to); + + for (let i = start.number; i <= end.number; i++) { + // Get current line object + const line = view.state.doc.line(i); + + // Match the line's text with the indent pattern + const text = view.state.sliceDoc(line.from, line.to); + const matches = softIndentPattern.exec(text); + if (!matches) continue; + + const nonContent = matches[0]; + if (!nonContent) continue; + + // Get indent width by measuring DOM coordinates + const startCoords = view.coordsAtPos(line.from); + const endCoords = view.coordsAtPos(line.from + nonContent.length); + + if (!startCoords || !endCoords) continue; + + const indentWidth = endCoords.left - startCoords.left; + if (indentWidth <= 0) continue; + + indents.push({ + line, + indentWidth + }); + } + } + + return indents; + } + + /** + * Build decorations from indent data. + */ + buildDecorations(indents: IndentData[]): DecorationSet { + const builder = new RangeSetBuilder(); + + for (const { line, indentWidth } of indents) { + const deco = Decoration.line({ + attributes: { + style: `padding-inline-start: ${Math.ceil( + indentWidth + 6 + )}px; text-indent: -${Math.ceil(indentWidth)}px;` + } + }); + + builder.add(line.from, line.from, deco); + } + + return builder.finish(); + } + + /** + * Apply new decorations and dispatch a transaction if needed. + */ + applyIndents(indents: IndentData[], view: EditorView) { + const newDecos = this.buildDecorations(indents); + let changed = false; + + for (const { from, to } of view.visibleRanges) { + if (!RangeSet.eq([this.decorations], [newDecos], from, to)) { + changed = true; + break; + } + } + + if (changed) { + queueMicrotask(() => { + view.dispatch({ annotations: [softIndentRefresh.of(true)] }); + }); + } + + this.decorations = newDecos; + } +} + +const softIndentPlugin = ViewPlugin.fromClass(SoftIndentPlugin, { + decorations: (v) => v.decorations +}); + diff --git a/frontend/src/views/editor/extensions/markdown/state/heading-slug.ts b/frontend/src/views/editor/extensions/markdown/state/heading-slug.ts new file mode 100644 index 0000000..9f93d58 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/state/heading-slug.ts @@ -0,0 +1,56 @@ +import { syntaxTree } from '@codemirror/language'; +import { EditorState, StateField } from '@codemirror/state'; +import { Slugger } from '../util'; +import {SyntaxNode} from "@lezer/common"; + +/** + * A heading slug is a string that is used to identify/reference + * a heading in the document. Heading slugs are URI-compatible and can be used + * in permalinks as heading IDs. + */ +export interface HeadingSlug { + slug: string; + pos: number; +} + +/** + * A plugin that stores the calculated slugs of the document headings in the + * editor state. These can be useful when resolving links to headings inside + * the document. + */ +export const headingSlugField = StateField.define({ + create: (state) => { + const slugs = extractSlugs(state); + return slugs; + }, + update: (value, tx) => { + if (tx.docChanged) return extractSlugs(tx.state); + return value; + }, + compare: (a, b) => + a.length === b.length && + a.every((slug, i) => slug.slug === b[i].slug && slug.pos === b[i].pos) +}); + +/** + * + * @param state - The current editor state. + * @returns An array of heading slugs. + */ +function extractSlugs(state: EditorState): HeadingSlug[] { + const slugs: HeadingSlug[] = []; + const slugger = new Slugger(); + syntaxTree(state).iterate({ + enter: ({ name, from, to, node }) => { + // Capture ATXHeading and SetextHeading + if (!name.includes('Heading')) return; + const mark: SyntaxNode | null = node.getChild('HeaderMark'); + + const headerText = state.sliceDoc(from, to).split(''); + headerText.splice(mark!.from - from, mark!.to - mark!.from); + const slug = slugger.slug(headerText.join('').trim()); + slugs.push({ slug, pos: from }); + } + }); + return slugs; +} diff --git a/frontend/src/views/editor/extensions/markdown/state/image.ts b/frontend/src/views/editor/extensions/markdown/state/image.ts new file mode 100644 index 0000000..8323520 --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/state/image.ts @@ -0,0 +1,171 @@ +import { syntaxTree } from '@codemirror/language'; +import { + StateField, + EditorState, + StateEffect, + TransactionSpec +} from '@codemirror/state'; +import { + DecorationSet, + Decoration, + WidgetType, + EditorView +} from '@codemirror/view'; +import { image as classes } from '../classes'; + +/** + * Representation of the data held by the image URL state field. + */ +export interface ImageInfo { + /** The source of the image. */ + src: string; + /** The starting position of the image element in the document. */ + from: number; + /** The end position of the image element in the document. */ + 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) => + // This does not need to be a block widget + Decoration.widget({ + widget: new ImagePreviewWidget(img, WidgetState.INITIAL), + info: img, + src: img.src + }).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 + ), + // Create returns a inline widget, return inline image + // if image is not loaded for consistency. + block: hasImageLoaded ? true : false, + 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 = /(?:!\[)(.*?)(?:\])/; + +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 }); + } + } + }); + return imageUrls; +} + +class ImagePreviewWidget extends WidgetType { + constructor( + public readonly info: ImageInfo, + public readonly state: WidgetState + ) { + super(); + } + + toDOM(view: EditorView): HTMLElement { + const img = new Image(); + img.classList.add(classes.widget); + 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.state === WidgetState.LOADED) return img; + // Render placeholder + else return new Image(); + } + + eq(widget: ImagePreviewWidget): boolean { + return ( + JSON.stringify(widget.info) === JSON.stringify(this.info) && + widget.state === this.state + ); + } +} diff --git a/frontend/src/views/editor/extensions/markdown/util.ts b/frontend/src/views/editor/extensions/markdown/util.ts new file mode 100644 index 0000000..0d597ec --- /dev/null +++ b/frontend/src/views/editor/extensions/markdown/util.ts @@ -0,0 +1,123 @@ +import { foldedRanges, syntaxTree } from '@codemirror/language'; +import type { SyntaxNodeRef } from '@lezer/common'; +import { Decoration, EditorView } from '@codemirror/view'; +import { EditorState } from '@codemirror/state'; + +/** + * Check if two ranges overlap + * Based on the visual diagram on https://stackoverflow.com/a/25369187 + * @param range1 - Range 1 + * @param range2 - Range 2 + * @returns True if the ranges overlap + */ +export function checkRangeOverlap( + range1: [number, number], + range2: [number, number] +) { + return range1[0] <= range2[1] && range2[0] <= range1[1]; +} + +/** + * Check if a range is inside another range + * @param parent - Parent (bigger) range + * @param child - Child (smaller) range + * @returns True if child is inside parent + */ +export function checkRangeSubset( + parent: [number, number], + child: [number, number] +) { + return child[0] >= parent[0] && child[1] <= parent[1]; +} + +/** + * Check if any of the editor cursors is in the given range + * @param state - Editor state + * @param range - Range to check + * @returns True if the cursor is in the range + */ +export function isCursorInRange(state: EditorState, range: [number, number]) { + return state.selection.ranges.some((selection) => + checkRangeOverlap(range, [selection.from, selection.to]) + ); +} + +/** + * Iterate over the syntax tree in the visible ranges of the document + * @param view - Editor view + * @param iterateFns - Object with `enter` and `leave` iterate function + */ +export function iterateTreeInVisibleRanges( + view: EditorView, + iterateFns: { + enter(node: SyntaxNodeRef): boolean | void; + leave?(node: SyntaxNodeRef): void; + } +) { + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ ...iterateFns, from, to }); + } +} + +/** + * Decoration to simply hide anything. + */ +export const invisibleDecoration = Decoration.replace({}); + +/** + * Returns the lines of the editor that are in the given range and not folded. + * This function is of use when you need to get the lines of a particular + * block node and add line decorations to each line of it. + * + * @param view - Editor view + * @param from - Start of the range + * @param to - End of the range + * @returns A list of line blocks that are in the range + */ +export function editorLines(view: EditorView, from: number, to: number) { + let lines = view.viewportLineBlocks.filter((block) => + // Keep lines that are in the range + checkRangeOverlap([block.from, block.to], [from, to]) + ); + + const folded = foldedRanges(view.state).iter(); + while (folded.value) { + lines = lines.filter( + (line) => + !checkRangeOverlap( + [folded.from, folded.to], + [line.from, line.to] + ) + ); + folded.next(); + } + + return lines; +} + +/** + * Class containing methods to generate slugs from heading contents. + */ +export class Slugger { + /** Occurrences for each slug. */ + private occurences: { [key: string]: number } = {}; + /** + * Generate a slug from the given content. + * @param text - Content to generate the slug from + * @returns the slug + */ + public slug(text: string) { + let slug = text + .toLowerCase() + .replace(/\s+/g, '-') + .replace(/[^\w-]+/g, ''); + + if (slug in this.occurences) { + this.occurences[slug]++; + slug += '-' + this.occurences[slug]; + } else { + this.occurences[slug] = 1; + } + return slug; + } +} diff --git a/frontend/src/views/editor/extensions/markdownPreview/PreviewPanel.vue b/frontend/src/views/editor/extensions/markdownPreview/PreviewPanel.vue deleted file mode 100644 index 8e7723b..0000000 --- a/frontend/src/views/editor/extensions/markdownPreview/PreviewPanel.vue +++ /dev/null @@ -1,520 +0,0 @@ - - - - - - diff --git a/frontend/src/views/editor/extensions/markdownPreview/index.ts b/frontend/src/views/editor/extensions/markdownPreview/index.ts deleted file mode 100644 index 224a2c4..0000000 --- a/frontend/src/views/editor/extensions/markdownPreview/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { EditorView, ViewUpdate, ViewPlugin } from "@codemirror/view"; -import { Extension } from "@codemirror/state"; -import { useDocumentStore } from "@/stores/documentStore"; -import { getActiveNoteBlock } from "../codeblock/state"; -import { markdownPreviewManager } from "./manager"; - -/** - * 切换预览面板的命令 - */ -export function toggleMarkdownPreview(view: EditorView): boolean { - const documentStore = useDocumentStore(); - const activeBlock = getActiveNoteBlock(view.state as any); - - // 如果当前没有激活的 Markdown 块,不执行操作 - if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') { - return false; - } - - // 获取当前文档ID - const currentDocumentId = documentStore.currentDocumentId; - if (currentDocumentId === null) { - return false; - } - - // 切换预览状态 - if (markdownPreviewManager.isVisible()) { - markdownPreviewManager.hide(); - } else { - markdownPreviewManager.show( - view, - currentDocumentId, - activeBlock.content.from, - activeBlock.content.to - ); - } - - return true; -} - -/** - * 预览同步插件 - */ -const previewSyncPlugin = ViewPlugin.fromClass( - class { - constructor(private view: EditorView) {} - - update(update: ViewUpdate) { - // 只在预览可见时处理 - if (!markdownPreviewManager.isVisible()) { - return; - } - - const documentStore = useDocumentStore(); - const currentDocumentId = documentStore.currentDocumentId; - const previewDocId = markdownPreviewManager.getCurrentDocumentId(); - - // 如果切换了文档,关闭预览 - if (currentDocumentId !== previewDocId) { - markdownPreviewManager.hide(); - return; - } - - // 文档内容改变时,更新预览 - if (update.docChanged) { - const activeBlock = getActiveNoteBlock(update.state as any); - - // 如果不再是 Markdown 块,关闭预览 - if (!activeBlock || activeBlock.language.name.toLowerCase() !== 'md') { - markdownPreviewManager.hide(); - return; - } - - const range = markdownPreviewManager.getCurrentBlockRange(); - - // 如果切换到其他块,关闭预览 - if (range && activeBlock.content.from !== range.from) { - markdownPreviewManager.hide(); - return; - } - - // 更新预览内容 - const newContent = update.state.doc.sliceString( - activeBlock.content.from, - activeBlock.content.to - ); - markdownPreviewManager.updateContent( - newContent, - activeBlock.content.from, - activeBlock.content.to - ); - } - } - - destroy() { - markdownPreviewManager.destroy(); - } - } -); - -/** - * 导出 Markdown 预览扩展 - */ -export function markdownPreviewExtension(): Extension { - return [previewSyncPlugin]; -} diff --git a/frontend/src/views/editor/extensions/markdownPreview/manager.ts b/frontend/src/views/editor/extensions/markdownPreview/manager.ts deleted file mode 100644 index b562d73..0000000 --- a/frontend/src/views/editor/extensions/markdownPreview/manager.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { EditorView } from '@codemirror/view'; -import { shallowRef, type ShallowRef } from 'vue'; - -/** - * 预览面板位置配置 - */ -interface PreviewPosition { - height: number; -} - -/** - * 预览状态 - */ -interface PreviewState { - visible: boolean; - position: PreviewPosition; - content: string; - blockFrom: number; - blockTo: number; - documentId: number | null; - view: EditorView | null; -} - -/** - * Markdown 预览管理器类 - */ -class MarkdownPreviewManager { - private state: ShallowRef = shallowRef({ - visible: false, - position: { height: 300 }, - content: '', - blockFrom: 0, - blockTo: 0, - documentId: null, - view: null - }); - - /** - * 获取状态(供 Vue 组件使用) - */ - useState() { - return this.state; - } - - /** - * 显示预览面板 - */ - show(view: EditorView, documentId: number, blockFrom: number, blockTo: number): void { - const content = view.state.doc.sliceString(blockFrom, blockTo); - - // 计算初始高度(编辑器容器高度的 50%) - const containerHeight = view.dom.parentElement?.clientHeight || view.dom.clientHeight; - const defaultHeight = Math.floor(containerHeight * 0.5); - - this.state.value = { - visible: true, - position: { height: Math.max(10, defaultHeight) }, - content, - blockFrom, - blockTo, - documentId, - view - }; - } - - /** - * 更新预览内容(文档编辑时调用) - */ - updateContent(content: string, blockFrom: number, blockTo: number): void { - if (!this.state.value.visible) return; - - this.state.value = { - ...this.state.value, - content, - blockFrom, - blockTo - }; - } - - /** - * 更新面板高度 - */ - updateHeight(height: number): void { - if (!this.state.value.visible) return; - - this.state.value = { - ...this.state.value, - position: { height: Math.max(10, height) } - }; - } - - /** - * 隐藏预览面板 - */ - hide(): void { - if (!this.state.value.visible) return; - - const view = this.state.value.view; - - this.state.value = { - visible: false, - position: { height: 300 }, - content: '', - blockFrom: 0, - blockTo: 0, - documentId: null, - view: null - }; - - // 关闭后聚焦编辑器 - if (view) { - view.focus(); - } - } - - /** - * 检查预览是否可见 - */ - isVisible(): boolean { - return this.state.value.visible; - } - - /** - * 获取当前预览的文档 ID - */ - getCurrentDocumentId(): number | null { - return this.state.value.documentId; - } - - /** - * 获取当前预览的块范围 - */ - getCurrentBlockRange(): { from: number; to: number } | null { - if (!this.state.value.visible) return null; - return { - from: this.state.value.blockFrom, - to: this.state.value.blockTo - }; - } - - /** - * 清理资源 - */ - destroy(): void { - this.state.value = { - visible: false, - position: { height: 300 }, - content: '', - blockFrom: 0, - blockTo: 0, - documentId: null, - view: null - }; - } -} - -/** - * 导出单例 - */ -export const markdownPreviewManager = new MarkdownPreviewManager(); - diff --git a/frontend/src/views/editor/extensions/markdownPreview/renderer.ts b/frontend/src/views/editor/extensions/markdownPreview/renderer.ts deleted file mode 100644 index bf8aca6..0000000 --- a/frontend/src/views/editor/extensions/markdownPreview/renderer.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * Markdown 渲染器配置和自定义插件 - */ -import MarkdownIt from 'markdown-it'; -import {tasklist} from "@mdit/plugin-tasklist"; -import {katex} from "@mdit/plugin-katex"; -import markPlugin from "@/common/markdown-it/plugins/markdown-it-mark"; -import hljs from 'highlight.js'; -import 'highlight.js/styles/default.css'; -import {full as emoji} from '@/common/markdown-it/plugins/markdown-it-emojis/' -import footnote_plugin from "@/common/markdown-it/plugins/markdown-it-footnote" -import sup_plugin from "@/common/markdown-it/plugins/markdown-it-sup" -import ins_plugin from "@/common/markdown-it/plugins/markdown-it-ins" -import deflist_plugin from "@/common/markdown-it/plugins/markdown-it-deflist" -import abbr_plugin from "@/common/markdown-it/plugins/markdown-it-abbr" -import sub_plugin from "@/common/markdown-it/plugins/markdown-it-sub" -import {MermaidIt} from "@/common/markdown-it/plugins/markdown-it-mermaid" -import {useThemeStore} from '@/stores/themeStore' - -/** - * 自定义链接插件:使用 data-href 替代 href,配合事件委托实现自定义跳转 - */ -export function customLinkPlugin(md: MarkdownIt) { - // 保存默认的 link_open 渲染器 - const defaultRender = md.renderer.rules.link_open || function (tokens, idx, options, env, self) { - return self.renderToken(tokens, idx, options); - }; - - // 重写 link_open 渲染器 - md.renderer.rules.link_open = function (tokens, idx, options, env, self) { - const token = tokens[idx]; - - // 获取 href 属性 - const hrefIndex = token.attrIndex('href'); - if (hrefIndex >= 0) { - const href = token.attrs![hrefIndex][1]; - - // 添加 data-href 属性保存原始链接 - token.attrPush(['data-href', href]); - - // 添加 class 用于样式 - const classIndex = token.attrIndex('class'); - if (classIndex < 0) { - token.attrPush(['class', 'markdown-link']); - } else { - token.attrs![classIndex][1] += ' markdown-link'; - } - - // 移除 href 属性,防止默认跳转 - token.attrs!.splice(hrefIndex, 1); - } - - return defaultRender(tokens, idx, options, env, self); - }; -} - -/** - * 创建 Markdown-It 实例 - */ -export function createMarkdownRenderer(): MarkdownIt { - const themeStore = useThemeStore(); - const mermaidTheme = themeStore.isDarkMode ? "dark" : "default"; - - return new MarkdownIt({ - html: true, - linkify: true, - typographer: true, - breaks: true, - langPrefix: "language-", - highlight: (code, lang) => { - // 对于大代码块(>1000行),跳过高亮以提升性能 - if (code.length > 50000) { - return `
${code}
`; - } - - if (lang && hljs.getLanguage(lang)) { - try { - return hljs.highlight(code, {language: lang, ignoreIllegals: true}).value; - } catch (error) { - console.warn(`Failed to highlight code block with language: ${lang}`, error); - return code; - } - } - - // 对于中等大小的代码块(>5000字符),跳过自动检测 - if (code.length > 5000) { - return code; - } - - // 小代码块才使用自动检测 - try { - return hljs.highlightAuto(code).value; - } catch (error) { - console.warn('Failed to auto-highlight code block', error); - return code; - } - } - }) - .use(tasklist, { - disabled: false, - }) - .use(customLinkPlugin) - .use(markPlugin) - .use(emoji) - .use(footnote_plugin) - .use(sup_plugin) - .use(ins_plugin) - .use(deflist_plugin) - .use(abbr_plugin) - .use(sub_plugin) - .use(katex) - .use(MermaidIt, { - theme: mermaidTheme - }); -} - -