From 54fecf71c818e35d21079b8c69213d5094f0a6a1 Mon Sep 17 00:00:00 2001 From: eezd Date: Fri, 16 Oct 2026 17:37:40 +0800 Subject: [PATCH] feat(web): render editable tables and make images clickable - Top-level GFM tables render as an editable widget: click a cell to edit its Markdown source, Tab/Enter/arrows to navigate, toolbar to insert/delete rows and columns or switch to source - Structural table edits are isolated undo steps; focus is restored after the widget is rebuilt - Clicking an image moves the cursor to its syntax to reveal the source - Renderer escapes raw HTML and filters dangerous URL schemes by default --- web/src/demo/sample.md | 2 +- web/src/editor/index.ts | 2 + web/src/editor/livepreview.ts | 2 +- web/src/editor/table.ts | 403 ++++++++++++++++++++++++++++++++++ web/src/editor/widgets.ts | 7 + web/src/markdown/html.test.ts | 15 +- web/src/markdown/html.ts | 32 ++- web/src/themes/base.css | 42 +++- 8 files changed, 494 insertions(+), 11 deletions(-) create mode 100644 web/src/editor/table.ts diff --git a/web/src/demo/sample.md b/web/src/demo/sample.md index f901d0b..85b5ffb 100644 --- a/web/src/demo/sample.md +++ b/web/src/demo/sample.md @@ -53,7 +53,7 @@ func main() { ![示例图片](https://vuejs.org/images/logo.png) -## 表格(M2 实现可视化编辑) +## 表格(点击单元格直接编辑,Tab 跳格,悬停显示工具栏) | 功能 | 状态 | | --- | --- | diff --git a/web/src/editor/index.ts b/web/src/editor/index.ts index c67bb04..8de7966 100644 --- a/web/src/editor/index.ts +++ b/web/src/editor/index.ts @@ -4,6 +4,7 @@ import { EditorState, Prec, type Extension } from '@codemirror/state' import { drawSelection, EditorView, keymap } from '@codemirror/view' import { codeBlockHighlight } from './codeblocks' import { livePreview, resolveImageUrl } from './livepreview' +import { tableEditing } from './table' import { markdownTheme } from './theme' export interface EditorOptions { @@ -26,6 +27,7 @@ export function createEditor(opts: EditorOptions): EditorView { markdownTheme, livePreview, codeBlockHighlight, + tableEditing, keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]), EditorView.contentAttributes.of({ spellcheck: 'false', autocapitalize: 'sentences' }), ] diff --git a/web/src/editor/livepreview.ts b/web/src/editor/livepreview.ts index ed36ad3..dc5bff4 100644 --- a/web/src/editor/livepreview.ts +++ b/web/src/editor/livepreview.ts @@ -138,7 +138,7 @@ function buildDecorations(view: EditorView): DecorationSet { classLines(node.from, node.to, () => 'cm-rm-code') return false case 'Table': - // 表格可视化编辑在 M2 实现,目前以等宽源码显示,内部语法不做隐藏以免列对不齐 + // 光标在表格内时由此以等宽源码显示(其余情况由 table.ts 渲染为可编辑表格),内部语法不做隐藏以免列对不齐 classLines(node.from, node.to, () => 'cm-rm-table') return false case 'HTMLBlock': diff --git a/web/src/editor/table.ts b/web/src/editor/table.ts new file mode 100644 index 0000000..98f5b97 --- /dev/null +++ b/web/src/editor/table.ts @@ -0,0 +1,403 @@ +import { isolateHistory, redo, undo } from '@codemirror/commands' +import { syntaxTree } from '@codemirror/language' +import { StateField, type EditorState, type Range } from '@codemirror/state' +import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view' +import type { SyntaxNode } from '@lezer/common' +import { renderInlineMarkdown } from '../markdown/html' + +type Align = '' | 'left' | 'center' | 'right' + +interface RowLine { + from: number + to: number + cells: string[] +} + +interface TableModel { + from: number + to: number + aligns: Align[] + // rows[0] 为表头,其余为表体;分隔行单独由 aligns 表示 + rows: RowLine[] +} + +// ---- 解析与序列化 ---- + +// 按未转义的 | 切分一行,去掉首尾可选的竖线 +function splitRow(line: string): string[] { + const cells: string[] = [] + let cur = '' + for (let i = 0; i < line.length; i++) { + const ch = line[i] + if (ch === '\\' && i + 1 < line.length) { + cur += ch + line[++i] + } else if (ch === '|') { + cells.push(cur) + cur = '' + } else cur += ch + } + cells.push(cur) + if (/^\s*$/.test(cells[0]) && line.trimStart().startsWith('|')) cells.shift() + if (cells.length && /^\s*$/.test(cells[cells.length - 1]) && line.trimEnd().endsWith('|')) cells.pop() + return cells.map((c) => c.trim()) +} + +function parseAlign(cell: string): Align { + const left = cell.startsWith(':') + const right = cell.endsWith(':') + return left && right ? 'center' : right ? 'right' : left ? 'left' : '' +} + +function escapeCell(text: string): string { + return text.replace(/\n/g, ' ').replace(/(^|[^\\])\|/g, '$1\\|') +} + +function serializeRow(cells: string[]): string { + return '| ' + cells.join(' | ') + ' |' +} + +function serializeDelimiter(aligns: Align[]): string { + const marks = { '': '---', left: ':---', center: ':---:', right: '---:' } + return '| ' + aligns.map((a) => marks[a]).join(' | ') + ' |' +} + +function serializeTable(aligns: Align[], rows: string[][]): string { + const [header, ...body] = rows + return [serializeRow(header), serializeDelimiter(aligns), ...body.map(serializeRow)].join('\n') +} + +function readTable(state: EditorState, node: SyntaxNode): TableModel { + const { doc } = state + const rows: RowLine[] = [] + let aligns: Align[] = [] + for (let n = doc.lineAt(node.from).number, end = doc.lineAt(node.to).number; n <= end; n++) { + const line = doc.line(n) + const cells = splitRow(line.text) + if (rows.length === 1 && aligns.length === 0) aligns = cells.map(parseAlign) + else rows.push({ from: line.from, to: line.to, cells }) + } + const width = aligns.length + for (const r of rows) { + r.cells = r.cells.slice(0, width) + while (r.cells.length < width) r.cells.push('') + } + return { from: node.from, to: node.to, aligns, rows } +} + +function findTable(state: EditorState, pos: number): SyntaxNode | null { + for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) { + if (node.name === 'Table' && node.from === pos) return node + if (node.from > pos) break + } + return null +} + +// ---- 渲染组件 ---- + +interface WidgetState { + model: TableModel + // 最近编辑的单元格,工具栏操作以它为准 + current: { r: number; c: number } + // 结构变更后需要聚焦的单元格(DOM 重建后生效) + pendingFocus: { r: number; c: number } | null +} + +const widgetStates = new WeakMap() + +class TableWidget extends WidgetType { + readonly key: string + + constructor(readonly model: TableModel) { + super() + this.key = JSON.stringify([model.aligns, model.rows.map((r) => r.cells)]) + } + + eq(other: TableWidget) { + return other.key === this.key + } + + get estimatedHeight() { + return this.model.rows.length * 34 + 8 + } + + toDOM(view: EditorView) { + const wrap = document.createElement('div') + wrap.className = 'cm-rm-table-widget' + widgetStates.set(wrap, { model: this.model, current: { r: 0, c: 0 }, pendingFocus: null }) + // 横向滚动放在内层,避免绝对定位的工具栏被裁剪 + const scroller = document.createElement('div') + scroller.className = 'cm-rm-table-scroll' + scroller.append(this.buildTable(view, wrap)) + wrap.append(this.toolbar(view, wrap), scroller) + return wrap + } + + updateDOM(dom: HTMLElement, view: EditorView) { + const st = widgetStates.get(dom) + if (!st) return false + const old = st.model + st.model = this.model + const sameShape = + old.rows.length === this.model.rows.length && old.aligns.join() === this.model.aligns.join() + if (!sameShape || st.pendingFocus) { + // 重建前记下正在编辑的单元格(如撤销改变了行数),重建后恢复焦点 + const editing = dom.querySelector('[contenteditable]') + const focus = + st.pendingFocus ?? (editing ? { r: Number(editing.dataset.r), c: Number(editing.dataset.c) } : null) + st.pendingFocus = null + dom.querySelector('table')!.replaceWith(this.buildTable(view, dom)) + if (focus) { + const rows = this.model.rows.length + const cols = this.model.aligns.length + const cell = cellAt(dom, Math.min(focus.r, rows - 1), Math.min(focus.c, cols - 1)) + if (cell) startEdit(cell, st) + } + return true + } + // 形状不变:只刷新内容变化的单元格;正在编辑的单元格若与文档一致则不动,以免打断输入 + dom.querySelectorAll('[data-r]').forEach((cell) => { + const r = Number(cell.dataset.r) + const c = Number(cell.dataset.c) + const raw = this.model.rows[r].cells[c] + if (cell.isContentEditable) { + if (escapeCell(cell.textContent ?? '') !== raw) { + cell.textContent = raw + placeCaretAtEnd(cell) + } + } else if (old.rows[r].cells[c] !== raw) cell.innerHTML = renderInlineMarkdown(raw) + }) + return true + } + + ignoreEvent() { + return true + } + + private buildTable(view: EditorView, wrap: HTMLElement): HTMLTableElement { + const table = document.createElement('table') + const thead = table.createTHead() + const tbody = table.createTBody() + this.model.rows.forEach((row, r) => { + const tr = (r === 0 ? thead : tbody).insertRow() + row.cells.forEach((raw, c) => { + const cell = document.createElement(r === 0 ? 'th' : 'td') + cell.dataset.r = String(r) + cell.dataset.c = String(c) + if (this.model.aligns[c]) cell.style.textAlign = this.model.aligns[c] + cell.innerHTML = renderInlineMarkdown(raw) + bindCell(view, wrap, cell) + tr.append(cell) + }) + }) + return table + } + + 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 b = document.createElement('button') + b.type = 'button' + b.textContent = label + b.title = title + // mousedown 阻止默认行为,保持当前单元格的焦点与光标 + b.addEventListener('mousedown', (e) => { + e.preventDefault() + run() + }) + bar.append(b) + } + button('+行', '在下方插入行', () => insertRow(view, wrap)) + button('+列', '在右侧插入列', () => insertColumn(view, wrap)) + button('−行', '删除当前行', () => deleteRow(view, wrap)) + button('−列', '删除当前列', () => deleteColumn(view, wrap)) + button('源码', '以 Markdown 源码编辑', () => { + const model = currentModel(view, wrap) + if (!model) return + view.dispatch({ selection: { anchor: model.from } }) + view.focus() + }) + return bar + } +} + +// 结构变更:基于文档中的最新表格重新生成整张表的源码,返回变更后应聚焦的单元格 +function mutate( + view: EditorView, + wrap: HTMLElement, + f: (rows: string[][], aligns: Align[], cur: { r: number; c: number }) => { r: number; c: number }, +) { + const st = widgetStates.get(wrap)! + const model = currentModel(view, wrap) + if (!model) return + const rows = model.rows.map((r) => [...r.cells]) + const aligns = [...model.aligns] + st.pendingFocus = f(rows, aligns, st.current) + // 结构变更单独成为一步撤销,不与前后的单元格输入合并 + view.dispatch({ + changes: { from: model.from, to: model.to, insert: serializeTable(aligns, rows) }, + annotations: isolateHistory.of('full'), + }) +} + +// focusColumn 未指定时聚焦新行的同一列 +const insertRow = (view: EditorView, wrap: HTMLElement, focusColumn?: number) => + mutate(view, wrap, (rows, aligns, cur) => { + rows.splice(cur.r + 1, 0, aligns.map(() => '')) + return { r: cur.r + 1, c: focusColumn ?? cur.c } + }) + +const insertColumn = (view: EditorView, wrap: HTMLElement) => + mutate(view, wrap, (rows, aligns, cur) => { + const c = cur.c + 1 + aligns.splice(c, 0, '') + rows.forEach((row) => row.splice(c, 0, '')) + return { r: cur.r, c } + }) + +const deleteRow = (view: EditorView, wrap: HTMLElement) => + mutate(view, wrap, (rows, _, cur) => { + // 表头行不能删除 + if (cur.r === 0) return cur + rows.splice(cur.r, 1) + return { r: Math.min(cur.r, rows.length - 1), c: cur.c } + }) + +const deleteColumn = (view: EditorView, wrap: HTMLElement) => + mutate(view, wrap, (rows, aligns, cur) => { + if (aligns.length <= 1) return cur + aligns.splice(cur.c, 1) + rows.forEach((row) => row.splice(cur.c, 1)) + return { r: cur.r, c: Math.min(cur.c, aligns.length - 1) } + }) + +function currentModel(view: EditorView, wrap: HTMLElement): TableModel | null { + const node = findTable(view.state, view.posAtDOM(wrap)) + return node ? readTable(view.state, node) : null +} + +function cellAt(wrap: HTMLElement, r: number, c: number): HTMLElement | null { + return wrap.querySelector(`[data-r="${r}"][data-c="${c}"]`) +} + +function placeCaretAtEnd(el: HTMLElement) { + const sel = el.ownerDocument.getSelection() + if (!sel) return + const range = el.ownerDocument.createRange() + range.selectNodeContents(el) + range.collapse(false) + sel.removeAllRanges() + sel.addRange(range) +} + +// 单元格编辑时显示该格的 Markdown 源码,失焦后恢复渲染 +function startEdit(cell: HTMLElement, st: WidgetState) { + const r = Number(cell.dataset.r) + const c = Number(cell.dataset.c) + st.current = { r, c } + if (!cell.isContentEditable) { + cell.contentEditable = 'plaintext-only' + cell.textContent = st.model.rows[r].cells[c] + } + cell.focus() + placeCaretAtEnd(cell) +} + +function bindCell(view: EditorView, wrap: HTMLElement, cell: HTMLElement) { + const st = () => widgetStates.get(wrap)! + const r = () => Number(cell.dataset.r) + const c = () => Number(cell.dataset.c) + + cell.addEventListener('mousedown', (e) => { + if (cell.isContentEditable) return + e.preventDefault() + startEdit(cell, st()) + }) + + const commit = () => { + const model = currentModel(view, wrap) + if (!model) return + const row = model.rows[r()] + const cells = [...row.cells] + const next = escapeCell(cell.textContent ?? '') + if (cells[c()] === next) return + cells[c()] = next + view.dispatch({ changes: { from: row.from, to: row.to, insert: serializeRow(cells) }, userEvent: 'input.type' }) + } + + cell.addEventListener('input', (e) => { + if (!(e as InputEvent).isComposing) commit() + }) + cell.addEventListener('compositionend', commit) + + cell.addEventListener('blur', () => { + cell.removeAttribute('contenteditable') + cell.innerHTML = renderInlineMarkdown(st().model.rows[r()]?.cells[c()] ?? '') + }) + + const moveTo = (nr: number, nc: number) => { + const target = cellAt(wrap, nr, nc) + if (target) startEdit(target, st()) + } + + cell.addEventListener('keydown', (e) => { + if (e.isComposing) return + const { rows, aligns } = st().model + const cols = aligns.length + const mod = e.ctrlKey || e.metaKey + if (mod && e.key.toLowerCase() === 'z') { + e.preventDefault() + ;(e.shiftKey ? redo : undo)(view) + } else if (mod && e.key.toLowerCase() === 'y') { + e.preventDefault() + redo(view) + } else if (e.key === 'Tab') { + e.preventDefault() + const idx = r() * cols + c() + (e.shiftKey ? -1 : 1) + if (idx >= 0 && idx < rows.length * cols) moveTo(Math.floor(idx / cols), idx % cols) + else if (idx >= rows.length * cols) insertRow(view, wrap, 0) + } else if (e.key === 'Enter') { + e.preventDefault() + if (r() + 1 < rows.length) moveTo(r() + 1, c()) + else insertRow(view, wrap) + } else if (e.key === 'ArrowUp' && r() > 0) { + e.preventDefault() + moveTo(r() - 1, c()) + } else if (e.key === 'ArrowDown' && r() + 1 < rows.length) { + e.preventDefault() + moveTo(r() + 1, c()) + } else if (e.key === 'Escape') { + // 退出表格,光标放到表格之后 + e.preventDefault() + const model = currentModel(view, wrap) + if (model) view.dispatch({ selection: { anchor: Math.min(model.to + 1, view.state.doc.length) } }) + view.focus() + } + }) +} + +// ---- 装饰 ---- + +// 块级替换装饰跨越多行,只能由 StateField 提供,不能来自 ViewPlugin +function buildTables(state: EditorState): DecorationSet { + const out: Range[] = [] + const sel = state.selection.ranges + // 只渲染顶层表格;引用、列表内的表格以源码显示 + for (let node = syntaxTree(state).topNode.firstChild; node; node = node.nextSibling) { + if (node.name !== 'Table') continue + if (sel.some((r) => r.from <= node.to && r.to >= node.from)) continue + const model = readTable(state, node) + if (model.rows.length === 0 || model.aligns.length === 0) continue + out.push(Decoration.replace({ widget: new TableWidget(model), block: true }).range(node.from, node.to)) + } + return Decoration.set(out) +} + +export const tableEditing = StateField.define({ + create: buildTables, + update(value, tr) { + if (tr.docChanged || tr.selection || syntaxTree(tr.startState) !== syntaxTree(tr.state)) return buildTables(tr.state) + return value + }, + provide: (f) => EditorView.decorations.from(f), +}) diff --git a/web/src/editor/widgets.ts b/web/src/editor/widgets.ts index 5d961d7..a3fd087 100644 --- a/web/src/editor/widgets.ts +++ b/web/src/editor/widgets.ts @@ -85,6 +85,13 @@ export class ImageWidget extends WidgetType { img.decoding = 'async' // 图片加载完成后高度变化,需通知编辑器重新测量,否则光标定位会错位 img.addEventListener('load', () => view.requestMeasure()) + // 组件内的事件默认被编辑器忽略,需手动把光标移到图片语法处以展开源码 + wrap.addEventListener('mousedown', (e) => { + e.preventDefault() + // 先设光标再聚焦:focus() 会把编辑器选区写回 DOM,反之会读到残留在别处(如表格单元格)的 DOM 选区 + view.dispatch({ selection: { anchor: view.posAtDOM(wrap) } }) + view.focus() + }) img.addEventListener('error', () => { wrap.classList.add('cm-rm-image-error') wrap.textContent = this.alt || this.src diff --git a/web/src/markdown/html.test.ts b/web/src/markdown/html.test.ts index 9bd9ce3..cd73986 100644 --- a/web/src/markdown/html.test.ts +++ b/web/src/markdown/html.test.ts @@ -43,11 +43,24 @@ const examples = specTests.map((t) => ({ // 用 it.fails 标记:上游修复后这些用例会转为通过并报错,提醒移出此列表。 const PARSER_DEVIATIONS = new Set([10, 171, 280, 493, 512, 523, 528, 569, 571, 621, 625, 626]) +describe('安全模式', () => { + it('转义原始 HTML', () => { + expect(renderMarkdown('\n\na b')).toBe( + '<img src=x onerror=alert(1)>\n

a <b>b</b>

\n', + ) + }) + it('过滤危险链接协议', () => { + expect(renderMarkdown('[a](javascript:alert(1)) ![i](data:image/png;base64,AA)')).toBe( + '

a javascript:x i

\n', + ) + }) +}) + describe('CommonMark spec', () => { for (const e of examples) { const test = PARSER_DEVIATIONS.has(e.number) ? it.fails : it test(`#${e.number} ${e.section}`, () => { - expect(normalize(renderMarkdown(e.markdown, commonmarkParser))).toBe(normalize(e.html)) + expect(normalize(renderMarkdown(e.markdown, { parser: commonmarkParser, unsafe: true }))).toBe(normalize(e.html)) }) } }) diff --git a/web/src/markdown/html.ts b/web/src/markdown/html.ts index 32bf849..7e6fd23 100644 --- a/web/src/markdown/html.ts +++ b/web/src/markdown/html.ts @@ -46,8 +46,19 @@ function tabWidth(col: number): number { return 4 - (col % 4) } -export function renderMarkdown(src: string, parser: MarkdownParser = markdownParser): string { +export interface RenderOptions { + parser?: MarkdownParser + // 为 true 时原样输出原始 HTML、不过滤危险链接协议;仅用于可信内容(如规范测试) + unsafe?: boolean +} + +const UNSAFE_PROTOCOL = /^\s*(javascript|vbscript|file|data):/i +const SAFE_DATA_IMAGE = /^\s*data:image\/(png|gif|jpeg|webp);/i + +export function renderMarkdown(src: string, { parser = markdownParser, unsafe = false }: RenderOptions = {}): string { const tree = parser.parse(src) + const safeHref = (href: string) => (unsafe || !UNSAFE_PROTOCOL.test(href) || SAFE_DATA_IMAGE.test(href) ? href : '#') + const rawHtml = (s: string) => (unsafe ? s : escapeHtml(s)) const text = (from: number, to: number) => src.slice(from, to) const defs = new Map() @@ -261,19 +272,19 @@ export function renderMarkdown(src: string, parser: MarkdownParser = markdownPar case 'HTMLTag': case 'Comment': case 'ProcessingInstruction': - return text(node.from, node.to) + return rawHtml(text(node.from, node.to)) case 'Autolink': { const url = node.getChild('URL') if (!url) return escapeHtml(text(node.from, node.to)) const raw = text(url.from, url.to) const href = /^[^@\s]+@[^@\s]+$/.test(raw) && !raw.includes(':') ? 'mailto:' + raw : raw - return `${escapeHtml(raw)}` + return `${escapeHtml(raw)}` } case 'URL': { // GFM 裸链接 const raw = text(node.from, node.to) const href = raw.startsWith('www.') ? 'http://' + raw : raw.includes('@') && !raw.includes(':') ? 'mailto:' + raw : raw - return `${escapeHtml(raw)}` + return `${escapeHtml(raw)}` } case 'Link': case 'Image': @@ -305,8 +316,8 @@ export function renderMarkdown(src: string, parser: MarkdownParser = markdownPar } } const title = def.title !== null ? ` title="${escapeAttr(def.title)}"` : '' - if (isImage) return `${escapeAttr(plainText(content))}` - return `${content}` + if (isImage) return `${escapeAttr(plainText(content))}` + return `${content}` } // ---- 块级 ---- @@ -402,7 +413,7 @@ export function renderMarkdown(src: string, parser: MarkdownParser = markdownPar case 'HTMLBlock': case 'CommentBlock': case 'ProcessingInstructionBlock': - return contentLines(node, false, false).map((l) => l.rest).join('\n') + '\n' + return rawHtml(contentLines(node, false, false).map((l) => l.rest).join('\n')) + '\n' case 'Table': return table(node) default: @@ -460,3 +471,10 @@ export function renderMarkdown(src: string, parser: MarkdownParser = markdownPar return blocks(tree.topNode) } + +// 渲染单行行内 Markdown(表格单元格等),不产生块级结构 +export function renderInlineMarkdown(src: string, options?: RenderOptions): string { + const html = renderMarkdown(src, options) + const m = /^

([\s\S]*)<\/p>\n$/.exec(html) + return m ? m[1] : escapeHtml(src) +} diff --git a/web/src/themes/base.css b/web/src/themes/base.css index f7db42f..afcae89 100644 --- a/web/src/themes/base.css +++ b/web/src/themes/base.css @@ -140,10 +140,50 @@ body { .cm-rm-image img { max-width: 100%; border-radius: 4px; } .cm-rm-image-error { color: var(--rm-text-muted); font-style: italic; } -/* 表格(M2 前以等宽源码显示) */ +/* 表格:光标在表格内时以等宽源码显示,否则为可编辑的渲染表格 */ .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-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, +.cm-rm-table-widget td { + border: 1px solid var(--rm-divider); + padding: 6px 12px; + min-width: 3em; + cursor: text; + outline: none; +} +.cm-rm-table-widget th { font-weight: 600; text-align: left; background: var(--rm-inline-code-bg); } +.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; +} +.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; + border: 1px solid var(--rm-divider); + border-radius: 4px; + background: var(--rm-bg); + color: var(--rm-text-muted); + cursor: pointer; +} +.cm-rm-table-toolbar button:hover { color: var(--rm-accent-strong); border-color: var(--rm-accent); } +.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-line.cm-rm-code { padding: 0 12px; } }