🚧 Refactor markdown preview extension
This commit is contained in:
48
frontend/package-lock.json
generated
48
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
// 创建编辑器状态
|
||||
|
||||
@@ -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%;
|
||||
|
||||
74
frontend/src/views/editor/extensions/markdown/classes.ts
Normal file
74
frontend/src/views/editor/extensions/markdown/classes.ts
Normal 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'
|
||||
};
|
||||
71
frontend/src/views/editor/extensions/markdown/index.ts
Normal file
71
frontend/src/views/editor/extensions/markdown/index.ts
Normal 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;
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
239
frontend/src/views/editor/extensions/markdown/plugins/emoji.ts
Normal file
239
frontend/src/views/editor/extensions/markdown/plugins/emoji.ts
Normal 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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
134
frontend/src/views/editor/extensions/markdown/plugins/heading.ts
Normal file
134
frontend/src/views/editor/extensions/markdown/plugins/heading.ts
Normal 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' }
|
||||
});
|
||||
@@ -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
|
||||
})
|
||||
];
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
154
frontend/src/views/editor/extensions/markdown/plugins/link.ts
Normal file
154
frontend/src/views/editor/extensions/markdown/plugins/link.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
177
frontend/src/views/editor/extensions/markdown/plugins/list.ts
Normal file
177
frontend/src/views/editor/extensions/markdown/plugins/list.ts
Normal 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 */
|
||||
}
|
||||
});
|
||||
@@ -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 }
|
||||
]);
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
171
frontend/src/views/editor/extensions/markdown/state/image.ts
Normal file
171
frontend/src/views/editor/extensions/markdown/state/image.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
123
frontend/src/views/editor/extensions/markdown/util.ts
Normal file
123
frontend/src/views/editor/extensions/markdown/util.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user