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 { 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue