Files
outline/shared/editor/nodes/TableCell.ts
T
Tom Moor 2eeeae4a7c perf: Table decorations (#10812)
* perf: CodeFence decos

* perf: Caching for table decos

* PR feedback
2025-12-06 12:08:22 +00:00

237 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Token } from "markdown-it";
import { NodeSpec, Slice } from "prosemirror-model";
import { EditorState, Plugin, PluginKey } from "prosemirror-state";
import { DecorationSet, Decoration } from "prosemirror-view";
import { addRowBefore, selectRow, selectTable } from "../commands/table";
import { getCellAttrs, setCellAttrs } from "../lib/table";
import {
getCellsInColumn,
getRowIndexInMap,
isRowSelected,
isTableSelected,
} from "../queries/table";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { cn } from "../styles/utils";
import Node from "./Node";
export default class TableCell extends Node {
get name() {
return "td";
}
get schema(): NodeSpec {
return {
content: "block+",
tableRole: "cell",
isolating: true,
parseDOM: [{ tag: "td", getAttrs: getCellAttrs }],
toDOM(node) {
return ["td", setCellAttrs(node), 0];
},
attrs: {
colspan: { default: 1 },
rowspan: { default: 1 },
alignment: { default: null },
colwidth: { default: null },
},
};
}
toMarkdown() {
// see: renderTable
}
parseMarkdown() {
return {
block: "td",
getAttrs: (tok: Token) => ({ alignment: tok.info }),
};
}
get plugins() {
function buildAddRowDecoration(pos: number, index: number) {
const className = cn(EditorStyleHelper.tableAddRow, {
first: index === 0,
});
return Decoration.widget(
pos + 1,
() => {
const plus = document.createElement("a");
plus.role = "button";
plus.className = className;
plus.dataset.index = index.toString();
return plus;
},
{
key: cn(className, index),
}
);
}
const createRowDecorations = (state: EditorState) => {
if (!this.editor.view?.editable) {
return DecorationSet.empty;
}
const { doc } = state;
const decorations: Decoration[] = [];
const rows = getCellsInColumn(0)(state);
if (rows) {
rows.forEach((pos, visualIndex) => {
const actualRowIndex = getRowIndexInMap(visualIndex, state);
const index = actualRowIndex !== -1 ? actualRowIndex : visualIndex;
if (index === 0) {
const className = cn(EditorStyleHelper.tableGrip, {
selected: isTableSelected(state),
});
decorations.push(
Decoration.widget(
pos + 1,
() => {
const grip = document.createElement("a");
grip.role = "button";
grip.className = className;
return grip;
},
{
key: className,
}
)
);
}
const className = cn(EditorStyleHelper.tableGripRow, {
selected: isRowSelected(index)(state) || isTableSelected(state),
first: index === 0,
last: visualIndex === rows.length - 1,
});
decorations.push(
Decoration.widget(
pos + 1,
() => {
const grip = document.createElement("a");
grip.role = "button";
grip.className = className;
grip.dataset.index = index.toString();
return grip;
},
{
key: cn(className, index),
}
)
);
if (index === 0) {
decorations.push(buildAddRowDecoration(pos, index));
}
decorations.push(buildAddRowDecoration(pos, index + 1));
});
}
return DecorationSet.create(doc, decorations);
};
return [
new Plugin({
key: new PluginKey("table-cell-decorations"),
state: {
init: (_, state) => createRowDecorations(state),
apply: (tr, pluginState, oldState, newState) => {
// Only recompute if selection or document changed
if (!tr.selectionSet && !tr.docChanged) {
return pluginState;
}
return createRowDecorations(newState);
},
},
props: {
transformCopied: (slice) => {
// check if the copied selection is a single table, with a single row, with a single cell. If so,
// copy the cell content only not a table with a single cell. This leads to more predictable pasting
// behavior, both in and outside the app.
if (slice.content.childCount === 1) {
const table = slice.content.firstChild;
if (
table?.type.spec.tableRole === "table" &&
table.childCount === 1
) {
const row = table.firstChild;
if (
row?.type.spec.tableRole === "row" &&
row.childCount === 1
) {
const cell = row.firstChild;
if (cell?.type.spec.tableRole === "cell") {
return new Slice(
cell.content,
slice.openStart,
slice.openEnd
);
}
}
}
}
return slice;
},
handleDOMEvents: {
mousedown: (view, event) => {
if (!(event.target instanceof HTMLElement)) {
return false;
}
const targetAddRow = event.target.closest(
`.${EditorStyleHelper.tableAddRow}`
);
if (targetAddRow) {
event.preventDefault();
event.stopImmediatePropagation();
const index = Number(targetAddRow.getAttribute("data-index"));
addRowBefore({ index })(view.state, view.dispatch);
return true;
}
const targetGrip = event.target.closest(
`.${EditorStyleHelper.tableGrip}`
);
if (targetGrip) {
event.preventDefault();
event.stopImmediatePropagation();
selectTable()(view.state, view.dispatch);
return true;
}
const targetGripRow = event.target.closest(
`.${EditorStyleHelper.tableGripRow}`
);
if (targetGripRow) {
event.preventDefault();
event.stopImmediatePropagation();
selectRow(
Number(targetGripRow.getAttribute("data-index")),
event.metaKey || event.shiftKey
)(view.state, view.dispatch);
return true;
}
return false;
},
},
decorations(state) {
return this.getState(state);
},
},
}),
];
}
}