diff --git a/web/src/editor/codeblocks.ts b/web/src/editor/codeblocks.ts index 4e3ea7f..bae1b5a 100644 --- a/web/src/editor/codeblocks.ts +++ b/web/src/editor/codeblocks.ts @@ -1,6 +1,7 @@ import { syntaxTree } from '@codemirror/language' -import { StateEffect, StateField, type EditorState, type Range } from '@codemirror/state' -import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view' +import { Prec, StateEffect, StateField, type EditorState, type Range } from '@codemirror/state' +import { Decoration, EditorView, keymap, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view' +import type { SyntaxNode } from '@lezer/common' import { cachedTokens, requestTokens, type TokenRun } from '../highlight/client' interface CodeBlock { @@ -63,6 +64,33 @@ export function fenceLanguage(state: EditorState, infoFrom: number, infoTo: numb return state.sliceDoc(infoFrom, infoTo).trim().split(/\s/)[0].toLowerCase() } +// 围栏代码块的内容范围(不含围栏行);有闭合围栏时到闭合行之前为止,否则到块尾 +export function codeContentRange(state: EditorState, node: SyntaxNode): { from: number; to: number } { + const open = state.doc.lineAt(node.from) + const marks = node.getChildren('CodeMark') + const to = marks.length > 1 ? state.doc.lineAt(node.to).from - 1 : node.to + return { from: Math.min(open.to + 1, to), to } +} + +export function fencedCodeAt(state: EditorState, pos: number): SyntaxNode | null { + for (let node: SyntaxNode | null = syntaxTree(state).resolveInner(pos, 1); node; node = node.parent) { + if (node.name === 'FencedCode') return node + } + return null +} + +// Ctrl/Cmd+A:光标在代码块内时先选中整块代码,已选中整块时交给默认的全选 +function selectCodeBlock(view: EditorView): boolean { + const { state } = view + const main = state.selection.main + const node = fencedCodeAt(state, main.head) + if (!node) return false + const { from, to } = codeContentRange(state, node) + if (to <= from || main.head < from || main.head > to || (main.from === from && main.to === to)) return false + view.dispatch({ selection: { anchor: from, head: to }, userEvent: 'select' }) + return true +} + function visibleCodeBlocks(view: EditorView): CodeBlock[] { const { state } = view const blocks: CodeBlock[] = [] @@ -74,12 +102,7 @@ function visibleCodeBlocks(view: EditorView): CodeBlock[] { if (node.name !== 'FencedCode') return const info = node.node.getChild('CodeInfo') if (info) { - const open = state.doc.lineAt(node.from) - const close = state.doc.lineAt(node.to) - const marks = node.node.getChildren('CodeMark') - // 有闭合围栏时内容到闭合行之前为止,否则到块尾 - const end = marks.length > 1 ? close.from - 1 : node.to - const start = Math.min(open.to + 1, end) + const { from: start, to: end } = codeContentRange(state, node.node) if (end > start) { blocks.push({ from: start, to: end, lang: fenceLanguage(state, info.from, info.to), code: state.sliceDoc(start, end) }) } @@ -139,4 +162,8 @@ const codeHighlighter = ViewPlugin.fromClass( }, ) -export const codeBlockHighlight = [codeTokenField, codeHighlighter] +export const codeBlockHighlight = [ + codeTokenField, + codeHighlighter, + Prec.high(keymap.of([{ key: 'Mod-a', run: selectCodeBlock }])), +] diff --git a/web/src/editor/livepreview.ts b/web/src/editor/livepreview.ts index dc5bff4..84d16c8 100644 --- a/web/src/editor/livepreview.ts +++ b/web/src/editor/livepreview.ts @@ -2,8 +2,8 @@ import { syntaxTree } from '@codemirror/language' import { Facet, type EditorState, type Range } from '@codemirror/state' import { Decoration, EditorView, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view' import type { SyntaxNode } from '@lezer/common' -import { fenceLanguage } from './codeblocks' -import { BulletWidget, CheckboxWidget, CodeLangWidget, HorizontalRuleWidget, ImageWidget } from './widgets' +import { codeContentRange, fenceLanguage, fencedCodeAt } from './codeblocks' +import { BulletWidget, CheckboxWidget, CodeHeaderWidget, HorizontalRuleWidget, ImageWidget } from './widgets' // 把笔记中书写的图片地址解析为浏览器可加载的 URL(相对路径需结合笔记所在目录) export const resolveImageUrl = Facet.define<(src: string) => string, (src: string) => string>({ @@ -181,12 +181,16 @@ function buildDecorations(view: EditorView): DecorationSet { classLines(node.from, node.to, (n, first, last) => n === first ? 'cm-rm-code cm-rm-code-begin' : n === last && closed ? 'cm-rm-code cm-rm-code-end' : 'cm-rm-code', ) - if (isActive(node.from, node.to)) return const info = node.getChild('CodeInfo') const openLine = doc.lineAt(node.from) const lang = info ? fenceLanguage(state, info.from, info.to) : '' + if (isActive(node.from, node.to)) { + // 编辑中显示围栏源码,复制按钮浮在行尾 + out.push(Decoration.widget({ widget: new CodeHeaderWidget(lang, false, codeAt), side: 1 }).range(openLine.to)) + return + } out.push(lineDeco('cm-rm-code-fence').range(openLine.from)) - out.push(Decoration.replace({ widget: new CodeLangWidget(lang) }).range(marks[0].from, openLine.to)) + out.push(Decoration.replace({ widget: new CodeHeaderWidget(lang, true, codeAt) }).range(marks[0].from, openLine.to)) if (closed) { const closeMark = marks[marks.length - 1] out.push(lineDeco('cm-rm-code-fence').range(doc.lineAt(closeMark.from).from)) @@ -197,6 +201,13 @@ function buildDecorations(view: EditorView): DecorationSet { return Decoration.set(out, true) } +function codeAt(view: EditorView, pos: number): string | null { + const node = fencedCodeAt(view.state, pos) + if (!node) return null + const { from, to } = codeContentRange(view.state, node) + return view.state.sliceDoc(from, to) +} + function trailingSpaceStart(state: EditorState, pos: number): number { let start = pos while (start > 0 && state.sliceDoc(start - 1, start) === ' ') start-- diff --git a/web/src/editor/table.ts b/web/src/editor/table.ts index 98f5b97..f301a3c 100644 --- a/web/src/editor/table.ts +++ b/web/src/editor/table.ts @@ -117,7 +117,7 @@ class TableWidget extends WidgetType { } get estimatedHeight() { - return this.model.rows.length * 34 + 8 + return this.model.rows.length * 34 + 44 } toDOM(view: EditorView) { @@ -195,23 +195,35 @@ class TableWidget extends WidgetType { private toolbar(view: EditorView, wrap: HTMLElement): HTMLElement { const bar = document.createElement('div') bar.className = 'cm-rm-table-toolbar' - const button = (label: string, title: string, run: () => void) => { + const tools = document.createElement('div') + tools.className = 'cm-rm-table-tools' + bar.append(tools) + const button = (icon: string, label: string, title: string, run: () => void, danger = false) => { const b = document.createElement('button') b.type = 'button' - b.textContent = label b.title = title + if (danger) b.className = 'cm-rm-danger' + b.innerHTML = svg(icon) + b.append(label) // mousedown 阻止默认行为,保持当前单元格的焦点与光标 b.addEventListener('mousedown', (e) => { e.preventDefault() run() }) - bar.append(b) + tools.append(b) } - button('+行', '在下方插入行', () => insertRow(view, wrap)) - button('+列', '在右侧插入列', () => insertColumn(view, wrap)) - button('−行', '删除当前行', () => deleteRow(view, wrap)) - button('−列', '删除当前列', () => deleteColumn(view, wrap)) - button('源码', '以 Markdown 源码编辑', () => { + const separator = () => { + const sep = document.createElement('span') + sep.className = 'cm-rm-sep' + tools.append(sep) + } + button(ICONS.addRow, '行', '在下方插入行', () => insertRow(view, wrap)) + button(ICONS.addColumn, '列', '在右侧插入列', () => insertColumn(view, wrap)) + separator() + button(ICONS.deleteRow, '行', '删除当前行', () => deleteRow(view, wrap), true) + button(ICONS.deleteColumn, '列', '删除当前列', () => deleteColumn(view, wrap), true) + separator() + button(ICONS.source, '源码', '以 Markdown 源码编辑', () => { const model = currentModel(view, wrap) if (!model) return view.dispatch({ selection: { anchor: model.from } }) @@ -221,6 +233,18 @@ class TableWidget extends WidgetType { } } +const ICONS = { + addRow: '', + addColumn: '', + deleteRow: '', + deleteColumn: '', + source: '', +} + +function svg(body: string): string { + return `${body}` +} + // 结构变更:基于文档中的最新表格重新生成整张表的源码,返回变更后应聚焦的单元格 function mutate( view: EditorView, diff --git a/web/src/editor/widgets.ts b/web/src/editor/widgets.ts index a3fd087..1dc70a5 100644 --- a/web/src/editor/widgets.ts +++ b/web/src/editor/widgets.ts @@ -50,17 +50,70 @@ export class HorizontalRuleWidget extends WidgetType { } } -export class CodeLangWidget extends WidgetType { - constructor(readonly lang: string) { +// 写入剪贴板;Clipboard API 只在 HTTPS/localhost 可用,局域网 HTTP 访问时退回 execCommand +async function copyText(text: string): Promise { + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // 权限被拒时继续尝试旧方法 + } + } + const ta = document.createElement('textarea') + ta.value = text + ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0' + document.body.append(ta) + ta.select() + const ok = document.execCommand('copy') + ta.remove() + return ok +} + +const COPY_ICON = + '' +const CHECK_ICON = + '' + +// 代码块右上角:语言标签(未激活时)+ 复制按钮 +export class CodeHeaderWidget extends WidgetType { + constructor( + readonly lang: string, + readonly showLang: boolean, + // 根据组件所在位置取出代码块内容,由调用方结合语法树实现 + readonly codeAt: (view: EditorView, pos: number) => string | null, + ) { super() } - eq(other: CodeLangWidget) { - return other.lang === this.lang + eq(other: CodeHeaderWidget) { + return other.lang === this.lang && other.showLang === this.showLang } - toDOM() { + toDOM(view: EditorView) { const el = document.createElement('span') - el.className = 'cm-rm-code-lang' - el.textContent = this.lang + el.className = 'cm-rm-code-header' + if (this.showLang && this.lang) { + const label = document.createElement('span') + label.className = 'cm-rm-code-lang' + label.textContent = this.lang + el.append(label) + } + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'cm-rm-code-copy' + btn.title = '复制代码' + btn.innerHTML = COPY_ICON + btn.addEventListener('mousedown', (e) => e.preventDefault()) + btn.addEventListener('click', async () => { + const code = this.codeAt(view, view.posAtDOM(el)) + if (code === null || !(await copyText(code))) return + btn.innerHTML = CHECK_ICON + btn.classList.add('cm-rm-copied') + setTimeout(() => { + btn.innerHTML = COPY_ICON + btn.classList.remove('cm-rm-copied') + }, 1500) + }) + el.append(btn) return el } } diff --git a/web/src/themes/base.css b/web/src/themes/base.css index afcae89..b0638bb 100644 --- a/web/src/themes/base.css +++ b/web/src/themes/base.css @@ -99,26 +99,52 @@ body { border-radius: 4px; } -/* 代码块 */ +/* 代码块 + * 背景画在 ::before 上并置于选区图层之下:若直接给 .cm-line 设背景,会盖住 CodeMirror 绘制的选区 */ .cm-line.cm-rm-code { + position: relative; font-family: var(--rm-font-mono); font-size: var(--rm-code-font-size); line-height: 1.5; - background: var(--rm-code-bg); color: var(--rm-code-text); padding: 0 24px; tab-size: 4; } -.cm-line.cm-rm-code .cm-rm-mono { font-family: inherit; } -.cm-line.cm-rm-code-begin { border-radius: 8px 8px 0 0; padding-top: 10px; } -.cm-line.cm-rm-code-end { border-radius: 0 0 8px 8px; padding-bottom: 10px; } -.cm-line.cm-rm-code-begin.cm-rm-code-end { border-radius: 8px; } -/* 未激活时的围栏行:压低行高,只显示右上角语言标签 */ -.cm-line.cm-rm-code-fence { font-size: 12px; line-height: 1.2; text-align: right; } -.cm-rm-code-lang { - color: rgba(235, 235, 235, 0.38); - user-select: none; +.cm-line.cm-rm-code::before { + content: ''; + position: absolute; + inset: 0; + background: var(--rm-code-bg); + z-index: -10; + pointer-events: none; } +.cm-line.cm-rm-code .cm-rm-mono { font-family: inherit; } +.cm-line.cm-rm-code-begin { padding-top: 10px; } +.cm-line.cm-rm-code-end { padding-bottom: 10px; } +.cm-line.cm-rm-code-begin::before { border-radius: 8px 8px 0 0; } +.cm-line.cm-rm-code-end::before { border-radius: 0 0 8px 8px; } +.cm-line.cm-rm-code-begin.cm-rm-code-end::before { border-radius: 8px; } +/* 未激活时的围栏行:压低行高,只显示右上角语言标签与复制按钮 */ +.cm-line.cm-rm-code-fence { font-size: 12px; line-height: 1.2; text-align: right; padding-right: 12px; } +.cm-rm-code-header { display: inline-flex; align-items: center; gap: 6px; vertical-align: middle; user-select: none; } +.cm-line.cm-rm-code-begin:not(.cm-rm-code-fence) .cm-rm-code-header { position: absolute; top: 6px; right: 12px; } +.cm-rm-code-lang { color: rgba(235, 235, 235, 0.38); font-size: 12px; line-height: 1; } +.cm-rm-code-copy { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: rgba(235, 235, 235, 0.45); + cursor: pointer; + transition: color 0.15s, background-color 0.15s; +} +.cm-rm-code-copy:hover { color: #fff; background: rgba(255, 255, 255, 0.08); } +.cm-rm-code-copy.cm-rm-copied { color: #42d392; } .cm-rm-code .cm-rm-markup { color: rgba(235, 235, 235, 0.38); } /* 引用 */ @@ -142,7 +168,7 @@ body { /* 表格:光标在表格内时以等宽源码显示,否则为可编辑的渲染表格 */ .cm-line.cm-rm-table { font-family: var(--rm-font-mono); font-size: 0.9em; } -.cm-rm-table-widget { position: relative; padding: 4px 0; width: fit-content; max-width: 100%; } +.cm-rm-table-widget { padding: 2px 0 4px; width: fit-content; max-width: 100%; } .cm-rm-table-scroll { overflow-x: auto; } .cm-rm-table-widget table { border-collapse: collapse; font-size: 0.95em; line-height: 1.5; } .cm-rm-table-widget th, @@ -157,33 +183,51 @@ body { .cm-rm-table-widget [contenteditable] { box-shadow: inset 0 0 0 2px var(--rm-accent); font-family: var(--rm-font-mono); font-size: 0.9em; } .cm-rm-table-widget code { font-family: var(--rm-font-mono); font-size: 0.875em; background: var(--rm-inline-code-bg); color: var(--rm-inline-code-text); padding: 0.1em 0.35em; border-radius: 4px; } .cm-rm-table-widget a { color: var(--rm-accent-strong); } +/* 工具栏占据表格上方的固定空间,只切换可见性,不遮挡表格也不引起跳动 */ .cm-rm-table-toolbar { - position: absolute; - top: -22px; - right: 0; - display: none; - gap: 2px; - font-size: 12px; + display: flex; + justify-content: flex-end; + margin-bottom: 6px; + visibility: hidden; + opacity: 0; + transition: opacity 0.15s; } .cm-rm-table-widget:hover .cm-rm-table-toolbar, -.cm-rm-table-widget:focus-within .cm-rm-table-toolbar { display: flex; } -.cm-rm-table-toolbar button { - font: inherit; - padding: 2px 8px; - min-height: 22px; +.cm-rm-table-widget:focus-within .cm-rm-table-toolbar { visibility: visible; opacity: 1; } +.cm-rm-table-tools { + display: inline-flex; + align-items: center; + padding: 2px; border: 1px solid var(--rm-divider); - border-radius: 4px; + border-radius: 8px; background: var(--rm-bg); - color: var(--rm-text-muted); - cursor: pointer; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); } -.cm-rm-table-toolbar button:hover { color: var(--rm-accent-strong); border-color: var(--rm-accent); } +.cm-rm-table-tools button { + display: inline-flex; + align-items: center; + gap: 4px; + height: 26px; + padding: 0 8px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--rm-text-muted); + font: 12px/1 var(--rm-font-text); + cursor: pointer; + transition: color 0.15s, background-color 0.15s; +} +.cm-rm-table-tools button:hover { color: var(--rm-accent-strong); background: var(--rm-selection); } +.cm-rm-table-tools button.cm-rm-danger:hover { color: #e5484d; background: rgba(229, 72, 77, 0.1); } +.cm-rm-table-tools svg { flex: none; } +.cm-rm-table-tools .cm-rm-sep { width: 1px; height: 16px; margin: 0 2px; background: var(--rm-divider); } .cm-rm-image { cursor: pointer; } @media (max-width: 640px) { .cm-editor .cm-content { padding: 16px 16px 40vh; } - /* 触屏没有悬停,工具栏常显 */ - .cm-rm-table-toolbar { display: flex; position: static; justify-content: flex-end; margin-bottom: 4px; } - .cm-rm-table-toolbar button { min-height: 32px; padding: 4px 10px; } + /* 触屏没有悬停,工具栏常显,按钮加大触控热区 */ + .cm-rm-table-toolbar { visibility: visible; opacity: 1; } + .cm-rm-table-tools button { height: 36px; padding: 0 10px; } + .cm-rm-code-copy { width: 36px; height: 36px; } .cm-line.cm-rm-code { padding: 0 12px; } }