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
This commit is contained in:
1 parent
a1597697e5
commit
54fecf71c8
8 files changed
+494
-11
No files matched your search
@@ -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' }),
|
||||
]
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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<HTMLElement, WidgetState>()
|
||||
|
||||
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>(`[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<Decoration>[] = []
|
||||
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<DecorationSet>({
|
||||
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),
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user