Files
edentext/src/lib/editor/extensions/tableHeaderRow.ts
T

120 lines
4.8 KiB
TypeScript

import { Extension } from '@tiptap/core';
import type { CommandProps } from '@tiptap/core';
import type { Node as PMNode } from '@tiptap/pm/model';
import type { EditorState } from '@tiptap/pm/state';
import { selectedRect, isInTable } from '@tiptap/pm/tables';
import { parseTableLook, type TableRegion } from '../../styles/tableStyles';
// "Header row"/"header column" as a styling preset: the first row (or column) gets bold
// text + a light grey fill, both round-tripping to ODF/DOCX. The fill doubles as the
// marker, so no <table:table-header-rows> and no page repetition.
export const HEADER_SHADE = '#F2F2F2';
export type HeaderAxis = 'row' | 'column';
// The axis' matching Table Style Option.
const LOOK_REGION: Record<HeaderAxis, TableRegion> = { row: 'headerRow', column: 'firstColumn' };
// The cursor's table, if it has a named table style (else the manual shading applies).
function styledTable(state: EditorState): PMNode | null {
if (!isInTable(state)) return null;
const { table } = selectedRect(state);
return table.attrs.tableStyle ? table : null;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
tableHeaderRow: {
toggleHeaderRowStyle: () => ReturnType;
toggleHeaderColumnStyle: () => ReturnType;
};
}
}
// Distinct cell positions (relative to tableStart) of the table's first row / first column.
function headerCellPositions(
state: EditorState,
axis: HeaderAxis,
): { positions: number[]; tableStart: number; corner: number } | null {
if (!isInTable(state)) return null;
const { map, tableStart } = selectedRect(state);
const seen = new Set<number>();
const positions: number[] = [];
const n = axis === 'row' ? map.width : map.height;
for (let i = 0; i < n; i++) {
const pos = map.map[axis === 'row' ? i : i * map.width];
if (!seen.has(pos)) { seen.add(pos); positions.push(pos); }
}
return { positions, tableStart, corner: map.map[0] };
}
// True when the cursor sits in a header-shaded cell (so the Bold button should drive the
// fontWeight override, like a heading, instead of the bold mark — see Toolbar.svelte).
export function isInHeaderCell(state: EditorState): boolean {
const { $from } = state.selection;
for (let d = $from.depth; d > 0; d--) {
const role = $from.node(d).type.spec.tableRole;
if (role === 'cell' || role === 'header_cell') {
return $from.node(d).attrs.backgroundColor === HEADER_SHADE;
}
}
return false;
}
// True when the area is on: the style's option for a styled table, else the header fill
// on every cell of the first row / first column.
export function isHeaderStyled(state: EditorState, axis: HeaderAxis): boolean {
const styled = styledTable(state);
if (styled) return parseTableLook(styled.attrs.tableLook)[LOOK_REGION[axis]];
const info = headerCellPositions(state, axis);
if (!info || info.positions.length === 0) return false;
return info.positions.every(
(p) => state.doc.nodeAt(info.tableStart + p)?.attrs.backgroundColor === HEADER_SHADE,
);
}
export function toggleHeaderStyle(axis: HeaderAxis) {
return ({ state, tr, dispatch, commands }: CommandProps): boolean => {
// A styled table: flip the Table Style Option the gallery's checkbox shows.
if (styledTable(state)) {
return commands.setTableLook(LOOK_REGION[axis], !isHeaderStyled(state, axis));
}
const info = headerCellPositions(state, axis);
if (!info) return false;
if (!dispatch) return true;
const isHeader = isHeaderStyled(state, axis);
// The corner cell belongs to both headers, so turning one off must leave it alone
// while the other is still on.
const keepCorner = isHeader && isHeaderStyled(state, axis === 'row' ? 'column' : 'row');
const bold = state.schema.marks.bold;
// The fill is the header marker; bold is presentational (CSS on `.cell-header`, keyed
// off this fill) so it covers existing, typed, and pasted text alike. setNodeMarkup/
// removeMark don't shift positions, so the original cell positions stay valid.
for (const p of info.positions) {
if (keepCorner && p === info.corner) continue;
const cellPos = info.tableStart + p;
const cell = tr.doc.nodeAt(cellPos);
if (!cell) continue;
tr.setNodeMarkup(cellPos, undefined, { ...cell.attrs, backgroundColor: isHeader ? null : HEADER_SHADE });
// Turning off: strip any bold marks (incl. ones baked into the runs on export and
// read back on import) so the cells truly return to normal.
if (isHeader && bold) tr.removeMark(cellPos + 1, cellPos + cell.nodeSize - 1, bold);
}
dispatch(tr);
return true;
};
}
export const TableHeaderRow = Extension.create({
name: 'tableHeaderRow',
addCommands() {
return {
toggleHeaderRowStyle: () => toggleHeaderStyle('row'),
toggleHeaderColumnStyle: () => toggleHeaderStyle('column'),
};
},
});