feat(web): add code copy button, block-scoped select-all, table toolbar polish
- Copy button in the top-right of fenced code blocks, with execCommand fallback when the Clipboard API is unavailable over plain HTTP - Mod-A inside a code block selects the block content first; pressing it again selects the whole document - Paint code block backgrounds on a ::before layer below CodeMirror's selection layer so selections inside code blocks are visible - Table toolbar sits in reserved space above the table instead of overlapping it, with grouped icon buttons
This commit is contained in:
1 parent
54fecf71c8
commit
63f508d3df
5 files changed
+218
-59
No files matched your search
@@ -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 }])),
|
||||
]
|
||||
@@ -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--
|
||||
|
||||
+33
-9
@@ -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: '<rect x="3" y="3" width="18" height="8" rx="1.5"/><path d="M12 14v7M8.5 17.5h7"/>',
|
||||
addColumn: '<rect x="3" y="3" width="8" height="18" rx="1.5"/><path d="M14 12h7M17.5 8.5v7"/>',
|
||||
deleteRow: '<rect x="3" y="3" width="18" height="8" rx="1.5"/><path d="M8.5 17.5h7"/>',
|
||||
deleteColumn: '<rect x="3" y="3" width="8" height="18" rx="1.5"/><path d="M14 12h7"/>',
|
||||
source: '<path d="m16 18 6-6-6-6M8 6l-6 6 6 6"/>',
|
||||
}
|
||||
|
||||
function svg(body: string): string {
|
||||
return `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${body}</svg>`
|
||||
}
|
||||
|
||||
// 结构变更:基于文档中的最新表格重新生成整张表的源码,返回变更后应聚焦的单元格
|
||||
function mutate(
|
||||
view: EditorView,
|
||||
|
||||
@@ -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<boolean> {
|
||||
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 =
|
||||
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>'
|
||||
const CHECK_ICON =
|
||||
'<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
|
||||
|
||||
// 代码块右上角:语言标签(未激活时)+ 复制按钮
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+74
-30
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user