Files
outline/shared/editor/nodes/TableHeader.ts
2025-12-28 08:56:32 -05:00

251 lines
7.4 KiB
TypeScript

import type { Token } from "markdown-it";
import type { NodeSpec } from "prosemirror-model";
import type { EditorState } from "prosemirror-state";
import { Plugin, PluginKey } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { DecorationSet, Decoration } from "prosemirror-view";
import { TableMap } from "prosemirror-tables";
import { addColumnBefore, selectColumn } from "../commands/table";
import { getCellAttrs, setCellAttrs } from "../lib/table";
import {
getCellsInRow,
isColumnSelected,
isTableSelected,
} from "../queries/table";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { cn } from "../styles/utils";
import Node from "./Node";
export default class TableHeader extends Node {
get name() {
return "th";
}
get schema(): NodeSpec {
return {
content: "block+",
tableRole: "header_cell",
group: "cell",
isolating: true,
parseDOM: [{ tag: "th", getAttrs: getCellAttrs }],
toDOM(node) {
return ["th", setCellAttrs(node), 0];
},
attrs: {
colspan: { default: 1 },
rowspan: { default: 1 },
alignment: { default: null },
colwidth: { default: null },
},
};
}
toMarkdown() {
// see: renderTable
}
parseMarkdown() {
return {
block: "th",
getAttrs: (tok: Token) => ({ alignment: tok.info }),
};
}
get plugins() {
function buildAddColumnDecoration(pos: number, index: number) {
const className = cn(EditorStyleHelper.tableAddColumn, {
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 createColumnDecorations = (state: EditorState) => {
if (!this.editor.view?.editable) {
return DecorationSet.empty;
}
const { doc } = state;
const decorations: Decoration[] = [];
const cols = getCellsInRow(0)(state);
if (cols) {
cols.forEach((pos, index) => {
const className = cn(EditorStyleHelper.tableGripColumn, {
selected: isColumnSelected(index)(state) || isTableSelected(state),
first: index === 0,
last: index === cols.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(buildAddColumnDecoration(pos, index));
}
decorations.push(buildAddColumnDecoration(pos, index + 1));
});
}
return DecorationSet.create(doc, decorations);
};
const createHeaderDecorations = (state: EditorState) => {
const { doc } = state;
const decorations: Decoration[] = [];
// Iterate through all tables in the document
doc.descendants((node, pos) => {
if (node.type.spec.tableRole === "table") {
const map = TableMap.get(node);
// Mark cells in the first column and last row of this table
node.descendants((cellNode, cellPos) => {
if (cellNode.type.spec.tableRole === "header_cell") {
const cellOffset = cellPos;
const cellIndex = map.map.indexOf(cellOffset);
if (cellIndex !== -1) {
const col = cellIndex % map.width;
const row = Math.floor(cellIndex / map.width);
const rowspan = cellNode.attrs.rowspan || 1;
const colspan = cellNode.attrs.colspan || 1;
const attrs: Record<string, string> = {};
if (col === 0) {
attrs["data-first-column"] = "true";
}
// Mark cells that extend into the last column (accounting for colspan)
if (col + colspan >= map.width) {
attrs["data-last-column"] = "true";
}
// Mark cells that extend into the last row (accounting for rowspan)
if (row + rowspan >= map.height) {
attrs["data-last-row"] = "true";
}
if (Object.keys(attrs).length > 0) {
decorations.push(
Decoration.node(
pos + cellPos + 1,
pos + cellPos + 1 + cellNode.nodeSize,
attrs
)
);
}
}
}
});
}
});
return DecorationSet.create(doc, decorations);
};
return [
new Plugin({
key: new PluginKey("table-header-first-column"),
state: {
init: (_, state) => createHeaderDecorations(state),
apply: (tr, pluginState, oldState, newState) => {
// Only recompute if document changed
if (!tr.docChanged) {
return pluginState;
}
return createHeaderDecorations(newState);
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
}),
new Plugin({
key: new PluginKey("table-header-decorations"),
state: {
init: (_, state) => createColumnDecorations(state),
apply: (tr, pluginState, oldState, newState) => {
// Only recompute if selection or document changed
if (!tr.selectionSet && !tr.docChanged) {
return pluginState;
}
return createColumnDecorations(newState);
},
},
props: {
handleDOMEvents: {
mousedown: (view: EditorView, event: MouseEvent) => {
if (!(event.target instanceof HTMLElement)) {
return false;
}
const targetAddColumn = event.target.closest(
`.${EditorStyleHelper.tableAddColumn}`
);
if (targetAddColumn) {
event.preventDefault();
event.stopImmediatePropagation();
const index = Number(
targetAddColumn.getAttribute("data-index")
);
addColumnBefore({ index })(view.state, view.dispatch);
return true;
}
const targetGripColumn = event.target.closest(
`.${EditorStyleHelper.tableGripColumn}`
);
if (targetGripColumn) {
event.preventDefault();
event.stopImmediatePropagation();
selectColumn(
Number(targetGripColumn.getAttribute("data-index")),
event.metaKey || event.shiftKey
)(view.state, view.dispatch);
return true;
}
return false;
},
},
decorations(state) {
return this.getState(state);
},
},
}),
];
}
}