feat: add table header column
This commit is contained in:
1 parent
359915d6b2
commit
82699e653b
5 files changed
+81
-26
No files matched your search
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Editor, ChainedCommands } from '@tiptap/core';
|
||||
import { CellSelection } from '@tiptap/pm/tables';
|
||||
import { isHeaderRowStyled } from '../editor/extensions/tableHeaderRow';
|
||||
import { isHeaderStyled } from '../editor/extensions/tableHeaderRow';
|
||||
import ColorPicker from './ColorPicker.svelte';
|
||||
import TableBorderPicker from './TableBorderPicker.svelte';
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
@@ -31,8 +31,9 @@
|
||||
// Merge needs a multi-cell selection; re-evaluated per transaction via `tick`.
|
||||
const canMerge = $derived(tick >= 0 && !!editor && editor.can().mergeCells());
|
||||
|
||||
// Whether the first row is styled as a header (bold + light shading).
|
||||
const isHeaderRow = $derived(tick >= 0 && !!editor && isHeaderRowStyled(editor.state));
|
||||
// Whether the first row / first column is styled as a header (bold + light shading).
|
||||
const isHeaderRow = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'row'));
|
||||
const isHeaderCol = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'column'));
|
||||
|
||||
// Background colour of the selected cell(s): the uniform value, '' if mixed, null if
|
||||
// none. Drives the ColorPicker's "current" swatch (re-evaluated per `tick`).
|
||||
@@ -235,6 +236,23 @@
|
||||
<line x1="9" y1="6.5" x2="9" y2="16.5" stroke="currentColor" stroke-width="1.1"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tt-btn"
|
||||
class:active={isHeaderCol}
|
||||
title={t().table.headerColumn}
|
||||
aria-label={t().table.headerColumnAria}
|
||||
aria-pressed={isHeaderCol}
|
||||
onclick={() => run((c) => c.toggleHeaderColumnStyle())}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<rect x="1.5" y="1.5" width="15" height="15" rx="1" stroke="currentColor" stroke-width="1.3"/>
|
||||
<rect x="1.5" y="1.5" width="5" height="15" fill="currentColor" opacity="0.85"/>
|
||||
<line x1="6.5" y1="1.5" x2="6.5" y2="16.5" stroke="currentColor" stroke-width="1.1"/>
|
||||
<line x1="11.5" y1="1.5" x2="11.5" y2="16.5" stroke="currentColor" stroke-width="1.1"/>
|
||||
<line x1="6.5" y1="9" x2="16.5" y2="9" stroke="currentColor" stroke-width="1.1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -3,34 +3,38 @@ import type { CommandProps } from '@tiptap/core';
|
||||
import type { EditorState } from '@tiptap/pm/state';
|
||||
import { selectedRect, isInTable } from '@tiptap/pm/tables';
|
||||
|
||||
// Word/LibreOffice-style "header row" as a styling preset: the first row gets bold text
|
||||
// + a light grey fill. This is real formatting (bold mark + the backgroundColor cell
|
||||
// attr from tableCellBackground.ts), both of which already round-trip to/from ODF — so
|
||||
// the editor and the exported .odt look identical, with no page repetition (we
|
||||
// deliberately don't emit <table:table-header-rows>, which is what makes Word repeat it).
|
||||
// The shading doubles as the toggle's marker, so the active state survives save/reopen.
|
||||
// Word/LibreOffice "header row"/"header column" as a styling preset: the first row (or
|
||||
// column) gets bold text + a light grey fill, both of which round-trip 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';
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
tableHeaderRow: {
|
||||
toggleHeaderRowStyle: () => ReturnType;
|
||||
toggleHeaderColumnStyle: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Distinct cell positions (relative to tableStart) of the table's first row.
|
||||
function firstRowCellPositions(state: EditorState): { positions: number[]; tableStart: number } | null {
|
||||
// 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[] = [];
|
||||
for (let c = 0; c < map.width; c++) {
|
||||
const pos = map.map[c];
|
||||
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 };
|
||||
return { positions, tableStart, corner: map.map[0] };
|
||||
}
|
||||
|
||||
// True when the cursor sits in a header-shaded cell (so the Bold button should drive the
|
||||
@@ -46,33 +50,37 @@ export function isInHeaderCell(state: EditorState): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// True when every cell of the first row carries the header fill.
|
||||
export function isHeaderRowStyled(state: EditorState): boolean {
|
||||
const info = firstRowCellPositions(state);
|
||||
// True when every cell of the first row / first column carries the header fill.
|
||||
export function isHeaderStyled(state: EditorState, axis: HeaderAxis): boolean {
|
||||
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 toggleHeaderRowStyle() {
|
||||
export function toggleHeaderStyle(axis: HeaderAxis) {
|
||||
return ({ state, tr, dispatch }: CommandProps): boolean => {
|
||||
const info = firstRowCellPositions(state);
|
||||
const info = headerCellPositions(state, axis);
|
||||
if (!info) return false;
|
||||
if (!dispatch) return true;
|
||||
|
||||
const isHeader = isHeaderRowStyled(state);
|
||||
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 row truly returns to normal.
|
||||
// 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);
|
||||
@@ -85,7 +93,8 @@ export const TableHeaderRow = Extension.create({
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
toggleHeaderRowStyle: () => toggleHeaderRowStyle(),
|
||||
toggleHeaderRowStyle: () => toggleHeaderStyle('row'),
|
||||
toggleHeaderColumnStyle: () => toggleHeaderStyle('column'),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -240,6 +240,8 @@ const de: Messages = {
|
||||
noFill: 'Keine Füllung',
|
||||
headerRow: 'Kopfzeile (fett + Schattierung)',
|
||||
headerRowAria: 'Kopfzeile',
|
||||
headerColumn: 'Kopfspalte (fett + Schattierung)',
|
||||
headerColumnAria: 'Kopfspalte',
|
||||
insertTable: 'Tabelle einfügen',
|
||||
selectSize: 'Tabellengröße wählen',
|
||||
dimensions: (cols, rows) => `${cols} × ${rows}`,
|
||||
|
||||
@@ -244,6 +244,8 @@ const en = {
|
||||
noFill: 'No fill',
|
||||
headerRow: 'Header row (bold + shading)',
|
||||
headerRowAria: 'Header row',
|
||||
headerColumn: 'Header column (bold + shading)',
|
||||
headerColumnAria: 'Header column',
|
||||
insertTable: 'Insert table',
|
||||
selectSize: 'Select table size',
|
||||
dimensions: (cols: number, rows: number) => `${cols} × ${rows}`,
|
||||
|
||||
@@ -13,8 +13,9 @@ import { ResizableTableRow } from '../../src/lib/editor/extensions/tableRow';
|
||||
import { TableCellBackground } from '../../src/lib/editor/extensions/tableCellBackground';
|
||||
import {
|
||||
HEADER_SHADE,
|
||||
isHeaderRowStyled,
|
||||
toggleHeaderRowStyle,
|
||||
isHeaderStyled,
|
||||
toggleHeaderStyle,
|
||||
type HeaderAxis,
|
||||
} from '../../src/lib/editor/extensions/tableHeaderRow';
|
||||
|
||||
type N = any;
|
||||
@@ -43,12 +44,14 @@ function makeState(): EditorState {
|
||||
}
|
||||
|
||||
// Apply the command factory to a state, returning the next state.
|
||||
function run(state: EditorState): EditorState {
|
||||
function run(state: EditorState, axis: HeaderAxis = 'row'): EditorState {
|
||||
let next: EditorState | null = null;
|
||||
toggleHeaderRowStyle()({ state, tr: state.tr, dispatch: (tr: any) => { next = state.apply(tr); } } as any);
|
||||
toggleHeaderStyle(axis)({ state, tr: state.tr, dispatch: (tr: any) => { next = state.apply(tr); } } as any);
|
||||
return next ?? state;
|
||||
}
|
||||
|
||||
const isHeaderRowStyled = (s: EditorState) => isHeaderStyled(s, 'row');
|
||||
|
||||
const firstRow = (state: EditorState): N => state.doc.toJSON().content[0].content[0];
|
||||
const boldMarks = (c: N): boolean => (c.content[0].content[0]?.marks ?? []).some((m: N) => m.type === 'bold');
|
||||
|
||||
@@ -98,4 +101,25 @@ describe('toggleHeaderRowStyle', () => {
|
||||
expect(c.attrs.backgroundColor == null).toBe(true);
|
||||
expect(boldMarks(c)).toBe(false); // baked bold removed
|
||||
});
|
||||
|
||||
it('toggles the first column, and the shared corner survives turning one off', () => {
|
||||
let state = makeState();
|
||||
state = run(state, 'column');
|
||||
expect(isHeaderStyled(state, 'column')).toBe(true);
|
||||
const col = (s: EditorState) => s.doc.toJSON().content[0].content.map((r: N) => r.content[0]);
|
||||
for (const c of col(state)) expect(c.attrs.backgroundColor).toBe(HEADER_SHADE);
|
||||
// Cell (0,1) untouched.
|
||||
expect(state.doc.toJSON().content[0].content[0].content[1].attrs.backgroundColor == null).toBe(true);
|
||||
|
||||
// Both headers on: turning the row off keeps the corner (still the column header).
|
||||
state = run(state, 'row');
|
||||
expect(isHeaderStyled(state, 'row')).toBe(true);
|
||||
state = run(state, 'row');
|
||||
expect(isHeaderStyled(state, 'row')).toBe(false);
|
||||
expect(isHeaderStyled(state, 'column')).toBe(true);
|
||||
|
||||
state = run(state, 'column');
|
||||
expect(isHeaderStyled(state, 'column')).toBe(false);
|
||||
for (const c of col(state)) expect(c.attrs.backgroundColor == null).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user