feat(editor): 调整字体大小并增强链接语法高亮

- 将编辑器字体大小从15px调整为16-17px,提升可读性
- 在编辑状态为链接语法标记添加.cm-md-syntax样式,使其可见且颜色正确
- 修复编辑器内链接和光标颜色,统一使用主题主色
This commit is contained in:
cfq 2026-01-27 09:35:58 +08:00
parent c0458dc1e2
commit d15ef9da00
3 changed files with 283 additions and 254 deletions

View File

@ -399,6 +399,21 @@ const buildDecorations = (state, filePath) => {
to: nodeTo, to: nodeTo,
value: Decoration.mark({ class: 'cm-md-link-text' }) value: Decoration.mark({ class: 'cm-md-link-text' })
}) })
} else {
// 编辑状态:给链接的语法标记添加样式,使其可见且颜色正确
let cursor = node.node.cursor()
if (cursor.firstChild()) {
do {
const subType = cursor.type.name
if (subType === 'LinkMark' || subType === 'URL' || subType === 'LinkTitle') {
decorations.push({
from: cursor.from,
to: cursor.to,
value: Decoration.mark({ class: 'cm-md-syntax' })
})
}
} while (cursor.nextSibling())
}
} }
} }

View File

@ -1,299 +1,305 @@
<template> <template>
<div class="live-editor"> <div class="live-editor">
<EditorToolbar @action="handleToolbarAction" /> <EditorToolbar @action="handleToolbarAction" />
<codemirror <codemirror v-model="code" placeholder="请输入 Markdown 内容..." :style="{ height: '0', flex: 1, fontSize: 'var(--editor-font-size)', color: 'var(--primary-color)' }" :autofocus="true" :indent-with-tab="true" :tab-size="2" :extensions="extensions" @ready="handleReady" @change="handleChange" />
v-model="code"
placeholder="请输入 Markdown 内容..."
:style="{ height: '0', flex: 1, fontSize: 'var(--editor-font-size)' }"
:autofocus="true"
:indent-with-tab="true"
:tab-size="2"
:extensions="extensions"
@ready="handleReady"
@change="handleChange"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, onUnmounted, ref, watch, shallowRef } from 'vue' import { computed, onUnmounted, ref, watch, shallowRef } from "vue";
import { Codemirror } from 'vue-codemirror' import { Codemirror } from "vue-codemirror";
import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { EditorView } from '@codemirror/view' import { EditorView } from "@codemirror/view";
import { useTheme } from '../composables/useTheme' import { useTheme } from "../composables/useTheme";
import { liveMarkdownDecorations } from '../codemirror/liveMarkdownDecorations' import { liveMarkdownDecorations } from "../codemirror/liveMarkdownDecorations";
import { attachImagePaste } from '../codemirror/imagePaste' import { attachImagePaste } from "../codemirror/imagePaste";
import { useEditor } from '../composables/useEditor' import { useEditor } from "../composables/useEditor";
import { useMarkdownActions } from '../composables/useMarkdownActions' import { useMarkdownActions } from "../composables/useMarkdownActions";
import { useTabs } from '../composables/useTabs' import { useTabs } from "../composables/useTabs";
import EditorToolbar from './EditorToolbar.vue' import EditorToolbar from "./EditorToolbar.vue";
const props = defineProps({ const props = defineProps({
modelValue: String, modelValue: String,
filePath: String filePath: String,
}) });
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(["update:modelValue"]);
const code = ref(props.modelValue || '') const code = ref(props.modelValue || "");
const { isDark } = useTheme() const { isDark } = useTheme();
const { onEditorAction } = useEditor() const { onEditorAction } = useEditor();
const { activeTab } = useTabs() const { activeTab } = useTabs();
const { applyHeading, wrapSelection, prefixLines, insertLink } = useMarkdownActions() const { applyHeading, wrapSelection, prefixLines, insertLink } = useMarkdownActions();
// Markdown // Markdown
const markdownExtension = markdown({ base: markdownLanguage }) const markdownExtension = markdown({ base: markdownLanguage });
const editorView = shallowRef(null) const editorView = shallowRef(null);
let detachPaste = null let detachPaste = null;
let removeActionListener = null let removeActionListener = null;
const liveEditorTheme = computed(() => { const liveEditorTheme = computed(() => {
return EditorView.theme({ return EditorView.theme(
'&': { {
backgroundColor: 'var(--card-background)', "&": {
color: 'var(--text-color)' backgroundColor: "var(--card-background)",
color: "var(--text-color)",
}, },
'.cm-scroller': { ".cm-scroller": {
fontFamily: 'var(--app-font-family)' fontFamily: "var(--app-font-family)",
}, },
'.cm-content': { ".cm-content": {
padding: '30px 40px', padding: "30px 40px",
lineHeight: '1.8', lineHeight: "1.8",
maxWidth: '900px', maxWidth: "900px",
margin: '0 auto', margin: "0 auto",
caretColor: 'var(--primary-color)' caretColor: "var(--primary-color)",
}, },
// ... monospaceFont // ... monospaceFont
'.cm-selectionBackground, ::selection': { ".cm-selectionBackground, ::selection": {
backgroundColor: 'var(--primary-color-bg)' backgroundColor: "var(--primary-color-bg)",
}, },
'&.cm-focused .cm-cursor': { "&.cm-focused .cm-cursor": {
borderLeftColor: 'var(--primary-color)' borderLeftColor: "var(--primary-color)",
}, },
'.cm-gutters': { ".cm-gutters": {
display: 'none' display: "none",
}, },
'.cm-activeLine': { ".cm-activeLine": {
backgroundColor: 'transparent' backgroundColor: "transparent",
}, },
'.cm-activeLineGutter': { ".cm-activeLineGutter": {
backgroundColor: 'transparent' backgroundColor: "transparent",
}, },
// Markdown // Markdown
'.cm-md-syntax': { ".cm-md-syntax": {
opacity: '0.3', color: "var(--text-color-secondary)",
transition: 'opacity 0.2s',
color: 'var(--text-color-secondary)'
}, },
'.cm-md-hidden-mark': { ".cm-md-hidden-mark": {
opacity: 0, opacity: 0,
fontSize: '0' fontSize: "0",
}, },
'.cm-md-syntax-hidden': { ".cm-md-syntax-hidden": {
display: 'none !important' display: "none !important",
}, },
// //
'.cm-line.cm-md-heading': { ".cm-line.cm-md-heading": {
fontWeight: '600', fontWeight: "600",
lineHeight: '1.4', lineHeight: "1.4",
marginTop: '24px', marginTop: "24px",
marginBottom: '8px', marginBottom: "8px",
color: 'var(--text-color)' color: "var(--text-color)",
}, },
'.cm-line.cm-md-h1': { ".cm-line.cm-md-h1": {
fontSize: '2.2em', fontSize: "2.2em",
borderBottom: '1px solid var(--border-color)', borderBottom: "1px solid var(--border-color)",
paddingBottom: '0.3em' paddingBottom: "0.3em",
}, },
'.cm-line.cm-md-h2': { ".cm-line.cm-md-h2": {
fontSize: '1.75em', fontSize: "1.75em",
borderBottom: '1px solid var(--border-color)', borderBottom: "1px solid var(--border-color)",
paddingBottom: '0.3em' paddingBottom: "0.3em",
}, },
'.cm-line.cm-md-h3': { fontSize: '1.4em' }, ".cm-line.cm-md-h3": { fontSize: "1.4em" },
'.cm-line.cm-md-h4': { fontSize: '1.2em' }, ".cm-line.cm-md-h4": { fontSize: "1.2em" },
'.cm-line.cm-md-h5': { fontSize: '1.1em' }, ".cm-line.cm-md-h5": { fontSize: "1.1em" },
'.cm-line.cm-md-h6': { fontSize: '1em', color: 'var(--text-color-secondary)' }, ".cm-line.cm-md-h6": { fontSize: "1em", color: "var(--text-color-secondary)" },
// //
'.cm-line.cm-md-blockquote': { ".cm-line.cm-md-blockquote": {
color: 'var(--text-color-secondary)', color: "var(--text-color-secondary)",
borderLeft: '4px solid var(--border-color)', borderLeft: "4px solid var(--border-color)",
paddingLeft: '16px', paddingLeft: "16px",
margin: '4px 0' margin: "4px 0",
}, },
// //
'.cm-line.cm-md-list': { ".cm-line.cm-md-list": {
paddingLeft: '4px' paddingLeft: "4px",
}, },
'.cm-line.cm-md-task': { ".cm-line.cm-md-task": {
paddingLeft: '4px' paddingLeft: "4px",
}, },
// //
'.cm-md-monospace': { ".cm-md-monospace": {
fontFamily: 'var(--editor-font-family)', fontFamily: "var(--editor-font-family)",
backgroundColor: 'var(--hover-background)', backgroundColor: "var(--hover-background)",
padding: '2px 4px', padding: "2px 4px",
borderRadius: '4px', borderRadius: "4px",
fontSize: '0.9em' fontSize: "0.9em",
}, },
// //
'.cm-line.cm-md-fenced-code': { ".cm-line.cm-md-fenced-code": {
fontFamily: 'var(--editor-font-family)', fontFamily: "var(--editor-font-family)",
backgroundColor: 'var(--hover-background)', backgroundColor: "var(--hover-background)",
paddingLeft: '16px', paddingLeft: "16px",
fontSize: '0.9em' fontSize: "0.9em",
}, },
// //
'.cm-line.cm-md-fenced-code.cm-active': { ".cm-line.cm-md-fenced-code.cm-active": {
backgroundColor: 'var(--hover-background)', backgroundColor: "var(--hover-background)",
outline: '1px solid var(--border-color)' outline: "1px solid var(--border-color)",
}, },
'.cm-md-code-info': { ".cm-md-code-info": {
opacity: 0.5, opacity: 0.5,
fontSize: '0.85em', fontSize: "0.85em",
fontFamily: 'var(--app-font-family)', fontFamily: "var(--app-font-family)",
float: 'right', float: "right",
marginRight: '8px' marginRight: "8px",
}, },
// (Widget) // (Widget)
'.cm-md-table-preview': { ".cm-md-table-preview": {
display: 'block', display: "block",
width: '100%', width: "100%",
overflow: 'auto', overflow: "auto",
marginBottom: '16px' marginBottom: "16px",
}, },
'.cm-md-table-preview table': { ".cm-md-table-preview table": {
width: '100%', width: "100%",
borderSpacing: 0, borderSpacing: 0,
borderCollapse: 'collapse' borderCollapse: "collapse",
}, },
'.cm-md-table-preview th, .cm-md-table-preview td': { ".cm-md-table-preview th, .cm-md-table-preview td": {
padding: '6px 13px', padding: "6px 13px",
border: '1px solid var(--border-color)' border: "1px solid var(--border-color)",
}, },
'.cm-md-table-preview tr:nth-child(2n)': { ".cm-md-table-preview tr:nth-child(2n)": {
backgroundColor: 'var(--hover-background)' backgroundColor: "var(--hover-background)",
}, },
// //
'.cm-line.cm-md-table-source': { ".cm-line.cm-md-table-source": {
fontFamily: 'var(--editor-font-family)', fontFamily: "var(--editor-font-family)",
whiteSpace: 'pre' whiteSpace: "pre",
}, },
// 线 // 线
'.cm-md-hr-preview': { ".cm-md-hr-preview": {
border: 'none', border: "none",
borderBottom: '2px solid var(--border-color)', borderBottom: "2px solid var(--border-color)",
margin: '24px 0' margin: "24px 0",
}, },
// //
'.cm-md-image-preview': { ".cm-md-image-preview": {
maxWidth: '100%', maxWidth: "100%",
maxHeight: '400px', maxHeight: "400px",
display: 'block', display: "block",
margin: '8px auto', margin: "8px auto",
borderRadius: '4px', borderRadius: "4px",
cursor: 'default' cursor: "default",
}, },
// //
'.cm-md-link-text': { ".cm-md-link-text": {
color: 'var(--primary-color)', color: "var(--primary-color)",
textDecoration: 'underline', textDecoration: "underline",
cursor: 'pointer' cursor: "pointer",
}, },
// //
'.cm-md-task-checkbox': { ".cm-md-task-checkbox": {
display: 'inline-block', display: "inline-block",
marginRight: '8px', marginRight: "8px",
verticalAlign: 'middle', verticalAlign: "middle",
cursor: 'default' cursor: "default",
}, },
// 线 // 线
'.cm-md-strikethrough': { ".cm-md-strikethrough": {
textDecoration: 'line-through', textDecoration: "line-through",
color: 'var(--text-color-secondary)' color: "var(--text-color-secondary)",
} },
}, { dark: isDark.value }) },
}) { dark: isDark.value },
);
});
const extensions = computed(() => { const extensions = computed(() => {
return [ return [markdownExtension, liveMarkdownDecorations(props.filePath), EditorView.lineWrapping, liveEditorTheme.value];
markdownExtension, });
liveMarkdownDecorations(props.filePath),
EditorView.lineWrapping,
liveEditorTheme.value
]
})
watch(() => props.modelValue, (val) => { watch(
if (val !== code.value) code.value = val || '' () => props.modelValue,
}) (val) => {
if (val !== code.value) code.value = val || "";
},
);
const handleChange = (val) => { const handleChange = (val) => {
emit('update:modelValue', val) emit("update:modelValue", val);
} };
const handleReady = (payload) => { const handleReady = (payload) => {
editorView.value = payload.view editorView.value = payload.view;
if (typeof detachPaste === 'function') detachPaste() if (typeof detachPaste === "function") detachPaste();
detachPaste = attachImagePaste({ detachPaste = attachImagePaste({
view: payload.view, view: payload.view,
filePath: props.filePath, filePath: props.filePath,
onDocChange: (newDoc) => { onDocChange: (newDoc) => {
code.value = newDoc code.value = newDoc;
emit('update:modelValue', newDoc) emit("update:modelValue", newDoc);
} },
}) });
// //
if (removeActionListener) removeActionListener() if (removeActionListener) removeActionListener();
removeActionListener = onEditorAction((action, payload) => { removeActionListener = onEditorAction((action, payload) => {
// Tab // Tab
if (activeTab.value?.filePath !== props.filePath || !editorView.value) return if (activeTab.value?.filePath !== props.filePath || !editorView.value) return;
const view = editorView.value const view = editorView.value;
executeAction(view, action, payload) executeAction(view, action, payload);
}) });
} };
const handleToolbarAction = (action, payload) => { const handleToolbarAction = (action, payload) => {
if (!editorView.value) return if (!editorView.value) return;
executeAction(editorView.value, action, payload) executeAction(editorView.value, action, payload);
} };
const executeAction = (view, action, payload) => { const executeAction = (view, action, payload) => {
switch (action) { switch (action) {
case 'heading': applyHeading(view, payload); break; case "heading":
case 'bold': wrapSelection(view, '**', '**'); break; applyHeading(view, payload);
case 'italic': wrapSelection(view, '*', '*'); break; break;
case 'strike': wrapSelection(view, '~~', '~~'); break; case "bold":
case 'inlineCode': wrapSelection(view, '`', '`'); break; wrapSelection(view, "**", "**");
case 'quote': prefixLines(view, () => '> '); break; break;
case 'unorderedList': prefixLines(view, () => '- '); break; case "italic":
case 'orderedList': prefixLines(view, (i) => `${i + 1}. `); break; wrapSelection(view, "*", "*");
case 'link': insertLink(view); break; break;
case "strike":
wrapSelection(view, "~~", "~~");
break;
case "inlineCode":
wrapSelection(view, "`", "`");
break;
case "quote":
prefixLines(view, () => "> ");
break;
case "unorderedList":
prefixLines(view, () => "- ");
break;
case "orderedList":
prefixLines(view, (i) => `${i + 1}. `);
break;
case "link":
insertLink(view);
break;
} }
} };
onUnmounted(() => { onUnmounted(() => {
if (typeof detachPaste === 'function') detachPaste() if (typeof detachPaste === "function") detachPaste();
if (removeActionListener) removeActionListener() if (removeActionListener) removeActionListener();
}) });
</script> </script>
<style scoped> <style scoped>
@ -308,4 +314,12 @@ onUnmounted(() => {
:deep(.ͼ7) { :deep(.ͼ7) {
text-decoration: none; text-decoration: none;
} }
:deep(.ͼc) {
color: var(--primary-color);
}
:deep(.ͼe) {
color: var(--primary-color);
}
</style> </style>

View File

@ -10,10 +10,10 @@
--border-color: #f0f0f0; --border-color: #f0f0f0;
--hover-background: rgba(0, 0, 0, 0.04); --hover-background: rgba(0, 0, 0, 0.04);
--scrollbar-thumb: rgba(0, 0, 0, 0.28); --scrollbar-thumb: rgba(0, 0, 0, 0.28);
--app-font-size: 15px; --app-font-size: 16px;
--editor-font-size: 15px; --editor-font-size: 17px;
--preview-font-size: 15px; --preview-font-size: 16px;
--preview-code-font-size: 15px; --preview-code-font-size: 16px;
} }
html, html,