feat(editor): 添加事件总线以支持编辑器操作和标签页管理

- 在 useEditor 中实现事件总线,允许跨组件触发和监听编辑器操作
- 为 useTabs 添加 closeOtherTabs 和 closeAllTabs 方法,支持标签页批量操作
- 重构 TabBar 组件,使用下拉菜单实现右键标签页管理功能
- 创建 useMarkdownActions 组合式函数,集中处理编辑器格式操作
- 重构 LivePreviewEditor,移除内置工具栏,改为通过事件总线响应操作
- 为 FileTree 添加文件搜索功能,支持关键词过滤和自动展开
- 增强 liveMarkdownDecorations,支持图片、表格、任务列表等元素的实时预览
This commit is contained in:
cfq 2026-01-26 17:58:57 +08:00
parent 52ffdfb322
commit 738f320e3d
9 changed files with 660 additions and 273 deletions

View File

@ -1,6 +1,41 @@
import { RangeSetBuilder } from '@codemirror/state' import { RangeSetBuilder } from '@codemirror/state'
import { Decoration, ViewPlugin, WidgetType } from '@codemirror/view' import { Decoration, ViewPlugin, WidgetType } from '@codemirror/view'
import { syntaxTree } from '@codemirror/language' 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] 重叠 * 判断光标是否与给定的范围 [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 * 遍历语法树并构建 Decorations
*/ */
const buildDecorations = (view) => { const buildDecorations = (view, filePath) => {
const builder = new RangeSetBuilder() const builder = new RangeSetBuilder()
const { state } = view const { state } = view
const doc = state.doc const doc = state.doc
@ -40,8 +177,6 @@ const buildDecorations = (view) => {
const nodeFrom = node.from const nodeFrom = node.from
const nodeTo = node.to const nodeTo = node.to
// console.log(typeName, nodeFrom, nodeTo, doc.sliceString(nodeFrom, nodeTo))
// 1. 标题 (ATXHeading) // 1. 标题 (ATXHeading)
if (typeName.startsWith('ATXHeading')) { if (typeName.startsWith('ATXHeading')) {
const level = parseInt(typeName.replace('ATXHeading', '')) || 1 const level = parseInt(typeName.replace('ATXHeading', '')) || 1
@ -52,32 +187,22 @@ const buildDecorations = (view) => {
// 1.1 标题标记 (HeaderMark: #, ## ...) // 1.1 标题标记 (HeaderMark: #, ## ...)
if (typeName === 'HeaderMark') { if (typeName === 'HeaderMark') {
// 获取 HeaderMark 所在的父节点 (ATXHeading)
// 只有当光标在整个 Heading 范围内时,才显示 #
const parent = node.node.parent const parent = node.node.parent
const parentFrom = parent ? parent.from : nodeFrom const parentFrom = parent ? parent.from : nodeFrom
const parentTo = parent ? parent.to : nodeTo const parentTo = parent ? parent.to : nodeTo
if (!isCursorInRange(state, parentFrom, parentTo)) { if (!isCursorInRange(state, parentFrom, parentTo)) {
// 使用 Widget 替换实现彻底隐藏
builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() }))
} else { } else {
builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax cm-md-heading-mark' })) builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax cm-md-heading-mark' }))
} }
} }
// 2. 引用 (Blockquote) - 仅作为块标识 // 2. 引用 (Blockquote)
// Blockquote 节点可能包含多个段落Lezer 的 Blockquote 结构通常是:
// Blockquote -> QuoteMark, Paragraph ...
// 这里的 QuoteMark 可能每一行都有,也可能只在首行(取决于是否紧凑)
// 2.1 引用标记 (QuoteMark: >)
if (typeName === 'QuoteMark') { if (typeName === 'QuoteMark') {
const line = doc.lineAt(nodeFrom) const line = doc.lineAt(nodeFrom)
// 给该行添加引用样式
builder.add(line.from, line.from, Decoration.line({ class: 'cm-md-blockquote' })) builder.add(line.from, line.from, Decoration.line({ class: 'cm-md-blockquote' }))
// 判断光标是否在这一行
if (!isCursorInRange(state, line.from, line.to)) { if (!isCursorInRange(state, line.from, line.to)) {
builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() }))
} else { } else {
@ -86,31 +211,33 @@ const buildDecorations = (view) => {
} }
// 3. 列表 (ListItem) // 3. 列表 (ListItem)
// ListMark: -, *, +, 1.
if (typeName === 'ListMark') { if (typeName === 'ListMark') {
const line = doc.lineAt(nodeFrom) 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(line.from, line.from, Decoration.line({ class: 'cm-md-list' }))
// 列表标记通常不隐藏,保持缩进结构
builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax cm-md-list-mark' })) 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') { if (typeName === 'TaskMark') {
const line = doc.lineAt(nodeFrom) 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(line.from, line.from, Decoration.line({ class: 'cm-md-task' }))
// 如果光标不在该行,显示 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' })) builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax cm-md-task-mark' }))
} }
}
// 4. 加粗/斜体 (EmphasisMark: *, _, **, __) // 4. 加粗/斜体/删除线 (Emphasis/Strikethrough)
if (typeName === 'EmphasisMark') { if (typeName === 'EmphasisMark' || typeName === 'StrikethroughMark') {
// 找到 Emphasis / StrongEmphasis 父节点
// Lezer: Emphasis -> EmphasisMark ... EmphasisMark
const parent = node.node.parent const parent = node.node.parent
if (parent && !isCursorInRange(state, parent.from, parent.to)) { if (parent && !isCursorInRange(state, parent.from, parent.to)) {
builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() }))
@ -119,13 +246,15 @@ const buildDecorations = (view) => {
} }
} }
// 4.1 删除线内容样式
if (typeName === 'Strikethrough') {
builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-strikethrough' }))
}
// 5. 代码块 (FencedCode) // 5. 代码块 (FencedCode)
if (typeName === 'FencedCode') { if (typeName === 'FencedCode') {
// 给代码块范围内的所有行添加背景
const startLine = doc.lineAt(nodeFrom) const startLine = doc.lineAt(nodeFrom)
const endLine = doc.lineAt(nodeTo) const endLine = doc.lineAt(nodeTo)
// 判断光标是否在代码块内
const cursorInBlock = isCursorInRange(state, nodeFrom, nodeTo) const cursorInBlock = isCursorInRange(state, nodeFrom, nodeTo)
const blockClass = cursorInBlock ? 'cm-md-fenced-code cm-active' : 'cm-md-fenced-code' 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') { if (typeName === 'CodeMark') {
const parent = node.node.parent const parent = node.node.parent
if (parent?.name === 'FencedCode') { if (parent?.name === 'FencedCode') {
// 如果光标不在整个代码块内,隐藏首尾的 ```
if (!isCursorInRange(state, parent.from, parent.to)) { if (!isCursorInRange(state, parent.from, parent.to)) {
builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() }))
} else { } else {
builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax' })) builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax' }))
} }
} else if (parent?.name === 'InlineCode') { } else if (parent?.name === 'InlineCode') {
// 行内代码的反引号
if (!isCursorInRange(state, parent.from, parent.to)) { if (!isCursorInRange(state, parent.from, parent.to)) {
builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() }))
} else { } else {
@ -155,30 +282,90 @@ const buildDecorations = (view) => {
} }
} }
// 5.2 代码信息 (CodeInfo: javascript) // 5.2 代码信息 (CodeInfo)
if (typeName === 'CodeInfo') { if (typeName === 'CodeInfo') {
const parent = node.node.parent
// 如果光标不在代码块内,可以隐藏语言标识,或者显示得更好看
// 这里选择始终显示,但样式弱化
builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-code-info' })) builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-code-info' }))
} }
// 6. 链接 (Link) -> LinkMark, URL, LinkTitle... // 6. 链接 (Link) -> LinkMark, URL, LinkTitle...
if (typeName === 'LinkMark') { // 结构通常是: Link( [ LinkMark( [ ) ... LinkMark( ] ) LinkMark( ( ) URL ... LinkMark( ) ) ] )
const parent = node.node.parent // 我们希望在非编辑状态下:隐藏所有 LinkMark 和 URL只显示 Link 文本
if (parent && !isCursorInRange(state, parent.from, parent.to)) { if (typeName === 'Link') {
builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) if (!isCursorInRange(state, nodeFrom, nodeTo)) {
} else { // 遍历 Link 内部子节点
builder.add(nodeFrom, nodeTo, Decoration.mark({ class: 'cm-md-syntax' })) 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 的独立逻辑需要加条件
// 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 // 行内图片
}))
}
} }
} }
// URL 隐藏 // 8. 表格 (Table)
if (typeName === 'URL') { if (typeName === 'Table') {
const parent = node.node.parent if (!isCursorInRange(state, nodeFrom, nodeTo)) {
if (parent && parent.name === 'Link' && !isCursorInRange(state, parent.from, parent.to)) { const markdownText = doc.sliceString(nodeFrom, nodeTo)
// 隐藏 URL builder.add(nodeFrom, nodeTo, Decoration.replace({
builder.add(nodeFrom, nodeTo, Decoration.replace({ widget: new HiddenWidget() })) 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() return builder.finish()
} }
export const liveMarkdownDecorations = () => { export const liveMarkdownDecorations = (filePath) => {
return ViewPlugin.fromClass( return ViewPlugin.fromClass(
class { class {
constructor(view) { constructor(view) {
this.decorations = buildDecorations(view) this.decorations = buildDecorations(view, filePath)
} }
update(update) { update(update) {
if (update.docChanged || update.viewportChanged || update.selectionSet) { if (update.docChanged || update.viewportChanged || update.selectionSet) {
this.decorations = buildDecorations(update.view) this.decorations = buildDecorations(update.view, filePath)
} }
} }
}, },

View File

@ -20,6 +20,9 @@ import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView } from "@codemirror/view" import { EditorView } from "@codemirror/view"
import { useTheme } from '../composables/useTheme' import { useTheme } from '../composables/useTheme'
import { attachImagePaste } from '../codemirror/imagePaste' import { attachImagePaste } from '../codemirror/imagePaste'
import { useEditor } from '../composables/useEditor'
import { useMarkdownActions } from '../composables/useMarkdownActions'
import { useTabs } from '../composables/useTabs'
const props = defineProps({ const props = defineProps({
modelValue: String, modelValue: String,
@ -30,6 +33,10 @@ 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 { activeTab } = useTabs()
const { applyHeading, wrapSelection, prefixLines, insertLink } = useMarkdownActions()
const markdownExtension = markdown() const markdownExtension = markdown()
const editorTheme = EditorView.theme({ const editorTheme = EditorView.theme({
'&': { '&': {
@ -68,13 +75,19 @@ const handleChange = (val) => {
emit('update:modelValue', val) emit('update:modelValue', val)
} }
const editorView = ref(null)
let detachPaste = null let detachPaste = null
let removeActionListener = null
onUnmounted(() => { onUnmounted(() => {
if (typeof detachPaste === 'function') detachPaste() if (typeof detachPaste === 'function') detachPaste()
if (removeActionListener) removeActionListener()
}) })
const handleReady = (payload) => { const handleReady = (payload) => {
const view = payload.view; const view = payload.view;
editorView.value = view
if (typeof detachPaste === 'function') detachPaste() if (typeof detachPaste === 'function') detachPaste()
detachPaste = attachImagePaste({ detachPaste = attachImagePaste({
view, view,
@ -84,5 +97,27 @@ const handleReady = (payload) => {
emit('update:modelValue', newDoc) 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;
}
})
} }
</script> </script>

View File

@ -1,7 +1,17 @@
<template> <template>
<div class="file-tree-container"> <div class="file-tree-container">
<div class="tree-header"> <div class="tree-header">
<span class="title">资源管理器</span> <a-input
v-model:value="searchKeyword"
placeholder="搜索文件..."
size="small"
allow-clear
class="search-input"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<div class="header-actions"> <div class="header-actions">
<a-button type="text" size="small" @click="handleRefresh" title="刷新"> <a-button type="text" size="small" @click="handleRefresh" title="刷新">
<template #icon><ReloadOutlined /></template> <template #icon><ReloadOutlined /></template>
@ -79,8 +89,8 @@
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref, watch } from 'vue';
import { EyeInvisibleOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons-vue'; import { EyeInvisibleOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue';
import { useFileTree } from '../composables/useFileTree'; import { useFileTree } from '../composables/useFileTree';
import { useTabs } from '../composables/useTabs'; import { useTabs } from '../composables/useTabs';
import { useConfig } from '../composables/useConfig'; import { useConfig } from '../composables/useConfig';
@ -96,6 +106,7 @@ const modalInputValue = ref('');
const modalPlaceholder = ref(''); const modalPlaceholder = ref('');
const currentAction = ref(null); const currentAction = ref(null);
const currentNode = ref(null); const currentNode = ref(null);
const searchKeyword = ref('');
// nodes treeData isLeaf // nodes treeData isLeaf
const transformNodes = (nodes) => { const transformNodes = (nodes) => {
@ -117,21 +128,91 @@ const isHiddenItemName = (name) => {
const filterNodes = (nodes) => { const filterNodes = (nodes) => {
if (!Array.isArray(nodes) || nodes.length === 0) return []; if (!Array.isArray(nodes) || nodes.length === 0) return [];
if (showHiddenItems.value) {
return nodes.map(node => ({ // 1.
...node, let filtered = nodes;
children: node.children === undefined ? undefined : filterNodes(node.children) if (!showHiddenItems.value) {
})); filtered = nodes.filter(node => !isHiddenItemName(node?.name));
} }
return nodes
.filter(node => !isHiddenItemName(node?.name)) //
.map(node => ({ return filtered.map(node => ({
...node, ...node,
children: node.children === undefined ? undefined : filterNodes(node.children) children: node.children === undefined ? undefined : filterNodes(node.children)
})); }));
}; };
const treeData = computed(() => transformNodes(filterNodes(state.nodes))); const searchFilterNodes = (nodes, keyword) => {
if (!Array.isArray(nodes) || nodes.length === 0) return [];
const lowerKeyword = keyword.toLowerCase();
const result = [];
for (const node of nodes) {
const nameMatch = node.name.toLowerCase().includes(lowerKeyword);
//
let filteredChildren = [];
if (node.children) {
filteredChildren = searchFilterNodes(node.children, keyword);
}
//
if (nameMatch || filteredChildren.length > 0) {
result.push({
...node,
children: node.children ? filteredChildren : undefined
});
}
}
return result;
};
//
const getExpandedKeys = (nodes, keyword, result = []) => {
if (!keyword) return result;
const lowerKeyword = keyword.toLowerCase();
for (const node of nodes) {
const nameMatch = node.name.toLowerCase().includes(lowerKeyword);
let hasChildMatch = false;
if (node.children) {
const initialLength = result.length;
getExpandedKeys(node.children, keyword, result);
if (result.length > initialLength) {
hasChildMatch = true;
}
}
//
if (hasChildMatch) {
result.push(node.path);
}
}
return result;
};
const treeData = computed(() => {
// 1.
let nodes = filterNodes(state.nodes);
// 2.
if (searchKeyword.value) {
nodes = searchFilterNodes(nodes, searchKeyword.value);
}
return transformNodes(nodes);
});
//
watch(searchKeyword, (newVal) => {
if (newVal) {
const nodes = filterNodes(state.nodes); //
const keysToExpand = [];
getExpandedKeys(nodes, newVal, keysToExpand);
//
state.expandedKeys = [...new Set([...state.expandedKeys, ...keysToExpand])];
}
});
const toggleShowHiddenItems = async () => { const toggleShowHiddenItems = async () => {
if (!state.rootPath) return; if (!state.rootPath) return;
@ -369,8 +450,9 @@ const handleModalOk = async () => {
gap: 4px; gap: 4px;
} }
.title { .search-input {
font-weight: 600; flex: 1;
margin-right: 8px;
} }
.tree-content { .tree-content {

View File

@ -1,39 +1,5 @@
<template> <template>
<div class="live-editor"> <div class="live-editor">
<div class="live-editor-toolbar">
<a-space size="small">
<a-button size="small" type="text" @click="applyHeading(1)" title="H1">H1</a-button>
<a-button size="small" type="text" @click="applyHeading(2)" title="H2">H2</a-button>
<a-button size="small" type="text" @click="applyHeading(3)" title="H3">H3</a-button>
<a-divider type="vertical" />
<a-button size="small" type="text" @click="toggleBold" title="加粗">
<template #icon><BoldOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="toggleItalic" title="斜体">
<template #icon><ItalicOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="toggleStrike" title="删除线">
<template #icon><StrikethroughOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="toggleInlineCode" title="行内代码">
<template #icon><CodeOutlined /></template>
</a-button>
<a-divider type="vertical" />
<a-button size="small" type="text" @click="toggleQuote" title="引用">
<template #icon><MessageOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="toggleUnorderedList" title="无序列表">
<template #icon><UnorderedListOutlined /></template>
</a-button>
<a-button size="small" type="text" @click="toggleOrderedList" title="有序列表">
<template #icon><OrderedListOutlined /></template>
</a-button>
<a-divider type="vertical" />
<a-button size="small" type="text" @click="insertLink" title="链接">
<template #icon><LinkOutlined /></template>
</a-button>
</a-space>
</div>
<codemirror <codemirror
v-model="code" v-model="code"
placeholder="请输入 Markdown 内容..." placeholder="请输入 Markdown 内容..."
@ -53,20 +19,12 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import { Codemirror } from 'vue-codemirror' import { Codemirror } from 'vue-codemirror'
import { markdown } from '@codemirror/lang-markdown' import { markdown } from '@codemirror/lang-markdown'
import { EditorView } from '@codemirror/view' import { EditorView } from '@codemirror/view'
import { EditorSelection } from '@codemirror/state'
import {
BoldOutlined,
CodeOutlined,
ItalicOutlined,
LinkOutlined,
MessageOutlined,
OrderedListOutlined,
StrikethroughOutlined,
UnorderedListOutlined
} from '@ant-design/icons-vue'
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 { useMarkdownActions } from '../composables/useMarkdownActions'
import { useTabs } from '../composables/useTabs'
const props = defineProps({ const props = defineProps({
modelValue: String, modelValue: String,
@ -77,12 +35,16 @@ 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 { activeTab } = useTabs()
const { applyHeading, wrapSelection, prefixLines, insertLink } = useMarkdownActions()
// Markdown
const markdownExtension = markdown() const markdownExtension = markdown()
const editorView = ref(null) const editorView = ref(null)
let detachPaste = null let detachPaste = null
onUnmounted(() => { let removeActionListener = null
if (typeof detachPaste === 'function') detachPaste()
})
const liveEditorTheme = computed(() => { const liveEditorTheme = computed(() => {
const contentFont = '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif' const contentFont = '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif'
@ -190,12 +152,80 @@ const liveEditorTheme = computed(() => {
fontFamily: contentFont, fontFamily: contentFont,
float: 'right', float: 'right',
marginRight: '8px' marginRight: '8px'
},
// (Widget)
'.cm-md-table-preview': {
display: 'block',
width: '100%',
overflow: 'auto',
marginBottom: '16px'
},
'.cm-md-table-preview table': {
width: '100%',
borderSpacing: 0,
borderCollapse: 'collapse'
},
'.cm-md-table-preview th, .cm-md-table-preview td': {
padding: '6px 13px',
border: '1px solid var(--border-color)'
},
'.cm-md-table-preview tr:nth-child(2n)': {
backgroundColor: 'var(--hover-background)'
},
//
'.cm-line.cm-md-table-source': {
fontFamily: monospaceFont,
whiteSpace: 'pre'
},
// 线
'.cm-md-hr-preview': {
border: 'none',
borderBottom: '2px solid var(--border-color)',
margin: '24px 0'
},
//
'.cm-md-image-preview': {
maxWidth: '100%',
maxHeight: '400px',
display: 'block',
margin: '8px auto',
borderRadius: '4px',
cursor: 'default'
},
//
'.cm-md-link-text': {
color: 'var(--primary-color)',
textDecoration: 'underline',
cursor: 'pointer'
},
//
'.cm-md-task-checkbox': {
display: 'inline-block',
marginRight: '8px',
verticalAlign: 'middle',
cursor: 'default'
},
// 线
'.cm-md-strikethrough': {
textDecoration: 'line-through',
color: 'var(--text-color-secondary)'
} }
}, { dark: isDark.value }) }, { dark: isDark.value })
}) })
const extensions = computed(() => { const extensions = computed(() => {
return [markdownExtension, liveMarkdownDecorations(), EditorView.lineWrapping, liveEditorTheme.value] return [
markdownExtension,
liveMarkdownDecorations(props.filePath),
EditorView.lineWrapping,
liveEditorTheme.value
]
}) })
watch(() => props.modelValue, (val) => { watch(() => props.modelValue, (val) => {
@ -206,130 +236,9 @@ const handleChange = (val) => {
emit('update:modelValue', val) emit('update:modelValue', val)
} }
const syncDocToModel = () => {
const view = editorView.value
if (!view) return
const newDoc = view.state.doc.toString()
code.value = newDoc
emit('update:modelValue', newDoc)
}
const focusEditor = () => {
const view = editorView.value
if (!view) return
view.focus()
}
const getSelectedLineNumbers = (state) => {
const main = state.selection.main
const fromLine = state.doc.lineAt(main.from).number
const toLine = state.doc.lineAt(main.to).number
return { fromLine, toLine }
}
const applyHeading = (level) => {
const view = editorView.value
if (!view) return
const { state } = view
const { fromLine, toLine } = getSelectedLineNumbers(state)
const changes = []
const prefix = `${'#'.repeat(level)} `
for (let ln = toLine; ln >= fromLine; ln -= 1) {
const line = state.doc.line(ln)
const match = /^(#{1,6})\s+/.exec(line.text)
if (match) {
const replaceFrom = line.from
const replaceTo = line.from + match[0].length
changes.push({ from: replaceFrom, to: replaceTo, insert: prefix })
} else {
changes.push({ from: line.from, to: line.from, insert: prefix })
}
}
view.dispatch(state.update({ changes }))
syncDocToModel()
focusEditor()
}
const wrapSelection = (left, right) => {
const view = editorView.value
if (!view) return
const { state } = view
const main = state.selection.main
const selectedText = state.doc.sliceString(main.from, main.to)
const insertText = `${left}${selectedText}${right}`
const cursorFrom = main.from + left.length
const cursorTo = cursorFrom + selectedText.length
view.dispatch(
state.update({
changes: { from: main.from, to: main.to, insert: insertText },
selection: EditorSelection.single(cursorFrom, cursorTo)
})
)
syncDocToModel()
focusEditor()
}
const prefixLines = (makePrefix) => {
const view = editorView.value
if (!view) return
const { state } = view
const { fromLine, toLine } = getSelectedLineNumbers(state)
const changes = []
for (let ln = toLine; ln >= fromLine; ln -= 1) {
const line = state.doc.line(ln)
const indentMatch = /^(\s*)/.exec(line.text)
const indentLen = indentMatch ? indentMatch[1].length : 0
const insertAt = line.from + indentLen
const prefix = makePrefix(ln - fromLine)
changes.push({ from: insertAt, to: insertAt, insert: prefix })
}
view.dispatch(state.update({ changes }))
syncDocToModel()
focusEditor()
}
const toggleBold = () => wrapSelection('**', '**')
const toggleItalic = () => wrapSelection('*', '*')
const toggleStrike = () => wrapSelection('~~', '~~')
const toggleInlineCode = () => wrapSelection('`', '`')
const toggleQuote = () => prefixLines(() => '> ')
const toggleUnorderedList = () => prefixLines(() => '- ')
const toggleOrderedList = () => prefixLines((index) => `${index + 1}. `)
const insertLink = () => {
const view = editorView.value
if (!view) return
const { state } = view
const main = state.selection.main
const selectedText = state.doc.sliceString(main.from, main.to)
const textPart = selectedText || '链接文字'
const insertText = `[${textPart}](https://)`
const urlFrom = main.from + 2 + textPart.length
const urlTo = urlFrom + 'https://'.length
view.dispatch(
state.update({
changes: { from: main.from, to: main.to, insert: insertText },
selection: EditorSelection.single(urlFrom, urlTo)
})
)
syncDocToModel()
focusEditor()
}
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,
@ -339,7 +248,32 @@ const handleReady = (payload) => {
emit('update:modelValue', newDoc) emit('update:modelValue', newDoc)
} }
}) })
//
if (removeActionListener) removeActionListener()
removeActionListener = onEditorAction((action, payload) => {
// Tab
if (activeTab.value?.filePath !== props.filePath || !editorView.value) return
const view = editorView.value
switch (action) {
case 'heading': applyHeading(view, payload); break;
case 'bold': wrapSelection(view, '**', '**'); break;
case 'italic': wrapSelection(view, '*', '*'); 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(() => {
if (typeof detachPaste === 'function') detachPaste()
if (removeActionListener) removeActionListener()
})
</script> </script>
<style scoped> <style scoped>
@ -350,14 +284,4 @@ const handleReady = (payload) => {
overflow: hidden; overflow: hidden;
background: var(--card-background); background: var(--card-background);
} }
.live-editor-toolbar {
height: 40px;
display: flex;
align-items: center;
padding: 0 12px;
border-bottom: 1px solid var(--border-color);
background: var(--card-background);
flex-shrink: 0;
}
</style> </style>

View File

@ -1,11 +1,14 @@
<template> <template>
<div class="tab-bar"> <div class="tab-bar">
<div <a-dropdown
v-for="tab in state.tabs" v-for="tab in state.tabs"
:key="tab.id" :key="tab.id"
:trigger="['contextmenu']"
>
<div
:class="['tab-item', { active: tab.id === state.activeTabId }]" :class="['tab-item', { active: tab.id === state.activeTabId }]"
@click="activateTab(tab.id)" @click="activateTab(tab.id)"
@contextmenu.prevent="onContextMenu($event, tab)" @mousedown.middle.prevent="tab.closable && closeTab(tab.id)"
> >
<HomeOutlined v-if="tab.type === 'home'" /> <HomeOutlined v-if="tab.type === 'home'" />
<span v-else class="tab-title">{{ tab.title }}</span> <span v-else class="tab-title">{{ tab.title }}</span>
@ -20,6 +23,20 @@
<CloseOutlined /> <CloseOutlined />
</div> </div>
</div> </div>
<template #overlay>
<a-menu>
<a-menu-item key="close" :disabled="!tab.closable" @click="closeTab(tab.id)">
关闭
</a-menu-item>
<a-menu-item key="closeOther" @click="closeOtherTabs(tab.id)">
保留当前关闭其他
</a-menu-item>
<a-menu-item key="closeAll" @click="closeAllTabs()">
全部关闭
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div> </div>
</template> </template>
@ -27,11 +44,8 @@
import { HomeOutlined, CloseOutlined } from '@ant-design/icons-vue'; import { HomeOutlined, CloseOutlined } from '@ant-design/icons-vue';
import { useTabs } from '../composables/useTabs'; import { useTabs } from '../composables/useTabs';
const { state, activateTab, closeTab } = useTabs(); const { state, activateTab, closeTab, closeOtherTabs, closeAllTabs } = useTabs();
const onContextMenu = (e, tab) => {
//
};
</script> </script>
<style scoped> <style scoped>

View File

@ -63,7 +63,15 @@ import {
GithubOutlined, GithubOutlined,
ExportOutlined, ExportOutlined,
SettingOutlined, SettingOutlined,
HistoryOutlined HistoryOutlined,
BoldOutlined,
ItalicOutlined,
StrikethroughOutlined,
CodeOutlined,
MessageOutlined,
UnorderedListOutlined,
OrderedListOutlined,
LinkOutlined
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
import { useFileTree } from '../composables/useFileTree'; import { useFileTree } from '../composables/useFileTree';
import { useTabs } from '../composables/useTabs'; import { useTabs } from '../composables/useTabs';
@ -72,10 +80,12 @@ import { message } from 'ant-design-vue';
const { state } = useFileTree(); const { state } = useFileTree();
const { activeTab } = useTabs(); const { activeTab } = useTabs();
const { saveCurrentFile } = useEditor(); const { saveCurrentFile, triggerAction } = useEditor();
defineEmits(['select-directory', 'toggle-search', 'toggle-git', 'toggle-settings', 'toggle-history']); defineEmits(['select-directory', 'toggle-search', 'toggle-git', 'toggle-settings', 'toggle-history']);
const isFileTab = computed(() => activeTab.value && activeTab.value.type === 'file');
const canSave = computed(() => { const canSave = computed(() => {
return activeTab.value && activeTab.value.type === 'file' && activeTab.value.isDirty; return activeTab.value && activeTab.value.type === 'file' && activeTab.value.isDirty;
}); });

View File

@ -38,6 +38,20 @@ export function useEditor() {
return { return {
content, content,
saveCurrentFile saveCurrentFile,
triggerAction,
onEditorAction
}; };
} }
// 事件总线
const listeners = new Set();
const triggerAction = (action, payload) => {
listeners.forEach(fn => fn(action, payload));
};
const onEditorAction = (fn) => {
listeners.add(fn);
return () => listeners.delete(fn);
};

View File

@ -0,0 +1,102 @@
import { EditorSelection } from '@codemirror/state'
export function useMarkdownActions() {
const getSelectedLineNumbers = (state) => {
const main = state.selection.main
const fromLine = state.doc.lineAt(main.from).number
const toLine = state.doc.lineAt(main.to).number
return { fromLine, toLine }
}
const applyHeading = (view, level) => {
if (!view) return
const { state } = view
const { fromLine, toLine } = getSelectedLineNumbers(state)
const changes = []
const prefix = `${'#'.repeat(level)} `
for (let ln = toLine; ln >= fromLine; ln -= 1) {
const line = state.doc.line(ln)
const match = /^(#{1,6})\s+/.exec(line.text)
if (match) {
const replaceFrom = line.from
const replaceTo = line.from + match[0].length
changes.push({ from: replaceFrom, to: replaceTo, insert: prefix })
} else {
changes.push({ from: line.from, to: line.from, insert: prefix })
}
}
view.dispatch(state.update({ changes }))
view.focus()
}
const wrapSelection = (view, left, right) => {
if (!view) return
const { state } = view
const main = state.selection.main
const selectedText = state.doc.sliceString(main.from, main.to)
const insertText = `${left}${selectedText}${right}`
const cursorFrom = main.from + left.length
const cursorTo = cursorFrom + selectedText.length
view.dispatch(
state.update({
changes: { from: main.from, to: main.to, insert: insertText },
selection: EditorSelection.single(cursorFrom, cursorTo)
})
)
view.focus()
}
const prefixLines = (view, makePrefix) => {
if (!view) return
const { state } = view
const { fromLine, toLine } = getSelectedLineNumbers(state)
const changes = []
for (let ln = toLine; ln >= fromLine; ln -= 1) {
const line = state.doc.line(ln)
const indentMatch = /^(\s*)/.exec(line.text)
const indentLen = indentMatch ? indentMatch[1].length : 0
const insertAt = line.from + indentLen
const prefix = makePrefix(ln - fromLine)
changes.push({ from: insertAt, to: insertAt, insert: prefix })
}
view.dispatch(state.update({ changes }))
view.focus()
}
const insertLink = (view) => {
if (!view) return
const { state } = view
const main = state.selection.main
const selectedText = state.doc.sliceString(main.from, main.to)
const textPart = selectedText || '链接文字'
const insertText = `[${textPart}](https://)`
const urlFrom = main.from + 2 + textPart.length
const urlTo = urlFrom + 'https://'.length
view.dispatch(
state.update({
changes: { from: main.from, to: main.to, insert: insertText },
selection: EditorSelection.single(urlFrom, urlTo)
})
)
view.focus()
}
return {
applyHeading,
wrapSelection,
prefixLines,
insertLink
}
}

View File

@ -75,6 +75,23 @@ export function useTabs() {
state.tabs.splice(index, 1); state.tabs.splice(index, 1);
}; };
// 关闭其他 Tab
const closeOtherTabs = (keepTabId) => {
state.tabs = state.tabs.filter(t => t.id === keepTabId || !t.closable);
if (state.activeTabId !== keepTabId) {
state.activeTabId = keepTabId;
}
};
// 关闭所有 Tab (除了不可关闭的,如首页)
const closeAllTabs = () => {
state.tabs = state.tabs.filter(t => !t.closable);
// 如果还有剩余的 Tab例如首页激活第一个否则理论上应该有首页保持或重置
if (state.tabs.length > 0) {
state.activeTabId = state.tabs[0].id;
}
};
// 切换 Tab // 切换 Tab
const activateTab = (tabId) => { const activateTab = (tabId) => {
state.activeTabId = tabId; state.activeTabId = tabId;
@ -93,6 +110,8 @@ export function useTabs() {
activeTab, activeTab,
openFile, openFile,
closeTab, closeTab,
closeOtherTabs,
closeAllTabs,
activateTab, activateTab,
updateTab updateTab
}; };