diff --git a/src/codemirror/liveMarkdownDecorations.js b/src/codemirror/liveMarkdownDecorations.js index f4feeee..45f019a 100644 --- a/src/codemirror/liveMarkdownDecorations.js +++ b/src/codemirror/liveMarkdownDecorations.js @@ -1,6 +1,41 @@ import { RangeSetBuilder } from '@codemirror/state' import { Decoration, ViewPlugin, WidgetType } from '@codemirror/view' import { syntaxTree } from '@codemirror/language' +import MarkdownIt from 'markdown-it' + +/** + * 帮助函数:判断是否为绝对路径或特殊 URL + */ +const isAbsoluteOrSpecialUrl = (rawUrl) => { + if (!rawUrl) return true + if (rawUrl.startsWith('#')) return true + if (rawUrl.startsWith('//')) return true + return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(rawUrl) +} + +/** + * 帮助函数:解析文件 URL + */ +const resolveToFileUrl = (rawUrl, baseDirFileUrl) => { + if (!rawUrl || !baseDirFileUrl) return rawUrl || '' + if (isAbsoluteOrSpecialUrl(rawUrl)) return rawUrl + try { + return new URL(rawUrl, baseDirFileUrl).href + } catch { + return rawUrl + } +} + +/** + * 获取当前文件的基础目录 URL + */ +const getBaseDirFileUrl = (filePath) => { + if (!filePath) return '' + const normalized = filePath.replace(/\\/g, '/') + const lastSlashIndex = normalized.lastIndexOf('/') + const dirPath = lastSlashIndex >= 0 ? normalized.slice(0, lastSlashIndex + 1) : normalized + return encodeURI(`file:///${dirPath}`) +} /** * 判断光标是否与给定的范围 [from, to] 重叠 @@ -22,10 +57,112 @@ class HiddenWidget extends WidgetType { } } +/** + * 图片预览 Widget + */ +class ImageWidget extends WidgetType { + constructor(url, alt, title) { + super() + this.url = url + this.alt = alt + this.title = title + } + + eq(other) { + return other.url === this.url && other.alt === this.alt && other.title === this.title + } + + toDOM() { + const img = document.createElement('img') + img.src = this.url + img.alt = this.alt + if (this.title) img.title = this.title + img.className = 'cm-md-image-preview' + return img + } +} + +/** + * 复选框 Widget + */ +class CheckboxWidget extends WidgetType { + constructor(checked) { + super() + this.checked = checked + } + + eq(other) { + return other.checked === this.checked + } + + toDOM() { + const wrap = document.createElement('span') + wrap.className = 'cm-md-task-checkbox' + const input = document.createElement('input') + input.type = 'checkbox' + input.checked = this.checked + // 阻止点击事件传播,避免光标移动或编辑器行为异常 + // 注意:这里仅作展示,暂不支持直接点击修改文档 + input.addEventListener('mousedown', e => e.preventDefault()) + input.addEventListener('click', e => e.preventDefault()) + wrap.appendChild(input) + return wrap + } +} + +/** + * 表格预览 Widget + */ +class TableWidget extends WidgetType { + constructor(markdownText, filePath) { + super() + this.markdownText = markdownText + this.filePath = filePath + } + + eq(other) { + return other.markdownText === this.markdownText && other.filePath === this.filePath + } + + toDOM() { + const div = document.createElement('div') + div.className = 'cm-md-table-preview' + + // 初始化临时的 markdown-it 实例,配置正确的 baseDir + const md = new MarkdownIt({ html: true }) + const env = { baseDirFileUrl: getBaseDirFileUrl(this.filePath) } + + // 复用图片路径解析逻辑 + const defaultImageRule = md.renderer.rules.image + md.renderer.rules.image = (tokens, idx, options, env, self) => { + const token = tokens[idx] + const src = token.attrGet('src') + const resolved = resolveToFileUrl(src, env?.baseDirFileUrl) + if (resolved) token.attrSet('src', resolved) + if (defaultImageRule) return defaultImageRule(tokens, idx, options, env, self) + return self.renderToken(tokens, idx, options) + } + + div.innerHTML = md.render(this.markdownText, env) + return div + } +} + +/** + * 分割线预览 Widget + */ +class HRWidget extends WidgetType { + toDOM() { + const hr = document.createElement('hr') + hr.className = 'cm-md-hr-preview' + return hr + } +} + /** * 遍历语法树并构建 Decorations */ -const buildDecorations = (view) => { +const buildDecorations = (view, filePath) => { const builder = new RangeSetBuilder() const { state } = view const doc = state.doc @@ -40,8 +177,6 @@ const buildDecorations = (view) => { const nodeFrom = node.from const nodeTo = node.to - // console.log(typeName, nodeFrom, nodeTo, doc.sliceString(nodeFrom, nodeTo)) - // 1. 标题 (ATXHeading) if (typeName.startsWith('ATXHeading')) { const level = parseInt(typeName.replace('ATXHeading', '')) || 1 @@ -52,32 +187,22 @@ const buildDecorations = (view) => { // 1.1 标题标记 (HeaderMark: #, ## ...) if (typeName === 'HeaderMark') { - // 获取 HeaderMark 所在的父节点 (ATXHeading) - // 只有当光标在整个 Heading 范围内时,才显示 # const parent = node.node.parent const parentFrom = parent ? parent.from : nodeFrom const parentTo = parent ? parent.to : nodeTo if (!isCursorInRange(state, parentFrom, parentTo)) { - // 使用 Widget 替换实现彻底隐藏 builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) } else { builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax cm-md-heading-mark' })) } } - // 2. 引用 (Blockquote) - 仅作为块标识 - // Blockquote 节点可能包含多个段落,Lezer 的 Blockquote 结构通常是: - // Blockquote -> QuoteMark, Paragraph ... - // 这里的 QuoteMark 可能每一行都有,也可能只在首行(取决于是否紧凑) - - // 2.1 引用标记 (QuoteMark: >) + // 2. 引用 (Blockquote) if (typeName === 'QuoteMark') { const line = doc.lineAt(nodeFrom) - // 给该行添加引用样式 builder.add(line.from, line.from, Decoration.line({ class: 'cm-md-blockquote' })) - // 判断光标是否在这一行 if (!isCursorInRange(state, line.from, line.to)) { builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) } else { @@ -86,31 +211,33 @@ const buildDecorations = (view) => { } // 3. 列表 (ListItem) - // ListMark: -, *, +, 1. if (typeName === 'ListMark') { const line = doc.lineAt(nodeFrom) - // 判断是否在 Task 列表内 - // Lezer Markdown: ListItem -> ListMark, [Task -> TaskMark, ...] - // 或者 TaskListItem? 需要 Debug 确认,通常 ListItem 包含 ListMark - builder.add(line.from, line.from, Decoration.line({ class: 'cm-md-list' })) - - // 列表标记通常不隐藏,保持缩进结构 builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax cm-md-list-mark' })) } - // 3.1 任务标记 (TaskMark: [ ] or [x]) + // 3.1 任务列表 (Task) if (typeName === 'TaskMark') { const line = doc.lineAt(nodeFrom) - // 叠加 Task 样式 + // 获取标记文本,例如 "[ ]" 或 "[x]" + const markText = doc.sliceString(nodeFrom, nodeTo) + const isChecked = markText.toLowerCase().includes('x') + builder.add(line.from, line.from, Decoration.line({ class: 'cm-md-task' })) - builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax cm-md-task-mark' })) + + // 如果光标不在该行,显示 Checkbox Widget + if (!isCursorInRange(state, line.from, line.to)) { + builder.add(nodeFrom, nodeTo, Decoration.replace({ + widget: new CheckboxWidget(isChecked) + })) + } else { + builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax cm-md-task-mark' })) + } } - // 4. 加粗/斜体 (EmphasisMark: *, _, **, __) - if (typeName === 'EmphasisMark') { - // 找到 Emphasis / StrongEmphasis 父节点 - // Lezer: Emphasis -> EmphasisMark ... EmphasisMark + // 4. 加粗/斜体/删除线 (Emphasis/Strikethrough) + if (typeName === 'EmphasisMark' || typeName === 'StrikethroughMark') { const parent = node.node.parent if (parent && !isCursorInRange(state, parent.from, parent.to)) { builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) @@ -118,14 +245,16 @@ const buildDecorations = (view) => { builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax' })) } } + + // 4.1 删除线内容样式 + if (typeName === 'Strikethrough') { + builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-strikethrough' })) + } // 5. 代码块 (FencedCode) if (typeName === 'FencedCode') { - // 给代码块范围内的所有行添加背景 const startLine = doc.lineAt(nodeFrom) const endLine = doc.lineAt(nodeTo) - - // 判断光标是否在代码块内 const cursorInBlock = isCursorInRange(state, nodeFrom, nodeTo) const blockClass = cursorInBlock ? 'cm-md-fenced-code cm-active' : 'cm-md-fenced-code' @@ -135,18 +264,16 @@ const buildDecorations = (view) => { } } - // 5.1 代码块标记 (CodeMark: ```) + // 5.1 代码块标记 (CodeMark) if (typeName === 'CodeMark') { const parent = node.node.parent if (parent?.name === 'FencedCode') { - // 如果光标不在整个代码块内,隐藏首尾的 ``` if (!isCursorInRange(state, parent.from, parent.to)) { builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) } else { builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax' })) } } else if (parent?.name === 'InlineCode') { - // 行内代码的反引号 if (!isCursorInRange(state, parent.from, parent.to)) { builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) } else { @@ -155,30 +282,90 @@ const buildDecorations = (view) => { } } - // 5.2 代码信息 (CodeInfo: javascript) + // 5.2 代码信息 (CodeInfo) if (typeName === 'CodeInfo') { - const parent = node.node.parent - // 如果光标不在代码块内,可以隐藏语言标识,或者显示得更好看 - // 这里选择始终显示,但样式弱化 builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-code-info' })) } // 6. 链接 (Link) -> LinkMark, URL, LinkTitle... - if (typeName === 'LinkMark') { - const parent = node.node.parent - if (parent && !isCursorInRange(state, parent.from, parent.to)) { - builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) - } else { - builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax' })) + // 结构通常是: Link( [ LinkMark( [ ) ... LinkMark( ] ) LinkMark( ( ) URL ... LinkMark( ) ) ] ) + // 我们希望在非编辑状态下:隐藏所有 LinkMark 和 URL,只显示 Link 文本 + if (typeName === 'Link') { + if (!isCursorInRange(state, nodeFrom, nodeTo)) { + // 遍历 Link 内部子节点 + let cursor = node.node.cursor() + if (cursor.firstChild()) { + do { + const subType = cursor.type.name + // 隐藏 [ ] ( ) 和 URL + if (subType === 'LinkMark' || subType === 'URL' || subType === 'LinkTitle') { + builder.add(cursor.from, cursor.to, Decoration.replace({ widget: new HiddenWidget() })) + } + } while (cursor.nextSibling()) + } + // 给整个 Link 范围添加链接样式 + builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-link-text' })) } } + // 如果光标在 Link 内,显示源码,LinkMark 等不需要处理(默认显示) + // 之前的 LinkMark 隐藏逻辑需要调整,避免冲突 + // 为了简单起见,如果 typeName === 'Link' 已经处理了隐藏,下面针对 LinkMark/URL 的独立逻辑需要加条件 - // URL 隐藏 - if (typeName === 'URL') { - const parent = node.node.parent - if (parent && parent.name === 'Link' && !isCursorInRange(state, parent.from, parent.to)) { - // 隐藏 URL - builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) + // 7. 图片 (Image) + if (typeName === 'Image') { + if (!isCursorInRange(state, nodeFrom, nodeTo)) { + // 解析图片 URL + let url = '' + let alt = '' + let title = '' + + // 简单的正则提取,比遍历子节点快 + const text = doc.sliceString(nodeFrom, nodeTo) + const match = /!\[(.*?)\]\((.*?)(?:\s+"(.*?)")?\)/.exec(text) + if (match) { + alt = match[1] + url = match[2] + title = match[3] + } + + if (url) { + const baseDirFileUrl = getBaseDirFileUrl(filePath) + const resolvedUrl = resolveToFileUrl(url, baseDirFileUrl) + builder.add(nodeFrom, nodeTo, Decoration.replace({ + widget: new ImageWidget(resolvedUrl, alt, title), + block: false // 行内图片 + })) + } + } + } + + // 8. 表格 (Table) + if (typeName === 'Table') { + if (!isCursorInRange(state, nodeFrom, nodeTo)) { + const markdownText = doc.sliceString(nodeFrom, nodeTo) + builder.add(nodeFrom, nodeTo, Decoration.replace({ + widget: new TableWidget(markdownText, filePath), + block: true + })) + } else { + const startLine = doc.lineAt(nodeFrom) + const endLine = doc.lineAt(nodeTo) + for (let l = startLine.number; l <= endLine.number; l++) { + const lineInfo = doc.line(l) + builder.add(lineInfo.from, lineInfo.from, Decoration.line({ class: 'cm-md-table-source' })) + } + } + } + + // 9. 分割线 (HorizontalRule) + if (typeName === 'HorizontalRule') { + if (!isCursorInRange(state, nodeFrom, nodeTo)) { + builder.add(nodeFrom, nodeTo, Decoration.replace({ + widget: new HRWidget(), + block: true + })) + } else { + builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax' })) } } } @@ -188,16 +375,16 @@ const buildDecorations = (view) => { return builder.finish() } -export const liveMarkdownDecorations = () => { +export const liveMarkdownDecorations = (filePath) => { return ViewPlugin.fromClass( class { constructor(view) { - this.decorations = buildDecorations(view) + this.decorations = buildDecorations(view, filePath) } update(update) { if (update.docChanged || update.viewportChanged || update.selectionSet) { - this.decorations = buildDecorations(update.view) + this.decorations = buildDecorations(update.view, filePath) } } }, diff --git a/src/components/Editor.vue b/src/components/Editor.vue index 24526d8..7ffeda3 100644 --- a/src/components/Editor.vue +++ b/src/components/Editor.vue @@ -20,6 +20,9 @@ import { oneDark } from '@codemirror/theme-one-dark' import { EditorView } from "@codemirror/view" import { useTheme } from '../composables/useTheme' import { attachImagePaste } from '../codemirror/imagePaste' +import { useEditor } from '../composables/useEditor' +import { useMarkdownActions } from '../composables/useMarkdownActions' +import { useTabs } from '../composables/useTabs' const props = defineProps({ modelValue: String, @@ -30,6 +33,10 @@ const emit = defineEmits(['update:modelValue']) const code = ref(props.modelValue || '') const { isDark } = useTheme() +const { onEditorAction } = useEditor() +const { activeTab } = useTabs() +const { applyHeading, wrapSelection, prefixLines, insertLink } = useMarkdownActions() + const markdownExtension = markdown() const editorTheme = EditorView.theme({ '&': { @@ -68,13 +75,19 @@ const handleChange = (val) => { emit('update:modelValue', val) } +const editorView = ref(null) let detachPaste = null +let removeActionListener = null + onUnmounted(() => { if (typeof detachPaste === 'function') detachPaste() + if (removeActionListener) removeActionListener() }) const handleReady = (payload) => { const view = payload.view; + editorView.value = view + if (typeof detachPaste === 'function') detachPaste() detachPaste = attachImagePaste({ view, @@ -84,5 +97,27 @@ const handleReady = (payload) => { emit('update:modelValue', newDoc) } }) + + // 注册事件监听 + if (removeActionListener) removeActionListener() + removeActionListener = onEditorAction((action, payload) => { + // 只有当自己是当前激活的 Tab 且编辑器实例存在且可见时才响应 + if (activeTab.value?.filePath !== props.filePath || !editorView.value) return + // 简单检查可见性 + if (editorView.value.dom.offsetParent === null) return + + const v = editorView.value + switch (action) { + case 'heading': applyHeading(v, payload); break; + case 'bold': wrapSelection(v, '**', '**'); break; + case 'italic': wrapSelection(v, '*', '*'); break; + case 'strike': wrapSelection(v, '~~', '~~'); break; + case 'inlineCode': wrapSelection(v, '`', '`'); break; + case 'quote': prefixLines(v, () => '> '); break; + case 'unorderedList': prefixLines(v, () => '- '); break; + case 'orderedList': prefixLines(v, (i) => `${i + 1}. `); break; + case 'link': insertLink(v); break; + } + }) } diff --git a/src/components/FileTree.vue b/src/components/FileTree.vue index 81eea73..18aef40 100644 --- a/src/components/FileTree.vue +++ b/src/components/FileTree.vue @@ -1,7 +1,17 @@ diff --git a/src/components/TabBar.vue b/src/components/TabBar.vue index dea4107..1994e74 100644 --- a/src/components/TabBar.vue +++ b/src/components/TabBar.vue @@ -1,25 +1,42 @@ @@ -27,11 +44,8 @@ import { HomeOutlined, CloseOutlined } from '@ant-design/icons-vue'; import { useTabs } from '../composables/useTabs'; -const { state, activateTab, closeTab } = useTabs(); +const { state, activateTab, closeTab, closeOtherTabs, closeAllTabs } = useTabs(); -const onContextMenu = (e, tab) => { - // 可以在这里添加右键菜单:关闭其他、关闭右侧等 -};