🚧 Refactor markdown preview extension

This commit is contained in:
2025-11-28 00:38:38 +08:00
parent ac086db1ed
commit 8d9bcdad7e
30 changed files with 2525 additions and 991 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -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(() => ({
<!-- 块语言选择器 -->
<BlockLanguageSelector/>
<!-- Markdown预览按钮 -->
<div
v-if="canPreviewMarkdown"
class="preview-button"
:class="{ 'active': isCurrentBlockPreviewing }"
:title="isCurrentBlockPreviewing ? t('toolbar.closePreview') : t('toolbar.previewMarkdown')"
@click="togglePreview"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</div>
<!-- 格式化按钮 - 支持点击操作 -->
<div
v-if="canFormatCurrentBlock"

View File

@@ -29,8 +29,8 @@ import {generateContentHash} from "@/common/utils/hashUtils";
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
import {EDITOR_CONFIG} from '@/common/constant/editor';
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
import {markdownPreviewExtension} from "@/views/editor/extensions/markdownPreview";
import {createDebounce} from '@/common/utils/debounce';
import markdownExtensions from "@/views/editor/extensions/markdown";
export interface DocumentStats {
lines: number;
@@ -261,8 +261,6 @@ export const useEditorStore = defineStore('editor', () => {
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
];
// 创建编辑器状态

View File

@@ -1,18 +1,17 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useEditorStore } from '@/stores/editorStore';
import { useDocumentStore } from '@/stores/documentStore';
import { useConfigStore } from '@/stores/configStore';
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
import {useEditorStore} from '@/stores/editorStore';
import {useDocumentStore} from '@/stores/documentStore';
import {useConfigStore} from '@/stores/configStore';
import Toolbar from '@/components/toolbar/Toolbar.vue';
import { useWindowStore } from '@/stores/windowStore';
import {useWindowStore} from '@/stores/windowStore';
import LoadingScreen from '@/components/loading/LoadingScreen.vue';
import { useTabStore } from '@/stores/tabStore';
import {useTabStore} from '@/stores/tabStore';
import ContextMenu from './contextMenu/ContextMenu.vue';
import { contextMenuManager } from './contextMenu/manager';
import {contextMenuManager} from './contextMenu/manager';
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
import { translatorManager } from './extensions/translator/manager';
import {markdownPreviewManager} from "@/views/editor/extensions/markdownPreview/manager";
import PreviewPanel from "@/views/editor/extensions/markdownPreview/PreviewPanel.vue";
import {translatorManager} from './extensions/translator/manager';
const editorStore = useEditorStore();
const documentStore = useDocumentStore();
@@ -39,7 +38,6 @@ onMounted(async () => {
onBeforeUnmount(() => {
contextMenuManager.destroy();
translatorManager.destroy();
markdownPreviewManager.destroy();
});
</script>
@@ -47,21 +45,17 @@ onBeforeUnmount(() => {
<div class="editor-container">
<!-- 加载动画 -->
<transition name="loading-fade">
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT" />
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
</transition>
<!-- 编辑器和预览面板的容器 -->
<div class="editor-wrapper">
<!-- 编辑器区域 -->
<div ref="editorElement" class="editor"></div>
<!-- Markdown 预览面板 -->
<PreviewPanel />
</div>
<!-- 编辑器区域 -->
<div ref="editorElement" class="editor"></div>
<!-- 工具栏 -->
<Toolbar />
<Toolbar/>
<!-- 右键菜单 -->
<ContextMenu :portal-target="editorElement" />
<ContextMenu :portal-target="editorElement"/>
<!-- 翻译器弹窗 -->
<TranslatorDialog :portal-target="editorElement" />
<TranslatorDialog :portal-target="editorElement"/>
</div>
</template>
@@ -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%;

View File

@@ -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'
};

View File

@@ -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;

View File

@@ -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<Decoration>[] = [];
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];
}

View File

@@ -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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</svg>
`;
copyButton.onclick = async (e) => {
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(this.code);
// Visual feedback
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
`;
setTimeout(() => {
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/>
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>
</svg>
`;
}, 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<ReturnType<Decoration['range']>> = [];
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'
}
});

View File

@@ -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<ReturnType<Decoration['range']>> = [];
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'
}
});

View File

@@ -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<ReturnType<Decoration['range']>> = [];
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);
}

View File

@@ -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<Element>();
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;
}
}
}
]
};

View File

@@ -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<ReturnType<Decoration['range']>> = [];
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<ReturnType<Decoration['range']>> = [];
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' }
});

View File

@@ -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<ReturnType<Decoration['range']>> = [];
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
})
];

View File

@@ -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 <hr> 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<ReturnType<Decoration['range']>> = [];
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<DecorationSet>({
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'
}
});

View File

@@ -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<EmbedBlockData>();
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<DecorationSet>({
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);
}
}

View File

@@ -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<Range<Decoration>>();
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'
}
});

View File

@@ -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<ReturnType<Decoration['range']>> = [];
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'
}
});

View File

@@ -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<ReturnType<Decoration['range']>> = [];
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<Decoration>[] = [];
iterateTreeInVisibleRanges(view, {
enter: this.iterateTree(view, widgets)
});
return Decoration.set(widgets, true);
}
private iterateTree(view: EditorView, widgets: Range<Decoration>[]) {
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 */
}
});

View File

@@ -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 }
]);

View File

@@ -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')
}
]);

View File

@@ -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<boolean>();
/**
* 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<Decoration>();
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
});

View File

@@ -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<HeadingSlug[]>({
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;
}

View File

@@ -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<ImageInfo>();
/** State field to store image preview decorations. */
export const imagePreview = StateField.define<DecorationSet>({
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<ImageInfo>[];
if (tx.docChanged || loadedImages.length > 0) {
const images = extractImages(tx.state);
const previous = value.iter();
const previousSpecs = new Array<ImageInfo>();
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
);
}
}

View File

@@ -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;
}
}

View File

@@ -1,520 +0,0 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import { markdownPreviewManager } from './manager';
import { createMarkdownRenderer } from './renderer';
import { updateMermaidTheme } from '@/common/markdown-it/plugins/markdown-it-mermaid';
import { useThemeStore } from '@/stores/themeStore';
import { createDebounce } from '@/common/utils/debounce';
import { morphHTML } from '@/common/utils/domDiff';
import * as runtime from '@wailsio/runtime';
import './github-markdown.css';
const state = markdownPreviewManager.useState();
const themeStore = useThemeStore();
const panelRef = ref<HTMLDivElement | null>(null);
const contentRef = ref<HTMLDivElement | null>(null);
const resizeHandleRef = ref<HTMLDivElement | null>(null);
const isVisible = computed(() => state.value.visible);
const content = computed(() => state.value.content);
const height = computed(() => state.value.position.height);
// Markdown 渲染器
let md = createMarkdownRenderer();
// 渲染的 HTML
const renderedHtml = ref('');
let lastRenderedContent = '';
let isDestroyed = false;
/**
* 使用 DOM Diff 渲染内容
*/
function renderWithDiff(markdownContent: string): void {
if (isDestroyed || !contentRef.value) return;
try {
const newHtml = md.render(markdownContent);
// 首次渲染或内容为空,直接设置
if (!lastRenderedContent || contentRef.value.children.length === 0) {
renderedHtml.value = newHtml;
} else {
// 使用 DOM Diff 增量更新
morphHTML(contentRef.value, newHtml);
}
lastRenderedContent = markdownContent;
} catch (error) {
console.warn('Markdown render error:', error);
renderedHtml.value = `<div class="markdown-error">Render failed: ${error}</div>`;
}
}
/**
* 异步渲染大内容
*/
function renderLargeContentAsync(markdownContent: string): void {
if (isDestroyed || !isVisible.value) return;
// 首次渲染显示加载状态
if (!lastRenderedContent) {
renderedHtml.value = '<div class="markdown-loading">Rendering...</div>';
}
// 使用 requestIdleCallback 在浏览器空闲时渲染
const callback = window.requestIdleCallback || ((cb: IdleRequestCallback) => setTimeout(cb, 1));
callback(() => {
// 再次检查状态,防止异步回调时预览已关闭
if (isDestroyed || !isVisible.value || !contentRef.value) return;
try {
const newHtml = md.render(markdownContent);
// 首次渲染或内容为空
if (!lastRenderedContent || contentRef.value.children.length === 0) {
// 使用 DocumentFragment 减少 DOM 操作
const fragment = document.createRange().createContextualFragment(newHtml);
if (contentRef.value) {
contentRef.value.innerHTML = '';
contentRef.value.appendChild(fragment);
}
} else {
// 使用 DOM Diff 增量更新
morphHTML(contentRef.value, newHtml);
}
lastRenderedContent = markdownContent;
} catch (error) {
console.warn('Large content render error:', error);
if (isVisible.value) {
renderedHtml.value = `<div class="markdown-error">Render failed: ${error}</div>`;
}
}
});
}
/**
* 渲染 Markdown 内容
*/
function renderMarkdown(markdownContent: string): void {
if (!markdownContent || markdownContent === lastRenderedContent) {
return;
}
// 大内容使用异步渲染
if (markdownContent.length > 1000) {
renderLargeContentAsync(markdownContent);
} else {
renderWithDiff(markdownContent);
}
}
/**
* 重新创建渲染器
*/
function resetRenderer(): void {
md = createMarkdownRenderer();
const currentTheme = themeStore.isDarkMode ? 'dark' : 'default';
updateMermaidTheme(currentTheme);
lastRenderedContent = '';
if (content.value) {
renderMarkdown(content.value);
}
}
// 计算 data-theme 属性值
const dataTheme = computed(() => themeStore.isDarkMode ? 'dark' : 'light');
// 拖动相关状态
let startY = 0;
let startHeight = 0;
let currentHandle: HTMLDivElement | null = null;
const onMouseMove = (e: MouseEvent) => {
const delta = startY - e.clientY; // 向上拖动增加高度
// 获取编辑器容器高度作为最大限制
const editorView = state.value.view;
const maxHeight = editorView
? (editorView.dom.parentElement?.clientHeight || editorView.dom.clientHeight)
: 9999;
const newHeight = Math.max(10, Math.min(maxHeight, startHeight + delta));
markdownPreviewManager.updateHeight(newHeight);
};
const onMouseUp = () => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (currentHandle) {
currentHandle.classList.remove('dragging');
}
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
const onMouseDown = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
startY = e.clientY;
startHeight = height.value;
currentHandle = resizeHandleRef.value;
if (currentHandle) {
currentHandle.classList.add('dragging');
}
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
/**
* 清理拖动事件
*/
function cleanupResize(): void {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
/**
* 处理链接点击
*/
function handleLinkClick(e: MouseEvent): void {
const target = e.target as HTMLElement;
const anchor = target.closest('a');
if (!anchor) return;
const href = anchor.getAttribute('href');
// 处理锚点跳转
if (href && href.startsWith('#')) {
e.preventDefault();
const targetId = href.substring(1);
const targetElement = document.getElementById(targetId);
if (targetElement && contentRef.value?.contains(targetElement)) {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
return;
}
// 处理外部链接
if (href && !href.startsWith('#')) {
e.preventDefault();
if (isValidUrl(href)) {
runtime.Browser.OpenURL(href);
} else {
console.warn('Invalid or relative link:', href);
}
return;
}
// data-href 属性处理
const dataHref = anchor.getAttribute('data-href');
if (dataHref) {
e.preventDefault();
if (isValidUrl(dataHref)) {
runtime.Browser.OpenURL(dataHref);
}
}
}
/**
* 验证 URL 是否有效
*/
function isValidUrl(url: string): boolean {
try {
if (url.match(/^[a-zA-Z][a-zA-Z\d+\-.]*:/)) {
const parsedUrl = new URL(url);
const allowedProtocols = ['http:', 'https:', 'mailto:', 'file:', 'ftp:'];
return allowedProtocols.includes(parsedUrl.protocol);
}
return false;
} catch {
return false;
}
}
// 面板样式
const panelStyle = computed(() => ({
height: `${height.value}px`
}));
// 创建防抖渲染函数
const { debouncedFn: debouncedRender, cancel: cancelDebounce } = createDebounce(
(newContent: string) => {
if (isVisible.value && newContent) {
renderMarkdown(newContent);
}
},
{ delay: 500 }
);
// 监听内容变化
watch(
content,
(newContent) => {
if (isVisible.value && newContent) {
debouncedRender(newContent);
}
},
{ immediate: true }
);
// 监听主题变化
watch(() => themeStore.isDarkMode, resetRenderer);
// 监听可见性变化,初始化/清理拖动
watch(isVisible, async (visible) => {
if (visible) {
await nextTick();
// 初始化拖动手柄
const handle = resizeHandleRef.value;
if (handle) {
handle.addEventListener('mousedown', onMouseDown);
}
if (content.value) {
renderMarkdown(content.value);
}
} else {
// 清理拖动事件
const handle = resizeHandleRef.value;
if (handle) {
handle.removeEventListener('mousedown', onMouseDown);
}
cleanupResize();
cancelDebounce();
renderedHtml.value = '';
lastRenderedContent = '';
}
});
// 组件卸载时清理
onUnmounted(() => {
isDestroyed = true;
cancelDebounce();
cleanupResize();
lastRenderedContent = '';
});
</script>
<template>
<transition name="preview-slide">
<div
v-if="isVisible"
ref="panelRef"
class="cm-markdown-preview-panel"
:style="panelStyle"
>
<!-- 拖动调整手柄 -->
<div ref="resizeHandleRef" class="cm-preview-resize-handle">
<div class="resize-indicator"></div>
</div>
<!-- 预览内容 -->
<div
ref="contentRef"
class="cm-preview-content markdown-body"
:data-theme="dataTheme"
@click="handleLinkClick"
v-html="renderedHtml"
></div>
</div>
</transition>
</template>
<style scoped lang="scss">
.cm-markdown-preview-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-height: 10px;
background: var(--bg-primary);
border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 10;
}
.cm-preview-resize-handle {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 8px;
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
background: transparent;
transition: background-color 0.2s ease;
}
.cm-preview-resize-handle:hover {
background: var(--bg-hover, rgba(255, 255, 255, 0.05));
}
.cm-preview-resize-handle.dragging {
background: var(--bg-hover, rgba(66, 133, 244, 0.1));
}
.resize-indicator {
width: 40px;
height: 3px;
border-radius: 2px;
background: var(--border-color, rgba(255, 255, 255, 0.2));
transition: background-color 0.2s ease;
}
.cm-preview-resize-handle:hover .resize-indicator {
background: var(--text-muted, rgba(255, 255, 255, 0.4));
}
.cm-preview-content {
flex: 1;
overflow-y: auto;
padding: 45px;
box-sizing: border-box;
position: relative; /* 为绝对定位的 loading/error 提供定位上下文 */
}
/* ========== macOS 窗口风格代码块(主题适配)========== */
.cm-preview-content.markdown-body {
:deep(pre) {
position: relative;
padding-top: 40px !important;
}
/* 暗色主题 */
&[data-theme="dark"] {
:deep(pre) {
/* macOS 窗口顶部栏 */
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 28px;
background-color: #1c1c1e;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
border-radius: 6px 6px 0 0;
z-index: 1;
}
/* macOS 三个控制按钮 */
&::after {
content: "";
position: absolute;
top: 10px;
left: 12px;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #ec6a5f;
box-shadow: 18px 0 0 0 #f4bf4f, 36px 0 0 0 #61c554;
z-index: 2;
}
}
}
/* 亮色主题 */
&[data-theme="light"] {
:deep(pre) {
/* macOS 窗口顶部栏 */
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 28px;
background-color: #e8e8e8;
border-bottom: 1px solid #d1d1d6;
border-radius: 6px 6px 0 0;
z-index: 1;
}
/* macOS 三个控制按钮 */
&::after {
content: "";
position: absolute;
top: 10px;
left: 12px;
width: 12px;
height: 12px;
border-radius: 50%;
background-color: #ff5f57;
box-shadow: 18px 0 0 0 #febc2e, 36px 0 0 0 #28c840;
z-index: 2;
}
}
}
}
/* Loading 和 Error 状态 - 居中显示 */
.cm-preview-content :deep(.markdown-loading),
.cm-preview-content :deep(.markdown-error) {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
font-size: 14px;
color: var(--text-muted, #7d8590);
text-align: center;
margin: 0;
padding: 0;
}
.cm-preview-content :deep(.markdown-error) {
color: #f85149;
font-weight: 500;
}
/* 过渡动画 - 从下往上弹起 */
.preview-slide-enter-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.preview-slide-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.6, 1);
}
.preview-slide-enter-from {
transform: translateY(100%);
opacity: 0;
}
.preview-slide-enter-to {
transform: translateY(0);
opacity: 1;
}
.preview-slide-leave-from {
transform: translateY(0);
opacity: 1;
}
.preview-slide-leave-to {
transform: translateY(100%);
opacity: 0;
}
</style>

View File

@@ -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];
}

View File

@@ -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<PreviewState> = 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();

View File

@@ -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 `<pre><code>${code}</code></pre>`;
}
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
});
}