🚧 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",
|
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||||
"@toml-tools/lexer": "^1.0.0",
|
"@toml-tools/lexer": "^1.0.0",
|
||||||
"@toml-tools/parser": "^1.0.0",
|
"@toml-tools/parser": "^1.0.0",
|
||||||
"@types/markdown-it": "^14.1.2",
|
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"codemirror-lang-elixir": "^4.0.0",
|
"codemirror-lang-elixir": "^4.0.0",
|
||||||
"colors-named": "^1.0.2",
|
"colors-named": "^1.0.2",
|
||||||
"colors-named-hex": "^1.0.2",
|
"colors-named-hex": "^1.0.2",
|
||||||
"groovy-beautify": "^0.0.17",
|
"groovy-beautify": "^0.0.17",
|
||||||
"highlight.js": "^11.11.1",
|
|
||||||
"hsl-matcher": "^1.2.4",
|
"hsl-matcher": "^1.2.4",
|
||||||
"java-parser": "^3.0.1",
|
"java-parser": "^3.0.1",
|
||||||
"linguist-languages": "^9.1.0",
|
"linguist-languages": "^9.1.0",
|
||||||
"markdown-it": "^14.1.0",
|
|
||||||
"mermaid": "^11.12.1",
|
"mermaid": "^11.12.1",
|
||||||
"npm": "^11.6.3",
|
"npm": "^11.6.3",
|
||||||
"php-parser": "^3.2.5",
|
"php-parser": "^3.2.5",
|
||||||
@@ -2920,6 +2917,18 @@
|
|||||||
"@types/d3-selection": "*"
|
"@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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -2989,6 +2998,15 @@
|
|||||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.10.1",
|
"version": "24.10.1",
|
||||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.10.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/node/-/node-24.10.1.tgz",
|
||||||
@@ -3828,6 +3846,7 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
|
||||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
|
"devOptional": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/asn1.js": {
|
"node_modules/asn1.js": {
|
||||||
@@ -6198,15 +6217,6 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/hmac-drbg": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
|
"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",
|
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"uc.micro": "^2.0.0"
|
"uc.micro": "^2.0.0"
|
||||||
}
|
}
|
||||||
@@ -6792,6 +6804,8 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1",
|
"argparse": "^2.0.1",
|
||||||
"entities": "^4.4.0",
|
"entities": "^4.4.0",
|
||||||
@@ -6864,7 +6878,9 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
@@ -10065,6 +10081,8 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
@@ -11050,7 +11068,9 @@
|
|||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
|
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/ufo": {
|
"node_modules/ufo": {
|
||||||
"version": "1.6.1",
|
"version": "1.6.1",
|
||||||
|
|||||||
@@ -60,17 +60,14 @@
|
|||||||
"@replit/codemirror-lang-svelte": "^6.0.0",
|
"@replit/codemirror-lang-svelte": "^6.0.0",
|
||||||
"@toml-tools/lexer": "^1.0.0",
|
"@toml-tools/lexer": "^1.0.0",
|
||||||
"@toml-tools/parser": "^1.0.0",
|
"@toml-tools/parser": "^1.0.0",
|
||||||
"@types/markdown-it": "^14.1.2",
|
|
||||||
"codemirror": "^6.0.2",
|
"codemirror": "^6.0.2",
|
||||||
"codemirror-lang-elixir": "^4.0.0",
|
"codemirror-lang-elixir": "^4.0.0",
|
||||||
"colors-named": "^1.0.2",
|
"colors-named": "^1.0.2",
|
||||||
"colors-named-hex": "^1.0.2",
|
"colors-named-hex": "^1.0.2",
|
||||||
"groovy-beautify": "^0.0.17",
|
"groovy-beautify": "^0.0.17",
|
||||||
"highlight.js": "^11.11.1",
|
|
||||||
"hsl-matcher": "^1.2.4",
|
"hsl-matcher": "^1.2.4",
|
||||||
"java-parser": "^3.0.1",
|
"java-parser": "^3.0.1",
|
||||||
"linguist-languages": "^9.1.0",
|
"linguist-languages": "^9.1.0",
|
||||||
"markdown-it": "^14.1.0",
|
|
||||||
"mermaid": "^11.12.1",
|
"mermaid": "^11.12.1",
|
||||||
"npm": "^11.6.3",
|
"npm": "^11.6.3",
|
||||||
"php-parser": "^3.2.5",
|
"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 {getLanguage} from '@/views/editor/extensions/codeblock/lang-parser/languages';
|
||||||
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
import {formatBlockContent} from '@/views/editor/extensions/codeblock/formatCode';
|
||||||
import {createDebounce} from '@/common/utils/debounce';
|
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 editorStore = useEditorStore();
|
||||||
const configStore = useConfigStore();
|
const configStore = useConfigStore();
|
||||||
@@ -25,7 +23,6 @@ const {t} = useI18n();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const canFormatCurrentBlock = ref(false);
|
const canFormatCurrentBlock = ref(false);
|
||||||
const canPreviewMarkdown = ref(false);
|
|
||||||
const isLoaded = shallowRef(false);
|
const isLoaded = shallowRef(false);
|
||||||
|
|
||||||
const { documentStats } = toRefs(editorStore);
|
const { documentStats } = toRefs(editorStore);
|
||||||
@@ -36,10 +33,6 @@ const isCurrentWindowOnTop = computed(() => {
|
|||||||
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
|
return config.value.general.alwaysOnTop || systemStore.isWindowOnTop;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 当前文档的预览是否打开
|
|
||||||
const isCurrentBlockPreviewing = computed(() => {
|
|
||||||
return markdownPreviewManager.isVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 切换窗口置顶状态
|
// 切换窗口置顶状态
|
||||||
const toggleAlwaysOnTop = async () => {
|
const toggleAlwaysOnTop = async () => {
|
||||||
@@ -68,22 +61,12 @@ const formatCurrentBlock = () => {
|
|||||||
formatBlockContent(editorStore.editorView);
|
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 updateButtonStates = () => {
|
||||||
const view: any = editorStore.editorView;
|
const view: any = editorStore.editorView;
|
||||||
if (!view) {
|
if (!view) {
|
||||||
canFormatCurrentBlock.value = false;
|
canFormatCurrentBlock.value = false;
|
||||||
canPreviewMarkdown.value = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +77,6 @@ const updateButtonStates = () => {
|
|||||||
// 提前返回,减少不必要的计算
|
// 提前返回,减少不必要的计算
|
||||||
if (!activeBlock) {
|
if (!activeBlock) {
|
||||||
canFormatCurrentBlock.value = false;
|
canFormatCurrentBlock.value = false;
|
||||||
canPreviewMarkdown.value = false;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,11 +84,9 @@ const updateButtonStates = () => {
|
|||||||
const language = getLanguage(languageName as any);
|
const language = getLanguage(languageName as any);
|
||||||
|
|
||||||
canFormatCurrentBlock.value = Boolean(language?.prettier);
|
canFormatCurrentBlock.value = Boolean(language?.prettier);
|
||||||
canPreviewMarkdown.value = languageName.toLowerCase() === 'md';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Error checking block capabilities:', error);
|
console.warn('Error checking block capabilities:', error);
|
||||||
canFormatCurrentBlock.value = false;
|
canFormatCurrentBlock.value = false;
|
||||||
canPreviewMarkdown.value = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -160,7 +140,6 @@ watch(
|
|||||||
cleanupListeners = setupEditorListeners(newView);
|
cleanupListeners = setupEditorListeners(newView);
|
||||||
} else {
|
} else {
|
||||||
canFormatCurrentBlock.value = false;
|
canFormatCurrentBlock.value = false;
|
||||||
canPreviewMarkdown.value = false;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -254,21 +233,6 @@ const statsData = computed(() => ({
|
|||||||
<!-- 块语言选择器 -->
|
<!-- 块语言选择器 -->
|
||||||
<BlockLanguageSelector/>
|
<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
|
<div
|
||||||
v-if="canFormatCurrentBlock"
|
v-if="canFormatCurrentBlock"
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ import {generateContentHash} from "@/common/utils/hashUtils";
|
|||||||
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
import {createTimerManager, type TimerManager} from '@/common/utils/timerUtils';
|
||||||
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
import {EDITOR_CONFIG} from '@/common/constant/editor';
|
||||||
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
import {createHttpClientExtension} from "@/views/editor/extensions/httpclient";
|
||||||
import {markdownPreviewExtension} from "@/views/editor/extensions/markdownPreview";
|
|
||||||
import {createDebounce} from '@/common/utils/debounce';
|
import {createDebounce} from '@/common/utils/debounce';
|
||||||
|
import markdownExtensions from "@/views/editor/extensions/markdown";
|
||||||
|
|
||||||
export interface DocumentStats {
|
export interface DocumentStats {
|
||||||
lines: number;
|
lines: number;
|
||||||
@@ -261,8 +261,6 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
|
|
||||||
const httpExtension = createHttpClientExtension();
|
const httpExtension = createHttpClientExtension();
|
||||||
|
|
||||||
// Markdown预览扩展
|
|
||||||
const previewExtension = markdownPreviewExtension();
|
|
||||||
|
|
||||||
// 再次检查操作有效性
|
// 再次检查操作有效性
|
||||||
if (!operationManager.isOperationValid(operationId, documentId)) {
|
if (!operationManager.isOperationValid(operationId, documentId)) {
|
||||||
@@ -298,7 +296,7 @@ export const useEditorStore = defineStore('editor', () => {
|
|||||||
codeBlockExtension,
|
codeBlockExtension,
|
||||||
...dynamicExtensions,
|
...dynamicExtensions,
|
||||||
...httpExtension,
|
...httpExtension,
|
||||||
previewExtension
|
markdownExtensions
|
||||||
];
|
];
|
||||||
|
|
||||||
// 创建编辑器状态
|
// 创建编辑器状态
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import {computed, onBeforeUnmount, onMounted, ref} from 'vue';
|
||||||
import { useEditorStore } from '@/stores/editorStore';
|
import {useEditorStore} from '@/stores/editorStore';
|
||||||
import { useDocumentStore } from '@/stores/documentStore';
|
import {useDocumentStore} from '@/stores/documentStore';
|
||||||
import { useConfigStore } from '@/stores/configStore';
|
import {useConfigStore} from '@/stores/configStore';
|
||||||
import Toolbar from '@/components/toolbar/Toolbar.vue';
|
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 LoadingScreen from '@/components/loading/LoadingScreen.vue';
|
||||||
import { useTabStore } from '@/stores/tabStore';
|
import {useTabStore} from '@/stores/tabStore';
|
||||||
import ContextMenu from './contextMenu/ContextMenu.vue';
|
import ContextMenu from './contextMenu/ContextMenu.vue';
|
||||||
import { contextMenuManager } from './contextMenu/manager';
|
import {contextMenuManager} from './contextMenu/manager';
|
||||||
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
|
import TranslatorDialog from './extensions/translator/TranslatorDialog.vue';
|
||||||
import { translatorManager } from './extensions/translator/manager';
|
import {translatorManager} from './extensions/translator/manager';
|
||||||
import {markdownPreviewManager} from "@/views/editor/extensions/markdownPreview/manager";
|
|
||||||
import PreviewPanel from "@/views/editor/extensions/markdownPreview/PreviewPanel.vue";
|
|
||||||
|
|
||||||
const editorStore = useEditorStore();
|
const editorStore = useEditorStore();
|
||||||
const documentStore = useDocumentStore();
|
const documentStore = useDocumentStore();
|
||||||
@@ -39,7 +38,6 @@ onMounted(async () => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
contextMenuManager.destroy();
|
contextMenuManager.destroy();
|
||||||
translatorManager.destroy();
|
translatorManager.destroy();
|
||||||
markdownPreviewManager.destroy();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -47,21 +45,17 @@ onBeforeUnmount(() => {
|
|||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
<!-- 加载动画 -->
|
<!-- 加载动画 -->
|
||||||
<transition name="loading-fade">
|
<transition name="loading-fade">
|
||||||
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT" />
|
<LoadingScreen v-if="editorStore.isLoading && enableLoadingAnimation" text="VOIDRAFT"/>
|
||||||
</transition>
|
</transition>
|
||||||
<!-- 编辑器和预览面板的容器 -->
|
|
||||||
<div class="editor-wrapper">
|
|
||||||
<!-- 编辑器区域 -->
|
<!-- 编辑器区域 -->
|
||||||
<div ref="editorElement" class="editor"></div>
|
<div ref="editorElement" class="editor"></div>
|
||||||
<!-- Markdown 预览面板 -->
|
|
||||||
<PreviewPanel />
|
|
||||||
</div>
|
|
||||||
<!-- 工具栏 -->
|
<!-- 工具栏 -->
|
||||||
<Toolbar />
|
<Toolbar/>
|
||||||
<!-- 右键菜单 -->
|
<!-- 右键菜单 -->
|
||||||
<ContextMenu :portal-target="editorElement" />
|
<ContextMenu :portal-target="editorElement"/>
|
||||||
<!-- 翻译器弹窗 -->
|
<!-- 翻译器弹窗 -->
|
||||||
<TranslatorDialog :portal-target="editorElement" />
|
<TranslatorDialog :portal-target="editorElement"/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -74,15 +68,6 @@ onBeforeUnmount(() => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
.editor-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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