webapp/src/components/Editor/prosemirror/extension/table.ts

314 lines
9.7 KiB
TypeScript
Raw Normal View History

2022-09-09 11:53:35 +00:00
import { EditorState, Selection } from 'prosemirror-state'
import type { Node, Schema, ResolvedPos } from 'prosemirror-model'
import { InputRule, inputRules } from 'prosemirror-inputrules'
import { keymap } from 'prosemirror-keymap'
2022-10-08 16:40:58 +00:00
import type { ProseMirrorExtension } from '../../store/state'
2022-09-09 11:53:35 +00:00
export const tableInputRule = (schema: Schema) =>
new InputRule(
new RegExp('^\\|{2,}\\s$'),
(state: EditorState, match: string[], start: number, end: number) => {
const tr = state.tr
const columns = [...Array.from({ length: match[0].trim().length - 1 })]
const headers = columns.map(() => schema.node(schema.nodes.table_header, {}))
const cells = columns.map(() => schema.node(schema.nodes.table_cell, {}))
const table = schema.node(schema.nodes.table, {}, [
schema.node(schema.nodes.table_head, {}, schema.node(schema.nodes.table_row, {}, headers)),
schema.node(schema.nodes.table_body, {}, schema.node(schema.nodes.table_row, {}, cells))
])
tr.delete(start - 1, end)
tr.insert(start - 1, table)
tr.setSelection(Selection.near(tr.doc.resolve(start + 3)))
return tr
}
)
const tableSchema = {
table: {
content: '(table_head | table_body)*',
isolating: true,
selectable: false,
group: 'block',
parseDOM: [{ tag: 'div[data-type="table"]' }],
toDOM: () => [
'div',
{
class: 'table-container',
'data-type': 'table'
},
['table', 0]
]
},
table_head: {
content: 'table_row',
isolating: true,
group: 'table_block',
selectable: false,
parseDOM: [{ tag: 'thead' }],
toDOM: () => ['thead', 0]
},
table_body: {
content: 'table_row+',
isolating: true,
group: 'table_block',
selectable: false,
parseDOM: [{ tag: 'tbody' }],
toDOM: () => ['tbody', 0]
},
table_row: {
content: '(table_cell | table_header)*',
parseDOM: [{ tag: 'tr' }],
toDOM: () => ['tr', 0]
},
table_cell: {
content: 'inline*',
isolating: true,
group: 'table_block',
selectable: false,
attrs: { style: { default: null } },
parseDOM: [
{
tag: 'td',
getAttrs: (dom: HTMLElement) => {
const textAlign = dom.style.textAlign
return textAlign ? { style: `text-align: ${textAlign}` } : null
}
}
],
toDOM: (node: Node) => ['td', node.attrs, 0]
},
table_header: {
content: 'inline*',
isolating: true,
group: 'table_block',
selectable: false,
attrs: { style: { default: null } },
parseDOM: [
{
tag: 'th',
getAttrs: (dom: HTMLElement) => {
const textAlign = dom.style.textAlign
return textAlign ? { style: `text-align: ${textAlign}` } : null
}
}
],
toDOM: (node: Node) => ['th', node.attrs, 0]
}
}
const findParentPos = ($pos: ResolvedPos, fn: (n: Node) => boolean) => {
for (let d = $pos.depth; d > 0; d--) {
if (fn($pos.node(d))) return $pos.doc.resolve($pos.before(d + 1))
}
return null
}
const findTableCellPos = ($pos: ResolvedPos, header = true) =>
findParentPos($pos, (n) => n.type.name === 'table_cell' || (header && n.type.name === 'table_header'))
const findTableRowPos = ($pos: ResolvedPos) => findParentPos($pos, (n) => n.type.name === 'table_row')
const findTableHeadPos = ($pos: ResolvedPos) => findParentPos($pos, (n) => n.type.name === 'table_head')
const findTablePos = ($pos: ResolvedPos) => findParentPos($pos, (n) => n.type.name === 'table')
const findNodePosition = (node: Node, fn: (n: Node, p: Node) => boolean) => {
let result = -1
node.descendants((n, pos, p) => {
if (result !== -1) {
return false
} else if (fn(n, p)) {
result = pos
return false
}
})
return result
}
const findVertTableCellPos = ($pos: ResolvedPos, dir = 'up') => {
const cellPos = findTableCellPos($pos)
const rowPos = findTableRowPos($pos)
const offset = cellPos.pos - ($pos.before() + 1)
const add = dir === 'up' ? -1 : rowPos.node().nodeSize + 1
const nodeBeforePos = $pos.doc.resolve(rowPos.before() + add)
let rowBeforePos = findTableRowPos(nodeBeforePos)
if (!rowBeforePos) {
const table = $pos.node(0)
const tablePos = findTablePos($pos)
const inTableHead = !!findTableHeadPos($pos)
if (dir === 'up' && inTableHead) {
return $pos.doc.resolve(Math.max(0, tablePos.before() - 1))
} else if (dir === 'down' && !inTableHead) {
return $pos.doc.resolve(tablePos.after())
}
const pos = findNodePosition(table, (n, p) => {
return inTableHead
? p.type.name === 'table_body' && n.type.name === 'table_row'
: p.type.name === 'table_head' && n.type.name === 'table_row'
})
rowBeforePos = $pos.doc.resolve(pos + 1)
}
const targetCell = $pos.doc.resolve(rowBeforePos.posAtIndex(rowPos.index()) + 1)
const targetCellTextSize = getTextSize(targetCell.node())
const cellOffset = offset > targetCellTextSize ? targetCellTextSize : offset
return $pos.doc.resolve(targetCell.pos + cellOffset)
}
const getTextSize = (n: Node) => {
let size = 0
n.descendants((d: Node) => {
size += d.text?.length ?? 0
})
return size
}
export default (): ProseMirrorExtension => ({
schema: (prev) => ({
...prev,
nodes: (prev.nodes as any).append(tableSchema)
}),
// FIXME (extract functions)
// eslint-disable-next-line sonarjs/cognitive-complexity
plugins: (prev, schema) => [
keymap({
'Ctrl-Enter': (state, dispatch) => {
const tablePos = findTablePos(state.selection.$head)
if (!tablePos) return false
const targetPos = tablePos.after()
const tr = state.tr
tr.insert(targetPos, state.schema.node('paragraph'))
tr.setSelection(Selection.near(tr.doc.resolve(targetPos)))
dispatch(tr)
return true
},
Backspace: (state, dispatch) => {
const sel = state.selection
if (!sel.empty) return false
const cellPos = findTableCellPos(sel.$head)
if (!cellPos) return false
if (getTextSize(cellPos.node()) === 0) {
const rowPos = findTableRowPos(sel.$head)
const tablePos = findTablePos(sel.$head)
const before = state.doc.resolve(cellPos.before() - 1)
const cellBeforePos = findTableCellPos(before)
const inTableHead = !!findTableHeadPos(sel.$head)
if (cellBeforePos) {
const tr = state.tr
tr.setSelection(Selection.near(before))
dispatch(tr)
return true
} else if (!inTableHead && getTextSize(rowPos.node()) === 0) {
const tr = state.tr
tr.delete(before.pos - 1, before.pos + rowPos.node().nodeSize)
tr.setSelection(Selection.near(tr.doc.resolve(before.pos - 4)))
dispatch(tr)
return true
} else if (getTextSize(tablePos.node()) === 0) {
const tr = state.tr
tr.delete(tablePos.before(), tablePos.before() + tablePos.node().nodeSize)
dispatch(tr)
return true
}
}
return false
},
Enter: (state, dispatch) => {
const sel = state.selection
if (!sel.empty) return false
const cellPos = findTableCellPos(sel.$head)
if (!cellPos) return false
const rowPos = findTableRowPos(sel.$head)
const cells = []
rowPos.node().forEach((cell) => {
cells.push(schema.nodes.table_cell.create(cell.attrs))
})
const newRow = schema.nodes.table_row.create(null, cells)
const theadPos = findTableHeadPos(sel.$head)
if (theadPos) {
const tablePos = findTablePos(sel.$head)
let tbodyPos: number
tablePos.node().descendants((node, pos) => {
if (node.type.name === 'table_body') {
tbodyPos = tablePos.pos + pos
}
})
if (tbodyPos) {
const tbody = state.doc.resolve(tbodyPos + 1)
const tr = state.tr.insert(tbody.pos, newRow)
tr.setSelection(Selection.near(tr.doc.resolve(tbody.pos)))
dispatch(tr)
} else {
const tbody = schema.nodes.table_body.create(null, [newRow])
const targetPos = theadPos.after()
const tr = state.tr.insert(targetPos, tbody)
tr.setSelection(Selection.near(tr.doc.resolve(targetPos)))
dispatch(tr)
}
return true
}
const targetPos = sel.$head.after(-1)
const tr = state.tr.insert(targetPos, newRow)
tr.setSelection(Selection.near(tr.doc.resolve(targetPos)))
dispatch(tr)
return true
},
ArrowUp: (state, dispatch) => {
const sel = state.selection
if (!sel.empty) return false
const cellPos = findTableCellPos(sel.$head)
if (!cellPos) return false
const abovePos = findVertTableCellPos(sel.$head)
if (abovePos) {
const tr = state.tr
let selection = Selection.near(abovePos)
if (abovePos.pos === 0 && cellPos.parentOffset === 0) {
tr.insert(0, state.schema.node('paragraph'))
selection = Selection.near(tr.doc.resolve(0))
}
tr.setSelection(selection)
dispatch(tr)
return true
}
return false
},
ArrowDown: (state, dispatch) => {
const sel = state.selection
if (!sel.empty) return false
const cellPos = findTableCellPos(sel.$head)
if (!cellPos) return false
const belowPos = findVertTableCellPos(sel.$head, 'down')
if (belowPos) {
const tr = state.tr
tr.setSelection(Selection.near(belowPos))
dispatch(tr)
return true
}
return false
}
}),
...prev,
inputRules({ rules: [tableInputRule(schema)] })
]
})