feat(editor): 添加事件总线以支持编辑器操作和标签页管理
- 在 useEditor 中实现事件总线,允许跨组件触发和监听编辑器操作 - 为 useTabs 添加 closeOtherTabs 和 closeAllTabs 方法,支持标签页批量操作 - 重构 TabBar 组件,使用下拉菜单实现右键标签页管理功能 - 创建 useMarkdownActions 组合式函数,集中处理编辑器格式操作 - 重构 LivePreviewEditor,移除内置工具栏,改为通过事件总线响应操作 - 为 FileTree 添加文件搜索功能,支持关键词过滤和自动展开 - 增强 liveMarkdownDecorations,支持图片、表格、任务列表等元素的实时预览
This commit is contained in:
parent
52ffdfb322
commit
738f320e3d
|
|
@ -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() }))
|
||||
|
|
@ -119,13 +246,15 @@ const buildDecorations = (view) => {
|
|||
}
|
||||
}
|
||||
|
||||
// 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 的独立逻辑需要加条件
|
||||
|
||||
// 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 隐藏
|
||||
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() }))
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
<template>
|
||||
<div class="file-tree-container">
|
||||
<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">
|
||||
<a-button type="text" size="small" @click="handleRefresh" title="刷新">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
|
|
@ -79,8 +89,8 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { EyeInvisibleOutlined, EyeOutlined, ReloadOutlined } from '@ant-design/icons-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { EyeInvisibleOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue';
|
||||
import { useFileTree } from '../composables/useFileTree';
|
||||
import { useTabs } from '../composables/useTabs';
|
||||
import { useConfig } from '../composables/useConfig';
|
||||
|
|
@ -96,6 +106,7 @@ const modalInputValue = ref('');
|
|||
const modalPlaceholder = ref('');
|
||||
const currentAction = ref(null);
|
||||
const currentNode = ref(null);
|
||||
const searchKeyword = ref('');
|
||||
|
||||
// 将 nodes 转换为 treeData,主要处理 isLeaf
|
||||
const transformNodes = (nodes) => {
|
||||
|
|
@ -117,21 +128,91 @@ const isHiddenItemName = (name) => {
|
|||
|
||||
const filterNodes = (nodes) => {
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) return [];
|
||||
if (showHiddenItems.value) {
|
||||
return nodes.map(node => ({
|
||||
...node,
|
||||
children: node.children === undefined ? undefined : filterNodes(node.children)
|
||||
}));
|
||||
|
||||
// 1. 处理隐藏文件
|
||||
let filtered = nodes;
|
||||
if (!showHiddenItems.value) {
|
||||
filtered = nodes.filter(node => !isHiddenItemName(node?.name));
|
||||
}
|
||||
return nodes
|
||||
.filter(node => !isHiddenItemName(node?.name))
|
||||
.map(node => ({
|
||||
...node,
|
||||
children: node.children === undefined ? undefined : filterNodes(node.children)
|
||||
}));
|
||||
|
||||
// 递归处理子节点
|
||||
return filtered.map(node => ({
|
||||
...node,
|
||||
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 () => {
|
||||
if (!state.rootPath) return;
|
||||
|
|
@ -369,8 +450,9 @@ const handleModalOk = async () => {
|
|||
gap: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
.search-input {
|
||||
flex: 1;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.tree-content {
|
||||
|
|
|
|||
|
|
@ -1,39 +1,5 @@
|
|||
<template>
|
||||
<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
|
||||
v-model="code"
|
||||
placeholder="请输入 Markdown 内容..."
|
||||
|
|
@ -53,20 +19,12 @@ import { computed, onUnmounted, ref, watch } from 'vue'
|
|||
import { Codemirror } from 'vue-codemirror'
|
||||
import { markdown } from '@codemirror/lang-markdown'
|
||||
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 { liveMarkdownDecorations } from '../codemirror/liveMarkdownDecorations'
|
||||
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,
|
||||
|
|
@ -77,12 +35,16 @@ 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()
|
||||
|
||||
// 配置 Markdown 扩展
|
||||
const markdownExtension = markdown()
|
||||
|
||||
const editorView = ref(null)
|
||||
let detachPaste = null
|
||||
onUnmounted(() => {
|
||||
if (typeof detachPaste === 'function') detachPaste()
|
||||
})
|
||||
let removeActionListener = null
|
||||
|
||||
const liveEditorTheme = computed(() => {
|
||||
const contentFont = '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif'
|
||||
|
|
@ -190,12 +152,80 @@ const liveEditorTheme = computed(() => {
|
|||
fontFamily: contentFont,
|
||||
float: 'right',
|
||||
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 })
|
||||
})
|
||||
|
||||
const extensions = computed(() => {
|
||||
return [markdownExtension, liveMarkdownDecorations(), EditorView.lineWrapping, liveEditorTheme.value]
|
||||
return [
|
||||
markdownExtension,
|
||||
liveMarkdownDecorations(props.filePath),
|
||||
EditorView.lineWrapping,
|
||||
liveEditorTheme.value
|
||||
]
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
|
|
@ -206,130 +236,9 @@ const handleChange = (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) => {
|
||||
editorView.value = payload.view
|
||||
|
||||
if (typeof detachPaste === 'function') detachPaste()
|
||||
detachPaste = attachImagePaste({
|
||||
view: payload.view,
|
||||
|
|
@ -339,7 +248,32 @@ const handleReady = (payload) => {
|
|||
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>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -350,14 +284,4 @@ const handleReady = (payload) => {
|
|||
overflow: hidden;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,42 @@
|
|||
<template>
|
||||
<div class="tab-bar">
|
||||
<div
|
||||
<a-dropdown
|
||||
v-for="tab in state.tabs"
|
||||
:key="tab.id"
|
||||
:class="['tab-item', { active: tab.id === state.activeTabId }]"
|
||||
@click="activateTab(tab.id)"
|
||||
@contextmenu.prevent="onContextMenu($event, tab)"
|
||||
:trigger="['contextmenu']"
|
||||
>
|
||||
<HomeOutlined v-if="tab.type === 'home'" />
|
||||
<span v-else class="tab-title">{{ tab.title }}</span>
|
||||
|
||||
<span v-if="tab.isDirty" class="dirty-dot">●</span>
|
||||
|
||||
<div
|
||||
v-if="tab.closable"
|
||||
class="close-btn"
|
||||
@click.stop="closeTab(tab.id)"
|
||||
:class="['tab-item', { active: tab.id === state.activeTabId }]"
|
||||
@click="activateTab(tab.id)"
|
||||
@mousedown.middle.prevent="tab.closable && closeTab(tab.id)"
|
||||
>
|
||||
<CloseOutlined />
|
||||
<HomeOutlined v-if="tab.type === 'home'" />
|
||||
<span v-else class="tab-title">{{ tab.title }}</span>
|
||||
|
||||
<span v-if="tab.isDirty" class="dirty-dot">●</span>
|
||||
|
||||
<div
|
||||
v-if="tab.closable"
|
||||
class="close-btn"
|
||||
@click.stop="closeTab(tab.id)"
|
||||
>
|
||||
<CloseOutlined />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
|
|
@ -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) => {
|
||||
// 可以在这里添加右键菜单:关闭其他、关闭右侧等
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
|
|
@ -63,7 +63,15 @@ import {
|
|||
GithubOutlined,
|
||||
ExportOutlined,
|
||||
SettingOutlined,
|
||||
HistoryOutlined
|
||||
HistoryOutlined,
|
||||
BoldOutlined,
|
||||
ItalicOutlined,
|
||||
StrikethroughOutlined,
|
||||
CodeOutlined,
|
||||
MessageOutlined,
|
||||
UnorderedListOutlined,
|
||||
OrderedListOutlined,
|
||||
LinkOutlined
|
||||
} from '@ant-design/icons-vue';
|
||||
import { useFileTree } from '../composables/useFileTree';
|
||||
import { useTabs } from '../composables/useTabs';
|
||||
|
|
@ -72,10 +80,12 @@ import { message } from 'ant-design-vue';
|
|||
|
||||
const { state } = useFileTree();
|
||||
const { activeTab } = useTabs();
|
||||
const { saveCurrentFile } = useEditor();
|
||||
const { saveCurrentFile, triggerAction } = useEditor();
|
||||
|
||||
defineEmits(['select-directory', 'toggle-search', 'toggle-git', 'toggle-settings', 'toggle-history']);
|
||||
|
||||
const isFileTab = computed(() => activeTab.value && activeTab.value.type === 'file');
|
||||
|
||||
const canSave = computed(() => {
|
||||
return activeTab.value && activeTab.value.type === 'file' && activeTab.value.isDirty;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -38,6 +38,20 @@ export function useEditor() {
|
|||
|
||||
return {
|
||||
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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -75,6 +75,23 @@ export function useTabs() {
|
|||
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
|
||||
const activateTab = (tabId) => {
|
||||
state.activeTabId = tabId;
|
||||
|
|
@ -93,6 +110,8 @@ export function useTabs() {
|
|||
activeTab,
|
||||
openFile,
|
||||
closeTab,
|
||||
closeOtherTabs,
|
||||
closeAllTabs,
|
||||
activateTab,
|
||||
updateTab
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue