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 ``;
+ }
+
+ 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
- });
-}
-
-