@openpresentation/opf-editor 0.10.6 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +333 -8
- package/dist/annotations.d.ts +71 -0
- package/dist/annotations.js +281 -0
- package/dist/assets.d.ts +67 -0
- package/dist/assets.js +176 -0
- package/dist/background-options.d.ts +48 -0
- package/dist/background-options.js +134 -0
- package/dist/block-convert.d.ts +64 -0
- package/dist/block-convert.js +142 -0
- package/dist/canvas.d.ts +16 -0
- package/dist/canvas.js +82 -21
- package/dist/chart-data.d.ts +32 -0
- package/dist/chart-data.js +101 -0
- package/dist/chart-options-panel.d.ts +16 -0
- package/dist/chart-options-panel.js +127 -0
- package/dist/chart-options.d.ts +49 -0
- package/dist/chart-options.js +157 -0
- package/dist/content-actions.d.ts +91 -0
- package/dist/content-actions.js +207 -0
- package/dist/content-controls.js +326 -0
- package/dist/data-grid.d.ts +37 -0
- package/dist/data-grid.js +1035 -0
- package/dist/design-controls.d.ts +43 -0
- package/dist/design-controls.js +1077 -0
- package/dist/design-options.d.ts +108 -0
- package/dist/design-options.js +412 -0
- package/dist/edit-helpers.js +52 -0
- package/dist/export.d.ts +77 -0
- package/dist/export.js +216 -0
- package/dist/find-panel.d.ts +44 -0
- package/dist/find-panel.js +431 -0
- package/dist/find-replace.d.ts +100 -0
- package/dist/find-replace.js +374 -0
- package/dist/grid-model.d.ts +135 -0
- package/dist/grid-model.js +836 -0
- package/dist/grid-text.d.ts +33 -0
- package/dist/grid-text.js +251 -0
- package/dist/image-crop.d.ts +59 -0
- package/dist/image-crop.js +336 -0
- package/dist/image-cropper.d.ts +29 -0
- package/dist/image-cropper.js +519 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.js +104 -171
- package/dist/numbering-panel.d.ts +21 -0
- package/dist/numbering-panel.js +200 -0
- package/dist/numbering.d.ts +62 -0
- package/dist/numbering.js +223 -0
- package/dist/outline-view.d.ts +17 -0
- package/dist/outline-view.js +278 -0
- package/dist/outline.d.ts +56 -0
- package/dist/outline.js +271 -0
- package/dist/persistence-ui.d.ts +24 -0
- package/dist/persistence-ui.js +81 -0
- package/dist/persistence.d.ts +105 -0
- package/dist/persistence.js +429 -0
- package/dist/review-panel.d.ts +44 -0
- package/dist/review-panel.js +359 -0
- package/dist/review.d.ts +75 -0
- package/dist/review.js +170 -0
- package/dist/slide-manager.d.ts +44 -0
- package/dist/slide-manager.js +695 -0
- package/dist/slides.d.ts +96 -0
- package/dist/slides.js +433 -0
- package/dist/switches.d.ts +26 -0
- package/dist/switches.js +127 -43
- package/dist/table-options.d.ts +80 -0
- package/dist/table-options.js +419 -0
- package/dist/table-structure.d.ts +30 -0
- package/dist/table-structure.js +92 -0
- package/dist/template-panel.d.ts +31 -0
- package/dist/template-panel.js +377 -0
- package/dist/templates.d.ts +126 -0
- package/dist/templates.js +331 -0
- package/dist/zip.d.ts +4 -0
- package/dist/zip.js +71 -0
- package/package.json +150 -10
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// Table style and cell merge (RR-06). A table is `{ columns?, rows }`; a cell is a plain value or a
|
|
2
|
+
// styled cell `{ value, style?, colSpan?, rowSpan? }`, and every grid position a merge covers holds
|
|
3
|
+
// `null`. These helpers edit that structure as one validated, undoable patch (a test guard on the
|
|
4
|
+
// table plus one replace), so the canvas redraws the styled and merged table and Undo restores it.
|
|
5
|
+
// Cells are addressed by `{ section: "header" | "body", row, column }`; `row` is ignored for the
|
|
6
|
+
// header section. Nothing here invents text: merging cells that hold text refuses unless you pass
|
|
7
|
+
// `join: true`, which keeps every word in the merged cell.
|
|
8
|
+
import { getValueAtPath, opfPathToJsonPointer, splitOpfPath, validateOpfDocument } from "./index.js";
|
|
9
|
+
import { checkedDocument, fail } from "./edit-helpers.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Table styles. A style is `{ header, banding, borders }`. The renderers' table defaults are a header
|
|
13
|
+
* filled with the scheme's primary color, surface-filled body cells, automatic text contrast and thin
|
|
14
|
+
* theme-colored borders; `theme` leaves each of those alone. Named presets for `setTableStyle`:
|
|
15
|
+
* `theme` (no overrides), `banded`, `grid`, `minimal` (plain header, horizontal rules) and `open`
|
|
16
|
+
* (plain header, no borders).
|
|
17
|
+
*/
|
|
18
|
+
export const TABLE_STYLE_PRESETS = Object.freeze({
|
|
19
|
+
theme: Object.freeze({ header: "theme", banding: false, borders: "theme" }),
|
|
20
|
+
banded: Object.freeze({ header: "theme", banding: true, borders: "theme" }),
|
|
21
|
+
grid: Object.freeze({ header: "theme", banding: false, borders: "grid" }),
|
|
22
|
+
minimal: Object.freeze({ header: "plain", banding: false, borders: "horizontal" }),
|
|
23
|
+
open: Object.freeze({ header: "plain", banding: false, borders: "none" }),
|
|
24
|
+
});
|
|
25
|
+
export const TABLE_HEADER_STYLES = Object.freeze(["theme", "plain", "accent"]);
|
|
26
|
+
export const TABLE_BORDER_STYLES = Object.freeze(["theme", "none", "horizontal", "grid"]);
|
|
27
|
+
// The style fields a table style owns; text color, alignment and padding are never touched.
|
|
28
|
+
const STYLE_KEYS = ["fill", "borders"];
|
|
29
|
+
const HEADER_FILL = Object.freeze({ plain: "surface", accent: "accent" });
|
|
30
|
+
const BAND_FILL = "background";
|
|
31
|
+
const RULE_COLOR = "textSecondary";
|
|
32
|
+
|
|
33
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
34
|
+
const isStyled = (cell) => isObject(cell) && Object.hasOwn(cell, "value");
|
|
35
|
+
const valueOf = (cell) => (isStyled(cell) ? cell.value : cell);
|
|
36
|
+
const isEmptyValue = (value) => value === null || value === undefined || value === "" || (Array.isArray(value) && value.every((run) => (typeof run === "string" ? run === "" : run?.text === "")));
|
|
37
|
+
const clone = (value) => structuredClone(value);
|
|
38
|
+
|
|
39
|
+
/** The table path and cell a selection path points at (`…table.rows.1.2`, `…table.rows.1.2.value`, `…table.columns.0`), or undefined. */
|
|
40
|
+
export function parseTableCellPath(path) {
|
|
41
|
+
let parts;
|
|
42
|
+
try {
|
|
43
|
+
parts = splitOpfPath(path);
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
for (let index = parts.length - 2; index >= 1; index -= 1) {
|
|
48
|
+
if (parts[index - 1] !== "table") continue;
|
|
49
|
+
const kind = parts[index];
|
|
50
|
+
const tablePath = parts.slice(0, index).join(".");
|
|
51
|
+
if (kind === "columns" && /^(0|[1-9]\d*)$/.test(parts[index + 1] ?? "")) return { tablePath, cell: { section: "header", row: 0, column: Number(parts[index + 1]) } };
|
|
52
|
+
if (kind === "rows" && /^(0|[1-9]\d*)$/.test(parts[index + 1] ?? "") && /^(0|[1-9]\d*)$/.test(parts[index + 2] ?? ""))
|
|
53
|
+
return { tablePath, cell: { section: "body", row: Number(parts[index + 1]), column: Number(parts[index + 2]) } };
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function tableAt(document, tablePath) {
|
|
59
|
+
const parts = splitOpfPath(tablePath);
|
|
60
|
+
const table = getValueAtPath(document, parts);
|
|
61
|
+
if (!isObject(table) || !Array.isArray(table.rows)) throw fail("table-not-found", "Choose a table (a path ending in .table).", { tablePath });
|
|
62
|
+
return { parts, table };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function widthOf(table) {
|
|
66
|
+
return Math.max(table.columns?.length ?? 0, ...table.rows.map((row) => row.length));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function lineOf(table, cell) {
|
|
70
|
+
if (cell.section === "header") {
|
|
71
|
+
if (!Array.isArray(table.columns)) throw fail("table-cell-not-found", "This table has no header row.", { cell });
|
|
72
|
+
return table.columns;
|
|
73
|
+
}
|
|
74
|
+
const line = table.rows[cell.row];
|
|
75
|
+
if (!Array.isArray(line)) throw fail("table-cell-not-found", `Row ${cell.row + 1} does not exist.`, { cell });
|
|
76
|
+
return line;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cellAt(table, cell) {
|
|
80
|
+
const line = lineOf(table, cell);
|
|
81
|
+
if (!Number.isInteger(cell.column) || cell.column < 0 || cell.column >= line.length) throw fail("table-cell-not-found", `Column ${cell.column + 1} does not exist.`, { cell });
|
|
82
|
+
return line[cell.column];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function spans(raw) {
|
|
86
|
+
return { colSpan: isStyled(raw) ? (raw.colSpan ?? 1) : 1, rowSpan: isStyled(raw) ? (raw.rowSpan ?? 1) : 1 };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Merge anchors of the table as rectangles `{section, row, column, rowSpan, colSpan}`. */
|
|
90
|
+
export function tableMerges(table) {
|
|
91
|
+
const merges = [];
|
|
92
|
+
const visit = (line, section, row) =>
|
|
93
|
+
line?.forEach((raw, column) => {
|
|
94
|
+
const { colSpan, rowSpan } = spans(raw);
|
|
95
|
+
if (colSpan > 1 || rowSpan > 1) merges.push({ section, row, column, rowSpan, colSpan });
|
|
96
|
+
});
|
|
97
|
+
visit(table.columns, "header", 0);
|
|
98
|
+
table.rows.forEach((line, row) => visit(line, "body", row));
|
|
99
|
+
return merges;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The state of one cell for a panel: its value, style, spans, whether it is a merge anchor and, for a
|
|
104
|
+
* covered position, the merge that covers it.
|
|
105
|
+
*/
|
|
106
|
+
export function describeTableCell(table, cell) {
|
|
107
|
+
const raw = cellAt(table, cell);
|
|
108
|
+
const merge = tableMerges(table).find(
|
|
109
|
+
(entry) => entry.section === cell.section && cell.column >= entry.column && cell.column < entry.column + entry.colSpan && (cell.section === "header" || (cell.row >= entry.row && cell.row < entry.row + entry.rowSpan)),
|
|
110
|
+
);
|
|
111
|
+
const anchor = merge && merge.column === cell.column && (cell.section === "header" || merge.row === cell.row);
|
|
112
|
+
const { colSpan, rowSpan } = spans(raw);
|
|
113
|
+
return {
|
|
114
|
+
...cell,
|
|
115
|
+
value: valueOf(raw),
|
|
116
|
+
style: isStyled(raw) ? (raw.style ?? {}) : {},
|
|
117
|
+
colSpan,
|
|
118
|
+
rowSpan,
|
|
119
|
+
merged: Boolean(merge),
|
|
120
|
+
anchor: Boolean(anchor),
|
|
121
|
+
covered: Boolean(merge) && !anchor,
|
|
122
|
+
...(merge && !anchor ? { anchorCell: { section: merge.section, row: merge.row, column: merge.column } } : {}),
|
|
123
|
+
columns: widthOf(table),
|
|
124
|
+
rows: table.rows.length,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function plainText(value) {
|
|
129
|
+
return Array.isArray(value) ? value.map((run) => (typeof run === "string" ? run : run.text)).join("") : String(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function joinedValue(values) {
|
|
133
|
+
const parts = values.filter((value) => !isEmptyValue(value));
|
|
134
|
+
if (parts.some(Array.isArray)) {
|
|
135
|
+
const runs = [];
|
|
136
|
+
parts.forEach((value, index) => {
|
|
137
|
+
if (index > 0) runs.push(" ");
|
|
138
|
+
runs.push(...(Array.isArray(value) ? clone(value) : [String(value)]));
|
|
139
|
+
});
|
|
140
|
+
return runs;
|
|
141
|
+
}
|
|
142
|
+
if (parts.length === 1) return parts[0];
|
|
143
|
+
return parts.map(plainText).join(" ");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function withCell(raw, patch) {
|
|
147
|
+
// A styled cell with no style and no spans collapses back to the plain value.
|
|
148
|
+
const merged = isStyled(raw) ? { ...raw } : { value: raw };
|
|
149
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
150
|
+
if (value === undefined || value === null || (key === "style" && isObject(value) && !Object.keys(value).length)) delete merged[key];
|
|
151
|
+
else merged[key] = value;
|
|
152
|
+
}
|
|
153
|
+
const keys = Object.keys(merged);
|
|
154
|
+
return keys.length === 1 && keys[0] === "value" ? merged.value : merged;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function transaction(document, tablePath, change, extra = {}) {
|
|
158
|
+
const { parts, table } = tableAt(document, tablePath);
|
|
159
|
+
const next = clone(table);
|
|
160
|
+
const info = change(next);
|
|
161
|
+
const pointer = opfPathToJsonPointer(parts);
|
|
162
|
+
const changed = JSON.stringify(table) !== JSON.stringify(next);
|
|
163
|
+
const patches = changed ? [{ op: "test", path: pointer, value: clone(table) }, { op: "replace", path: pointer, value: next }] : [];
|
|
164
|
+
const before = validateOpfDocument(document);
|
|
165
|
+
const result = changed ? checkedDocument(document, patches, before) : document;
|
|
166
|
+
return { ...extra, ...info, tablePath: parts.join("."), document: clone(result), patches, changed };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// --- merge and split ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
function unmerge(table, cell) {
|
|
172
|
+
const line = lineOf(table, cell);
|
|
173
|
+
const raw = line[cell.column];
|
|
174
|
+
const { colSpan, rowSpan } = spans(raw);
|
|
175
|
+
if (colSpan === 1 && rowSpan === 1) return false;
|
|
176
|
+
for (let r = 0; r < rowSpan; r += 1)
|
|
177
|
+
for (let c = 0; c < colSpan; c += 1) {
|
|
178
|
+
if (r === 0 && c === 0) continue;
|
|
179
|
+
const target = cell.section === "header" ? table.columns : table.rows[cell.row + r];
|
|
180
|
+
if (Array.isArray(target) && target[cell.column + c] === null) target[cell.column + c] = "";
|
|
181
|
+
}
|
|
182
|
+
line[cell.column] = withCell(raw, { colSpan: undefined, rowSpan: undefined });
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Compute the patch that merges a rectangle of cells starting at `cell` (the anchor, top left) into one
|
|
188
|
+
* cell spanning `colSpan` columns and `rowSpan` rows. The anchor keeps its value and style; covered
|
|
189
|
+
* positions become `null`. Throws `merge-would-lose-content` when a covered cell holds text, unless
|
|
190
|
+
* `options.join` is true (the words are joined with a space into the anchor, formatting kept), and
|
|
191
|
+
* `merge-overlap` for a rectangle that crosses another merged cell. A header cell merges across the
|
|
192
|
+
* header only (`rowSpan` 1). Re-merging an anchor first undoes its old merge.
|
|
193
|
+
*/
|
|
194
|
+
export function prepareTableMerge(document, tablePath, cell, span, options = {}) {
|
|
195
|
+
const colSpan = span.colSpan ?? 1;
|
|
196
|
+
const rowSpan = span.rowSpan ?? 1;
|
|
197
|
+
if (!Number.isInteger(colSpan) || colSpan < 1 || !Number.isInteger(rowSpan) || rowSpan < 1) throw fail("invalid-table-span", "Spans are whole numbers of 1 or more.", { span });
|
|
198
|
+
return transaction(document, tablePath, (table) => {
|
|
199
|
+
const target = { section: cell.section, row: cell.section === "header" ? 0 : cell.row, column: cell.column };
|
|
200
|
+
const existing = describeTableCell(table, target);
|
|
201
|
+
if (existing.covered) throw fail("merge-overlap", "This cell is covered by a merged cell. Select the merged cell itself.", { cell });
|
|
202
|
+
unmerge(table, target);
|
|
203
|
+
if (colSpan === 1 && rowSpan === 1) return { merged: false };
|
|
204
|
+
if (target.section === "header" && rowSpan > 1) throw fail("invalid-table-span", "A header cell cannot span into the body rows.", { span });
|
|
205
|
+
if (target.column + colSpan > widthOf(table)) throw fail("invalid-table-span", `Only ${widthOf(table) - target.column} column(s) are available from this cell.`, { span });
|
|
206
|
+
if (target.section === "body" && target.row + rowSpan > table.rows.length) throw fail("invalid-table-span", `Only ${table.rows.length - target.row} row(s) are available from this cell.`, { span });
|
|
207
|
+
const lines = [];
|
|
208
|
+
for (let r = 0; r < rowSpan; r += 1) lines.push({ row: target.row + r, line: target.section === "header" ? table.columns : table.rows[target.row + r] });
|
|
209
|
+
const covered = [];
|
|
210
|
+
for (const { row, line } of lines)
|
|
211
|
+
for (let c = 0; c < colSpan; c += 1) {
|
|
212
|
+
const column = target.column + c;
|
|
213
|
+
if (row === lines[0].row && c === 0) continue;
|
|
214
|
+
if (column >= line.length) throw fail("invalid-table-span", "The merged region leaves the table.", { span });
|
|
215
|
+
const raw = line[column];
|
|
216
|
+
const entry = describeTableCell(table, { section: target.section, row, column });
|
|
217
|
+
if (entry.merged) throw fail("merge-overlap", `The region crosses a merged cell at row ${row + 1}, column ${column + 1}. Split it first.`, { cell, span });
|
|
218
|
+
covered.push({ line, column, value: valueOf(raw) });
|
|
219
|
+
}
|
|
220
|
+
const filled = covered.filter((entry) => !isEmptyValue(entry.value));
|
|
221
|
+
let anchorValue = valueOf(lineOf(table, target)[target.column]);
|
|
222
|
+
if (filled.length) {
|
|
223
|
+
if (!options.join) throw fail("merge-would-lose-content", "Merging would hide text in the covered cells. Move it first, or merge and keep it by joining the text.", { cell, span, cells: filled.length });
|
|
224
|
+
anchorValue = joinedValue([anchorValue, ...filled.map((entry) => entry.value)]);
|
|
225
|
+
}
|
|
226
|
+
for (const entry of covered) entry.line[entry.column] = null;
|
|
227
|
+
const anchorLine = lineOf(table, target);
|
|
228
|
+
const raw = anchorLine[target.column];
|
|
229
|
+
anchorLine[target.column] = withCell(isStyled(raw) ? { ...raw, value: anchorValue } : anchorValue, { colSpan: colSpan > 1 ? colSpan : undefined, rowSpan: rowSpan > 1 ? rowSpan : undefined });
|
|
230
|
+
return { merged: true, joined: filled.length > 0 };
|
|
231
|
+
}, { action: "merge", cell, span: { colSpan, rowSpan } });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Compute the patch that splits a merged cell back into single cells (covered positions become empty text). */
|
|
235
|
+
export function prepareTableSplit(document, tablePath, cell) {
|
|
236
|
+
return transaction(document, tablePath, (table) => {
|
|
237
|
+
const target = { section: cell.section, row: cell.section === "header" ? 0 : cell.row, column: cell.column };
|
|
238
|
+
const entry = describeTableCell(table, target);
|
|
239
|
+
if (entry.covered) throw fail("table-cell-covered", "This position is covered by a merged cell. Select the merged cell to split it.", { cell });
|
|
240
|
+
return { split: unmerge(table, target) };
|
|
241
|
+
}, { action: "split", cell });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- cell style and table style ---------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Compute the patch that changes the style of the cells. `style` fields merge into each cell's style
|
|
248
|
+
* (fill, color, align, verticalAlign, padding, borders); a `null` field removes it, and `style: null`
|
|
249
|
+
* clears the whole style. Covered (merged) positions are skipped. A cell left with no style or spans
|
|
250
|
+
* becomes a plain value again. Style is appearance only: values and spans are untouched.
|
|
251
|
+
*/
|
|
252
|
+
export function prepareTableCellStyle(document, tablePath, cells, style) {
|
|
253
|
+
const list = Array.isArray(cells) ? cells : [cells];
|
|
254
|
+
if (!list.length) throw fail("table-cell-not-found", "Choose at least one cell.", {});
|
|
255
|
+
if (style !== null && !isObject(style)) throw fail("invalid-table-style", "A cell style is an object, or null to clear it.", { style });
|
|
256
|
+
return transaction(document, tablePath, (table) => {
|
|
257
|
+
let skipped = 0;
|
|
258
|
+
for (const cell of list) {
|
|
259
|
+
const target = { section: cell.section, row: cell.section === "header" ? 0 : cell.row, column: cell.column };
|
|
260
|
+
const line = lineOf(table, target);
|
|
261
|
+
const raw = cellAt(table, target);
|
|
262
|
+
if (raw === null) {
|
|
263
|
+
skipped += 1;
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
const current = isStyled(raw) ? { ...(raw.style ?? {}) } : {};
|
|
267
|
+
let nextStyle;
|
|
268
|
+
if (style === null) nextStyle = {};
|
|
269
|
+
else {
|
|
270
|
+
nextStyle = current;
|
|
271
|
+
for (const [key, value] of Object.entries(style)) {
|
|
272
|
+
if (value === null || value === undefined) delete nextStyle[key];
|
|
273
|
+
else nextStyle[key] = clone(value);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
line[target.column] = withCell(raw, { style: nextStyle });
|
|
277
|
+
}
|
|
278
|
+
return { skipped };
|
|
279
|
+
}, { action: "style" });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function bordersFor(mode) {
|
|
283
|
+
if (mode === "theme") return undefined;
|
|
284
|
+
const edge = (width) => ({ color: RULE_COLOR, width });
|
|
285
|
+
if (mode === "none") return { top: edge(0), right: edge(0), bottom: edge(0), left: edge(0) };
|
|
286
|
+
if (mode === "horizontal") return { top: edge(1), right: edge(0), bottom: edge(1), left: edge(0) };
|
|
287
|
+
return { top: edge(1), right: edge(1), bottom: edge(1), left: edge(1) };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function styleFor(table, style) {
|
|
291
|
+
const width = widthOf(table);
|
|
292
|
+
const result = [];
|
|
293
|
+
const visit = (section, row) => {
|
|
294
|
+
for (let column = 0; column < width; column += 1) {
|
|
295
|
+
const wanted = {};
|
|
296
|
+
if (section === "header" && style.header !== "theme") wanted.fill = HEADER_FILL[style.header];
|
|
297
|
+
if (section === "body" && style.banding && row % 2 === 1) wanted.fill = BAND_FILL;
|
|
298
|
+
const borders = bordersFor(style.borders);
|
|
299
|
+
if (borders) wanted.borders = borders;
|
|
300
|
+
result.push({ cell: { section, row, column }, style: wanted });
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
if (Array.isArray(table.columns)) visit("header", 0);
|
|
304
|
+
table.rows.forEach((_, row) => visit("body", row));
|
|
305
|
+
return result;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function resolveStyle(preset) {
|
|
309
|
+
const base = typeof preset === "string" ? TABLE_STYLE_PRESETS[preset] : preset;
|
|
310
|
+
if (!base || !isObject(base)) throw fail("invalid-table-style", `Choose a table style: ${Object.keys(TABLE_STYLE_PRESETS).join(", ")}, or { header, banding, borders }.`, { preset });
|
|
311
|
+
const style = { header: base.header ?? "theme", banding: Boolean(base.banding), borders: base.borders ?? "theme" };
|
|
312
|
+
if (!TABLE_HEADER_STYLES.includes(style.header)) throw fail("invalid-table-style", `Header is one of ${TABLE_HEADER_STYLES.join(", ")}.`, { preset });
|
|
313
|
+
if (!TABLE_BORDER_STYLES.includes(style.borders)) throw fail("invalid-table-style", `Borders are one of ${TABLE_BORDER_STYLES.join(", ")}.`, { preset });
|
|
314
|
+
return style;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Compute the patch that applies a table style: `{ header, banding, borders }` (a missing field means
|
|
319
|
+
* `theme`/false) or a named preset from `TABLE_STYLE_PRESETS`. It sets the header fill, the
|
|
320
|
+
* alternating body-row fill and the cell borders, and clears those fields on every other cell, so
|
|
321
|
+
* `theme` removes a previous style. Text color, alignment, padding, values and merges are kept.
|
|
322
|
+
* Fills use scheme roles (surface, accent, background), so they follow the color scheme and the
|
|
323
|
+
* renderers' automatic text contrast keeps text readable.
|
|
324
|
+
*/
|
|
325
|
+
export function prepareTableStyle(document, tablePath, preset) {
|
|
326
|
+
const style = resolveStyle(preset);
|
|
327
|
+
return transaction(document, tablePath, (table) => {
|
|
328
|
+
styleTable(table, style);
|
|
329
|
+
return {};
|
|
330
|
+
}, { action: "table-style", style });
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function styleTable(table, style) {
|
|
334
|
+
for (const { cell, style: wanted } of styleFor(table, style)) {
|
|
335
|
+
const line = lineOf(table, cell);
|
|
336
|
+
const raw = line[cell.column];
|
|
337
|
+
if (raw === null) continue;
|
|
338
|
+
const current = isStyled(raw) ? { ...(raw.style ?? {}) } : {};
|
|
339
|
+
for (const key of STYLE_KEYS) delete current[key];
|
|
340
|
+
line[cell.column] = withCell(raw, { style: { ...current, ...wanted } });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Apply a table style (a preset name or `{ header, banding, borders }`) to a table object in place, with the same
|
|
346
|
+
* rules as {@link prepareTableStyle}. The table structure operations use it to keep a recognised style
|
|
347
|
+
* (banding, header fill, borders) correct after rows or columns are inserted, deleted, moved or sorted.
|
|
348
|
+
*/
|
|
349
|
+
export function applyTableStyleToTable(table, preset) {
|
|
350
|
+
const style = resolveStyle(preset);
|
|
351
|
+
styleTable(table, style);
|
|
352
|
+
return style;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The table's current style: `{ header, banding, borders, preset }`, or "custom" in every field when
|
|
357
|
+
* its fills and borders are not one of the styles this module writes (for example hand-set fills).
|
|
358
|
+
* `preset` is the named preset the style equals, or "custom".
|
|
359
|
+
*/
|
|
360
|
+
export function readTableStyle(document, tablePath) {
|
|
361
|
+
return readTableStyleOfTable(tableAt(document, tablePath).table);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** {@link readTableStyle} for a table object. */
|
|
365
|
+
export function readTableStyleOfTable(table) {
|
|
366
|
+
const matches = (style) =>
|
|
367
|
+
styleFor(table, style).every(({ cell, style: wanted }) => {
|
|
368
|
+
const raw = lineOf(table, cell)[cell.column];
|
|
369
|
+
if (raw === null) return true;
|
|
370
|
+
const current = isStyled(raw) ? (raw.style ?? {}) : {};
|
|
371
|
+
return STYLE_KEYS.every((key) => JSON.stringify(current[key]) === JSON.stringify(wanted[key]));
|
|
372
|
+
});
|
|
373
|
+
for (const header of TABLE_HEADER_STYLES)
|
|
374
|
+
for (const banding of [false, true])
|
|
375
|
+
for (const borders of TABLE_BORDER_STYLES)
|
|
376
|
+
if (matches({ header, banding, borders })) {
|
|
377
|
+
const preset = Object.entries(TABLE_STYLE_PRESETS).find(([, value]) => value.header === header && value.banding === banding && value.borders === borders)?.[0] ?? "custom";
|
|
378
|
+
return { header, banding, borders, preset };
|
|
379
|
+
}
|
|
380
|
+
return { header: "custom", banding: "custom", borders: "custom", preset: "custom" };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Table structure (RR-24): insert, delete, move and sort rows and columns, the header row, cell values.
|
|
384
|
+
export * from "./table-structure.js";
|
|
385
|
+
|
|
386
|
+
// --- session forms ----------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
function commit(editor, prepared, meta = {}) {
|
|
389
|
+
const { document, patches, ...summary } = prepared;
|
|
390
|
+
void document;
|
|
391
|
+
if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
392
|
+
if (!prepared.changed) return { ...summary, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
|
|
393
|
+
const change = editor.applyPatch(patches, { ...meta, source: meta.source ?? "table-option", action: prepared.action, path: prepared.tablePath });
|
|
394
|
+
return { ...change, ...summary };
|
|
395
|
+
}
|
|
396
|
+
function checkEditor(editor) {
|
|
397
|
+
if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
398
|
+
}
|
|
399
|
+
/** Merge cells as one undoable transaction. See {@link prepareTableMerge}. */
|
|
400
|
+
export function mergeTableCells(editor, tablePath, cell, span, options = {}) {
|
|
401
|
+
checkEditor(editor);
|
|
402
|
+
const { meta, ...rest } = options;
|
|
403
|
+
return commit(editor, prepareTableMerge(editor.document, tablePath, cell, span, rest), meta);
|
|
404
|
+
}
|
|
405
|
+
/** Split a merged cell as one undoable transaction. */
|
|
406
|
+
export function splitTableCell(editor, tablePath, cell, meta = {}) {
|
|
407
|
+
checkEditor(editor);
|
|
408
|
+
return commit(editor, prepareTableSplit(editor.document, tablePath, cell), meta);
|
|
409
|
+
}
|
|
410
|
+
/** Style cells as one undoable transaction. See {@link prepareTableCellStyle}. */
|
|
411
|
+
export function setTableCellStyle(editor, tablePath, cells, style, meta = {}) {
|
|
412
|
+
checkEditor(editor);
|
|
413
|
+
return commit(editor, prepareTableCellStyle(editor.document, tablePath, cells, style), meta);
|
|
414
|
+
}
|
|
415
|
+
/** Apply a table style as one undoable transaction. See {@link prepareTableStyle}. */
|
|
416
|
+
export function setTableStyle(editor, tablePath, preset, meta = {}) {
|
|
417
|
+
checkEditor(editor);
|
|
418
|
+
return commit(editor, prepareTableStyle(editor.document, tablePath, preset), meta);
|
|
419
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { EditorSession } from "./index.js";
|
|
2
|
+
import type { GridAddress, GridCellEdit, GridChange, GridOptions, GridSortOptions, PreparedGridChange } from "./grid-model.js";
|
|
3
|
+
|
|
4
|
+
/** Set cell text (or typed values) in a table. Rich cells keep their runs' formatting and styled cells their style and spans. One patch; every problem is in `error.issues`. */
|
|
5
|
+
export declare function prepareTableCells(document: unknown, tablePath: string, edits: GridCellEdit[], options?: GridOptions): PreparedGridChange;
|
|
6
|
+
export declare function setTableCells(editor: EditorSession, tablePath: string, edits: GridCellEdit[], options?: GridOptions): GridChange;
|
|
7
|
+
/** Paste TSV/CSV text or rows at a cell, growing the table as needed. Covered (merged) positions refuse the paste. One patch. */
|
|
8
|
+
export declare function prepareTablePaste(document: unknown, tablePath: string, anchor: GridAddress, source: string | string[][], options?: GridOptions): PreparedGridChange;
|
|
9
|
+
export declare function pasteTableText(editor: EditorSession, tablePath: string, anchor: GridAddress, source: string | string[][], options?: GridOptions): GridChange;
|
|
10
|
+
/** Insert `count` rows so the first is body row `at`. A merged cell spanning that position grows. */
|
|
11
|
+
export declare function prepareTableInsertRows(document: unknown, tablePath: string, at: number, count?: number, options?: GridOptions): PreparedGridChange;
|
|
12
|
+
export declare function insertTableRows(editor: EditorSession, tablePath: string, at: number, count?: number, options?: GridOptions): GridChange;
|
|
13
|
+
/** Delete body rows. A merged cell spanning a deleted row shrinks and keeps its text. The last row cannot be deleted. */
|
|
14
|
+
export declare function prepareTableDeleteRows(document: unknown, tablePath: string, indices: number[], options?: GridOptions): PreparedGridChange;
|
|
15
|
+
export declare function deleteTableRows(editor: EditorSession, tablePath: string, indices: number[], options?: GridOptions): GridChange;
|
|
16
|
+
/** Move `count` rows from `from` so the first is at `to`. Refused when it would split a merged cell. */
|
|
17
|
+
export declare function prepareTableMoveRows(document: unknown, tablePath: string, from: number, to: number, count?: number, options?: GridOptions): PreparedGridChange;
|
|
18
|
+
export declare function moveTableRows(editor: EditorSession, tablePath: string, from: number, to: number, count?: number, options?: GridOptions): GridChange;
|
|
19
|
+
export declare function prepareTableInsertColumns(document: unknown, tablePath: string, at: number, count?: number, options?: GridOptions): PreparedGridChange;
|
|
20
|
+
export declare function insertTableColumns(editor: EditorSession, tablePath: string, at: number, count?: number, options?: GridOptions): GridChange;
|
|
21
|
+
export declare function prepareTableDeleteColumns(document: unknown, tablePath: string, indices: number[], options?: GridOptions): PreparedGridChange;
|
|
22
|
+
export declare function deleteTableColumns(editor: EditorSession, tablePath: string, indices: number[], options?: GridOptions): GridChange;
|
|
23
|
+
export declare function prepareTableMoveColumns(document: unknown, tablePath: string, from: number, to: number, count?: number, options?: GridOptions): PreparedGridChange;
|
|
24
|
+
export declare function moveTableColumns(editor: EditorSession, tablePath: string, from: number, to: number, count?: number, options?: GridOptions): GridChange;
|
|
25
|
+
/** Sort the body rows by a column, stably and by type (numbers, ISO dates, text; empty cells last). Rows joined by a row-spanning merge move as a block. */
|
|
26
|
+
export declare function prepareTableSort(document: unknown, tablePath: string, column: number, options?: GridSortOptions): PreparedGridChange;
|
|
27
|
+
export declare function sortTableRows(editor: EditorSession, tablePath: string, column: number, options?: GridSortOptions): GridChange;
|
|
28
|
+
/** Turn the header row on (the first row becomes the header, or `{ use: "new" }` adds an empty one) or off. */
|
|
29
|
+
export declare function prepareTableHeader(document: unknown, tablePath: string, enabled: boolean, options?: { use?: "first-row" | "new" }): PreparedGridChange;
|
|
30
|
+
export declare function setTableHeader(editor: EditorSession, tablePath: string, enabled: boolean, options?: { use?: "first-row" | "new"; meta?: Record<string, unknown> }): GridChange;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Table structure (RR-24): insert, delete, move and sort rows and columns, turn the header row on and off, and set cell text
|
|
2
|
+
// or paste spreadsheet text, each as one validated, undoable patch. These are the table-shaped forms of the data grid's operations
|
|
3
|
+
// (grid-model.js); merged cells stay whole (a merge that spans an inserted row grows, a deleted row shrinks it, a move that would
|
|
4
|
+
// split one is refused with the reason), and a table style this package wrote (banding, header fill, borders) is kept
|
|
5
|
+
// correct. A table cell is a plain value, rich runs or a styled cell; editing keeps runs' formatting and a cell's style and spans.
|
|
6
|
+
// Rows are addressed from 0 in the body; the header row is addressed as `{ section: "header", column }`.
|
|
7
|
+
//
|
|
8
|
+
// `prepare*` computes `{ document, patches, changed }` without touching a session; the session forms apply it as one undo step.
|
|
9
|
+
import {
|
|
10
|
+
prepareGridCells,
|
|
11
|
+
preparePaste,
|
|
12
|
+
prepareInsertRows,
|
|
13
|
+
prepareDeleteRows,
|
|
14
|
+
prepareMoveRows,
|
|
15
|
+
prepareInsertColumns,
|
|
16
|
+
prepareDeleteColumns,
|
|
17
|
+
prepareMoveColumns,
|
|
18
|
+
prepareSortRows,
|
|
19
|
+
setGridCells,
|
|
20
|
+
pasteGridText,
|
|
21
|
+
insertGridRows,
|
|
22
|
+
deleteGridRows,
|
|
23
|
+
moveGridRows,
|
|
24
|
+
insertGridColumns,
|
|
25
|
+
deleteGridColumns,
|
|
26
|
+
moveGridColumns,
|
|
27
|
+
sortGridRows,
|
|
28
|
+
prepareSetHeader,
|
|
29
|
+
setGridHeader,
|
|
30
|
+
} from "./grid-model.js";
|
|
31
|
+
export function prepareTableCells(document, tablePath, edits, options = {}) {
|
|
32
|
+
return prepareGridCells(document, tablePath, edits, { ...options, kind: "table" });
|
|
33
|
+
}
|
|
34
|
+
export function setTableCells(editor, tablePath, edits, options = {}) {
|
|
35
|
+
return setGridCells(editor, tablePath, edits, { ...options, kind: "table" });
|
|
36
|
+
}
|
|
37
|
+
export function prepareTablePaste(document, tablePath, anchor, source, options = {}) {
|
|
38
|
+
return preparePaste(document, tablePath, anchor, source, { ...options, kind: "table" });
|
|
39
|
+
}
|
|
40
|
+
export function pasteTableText(editor, tablePath, anchor, source, options = {}) {
|
|
41
|
+
return pasteGridText(editor, tablePath, anchor, source, { ...options, kind: "table" });
|
|
42
|
+
}
|
|
43
|
+
export function prepareTableInsertRows(document, tablePath, at, count = 1, options = {}) {
|
|
44
|
+
return prepareInsertRows(document, tablePath, at, count, { ...options, kind: "table" });
|
|
45
|
+
}
|
|
46
|
+
export function insertTableRows(editor, tablePath, at, count = 1, options = {}) {
|
|
47
|
+
return insertGridRows(editor, tablePath, at, count, { ...options, kind: "table" });
|
|
48
|
+
}
|
|
49
|
+
export function prepareTableDeleteRows(document, tablePath, indices, options = {}) {
|
|
50
|
+
return prepareDeleteRows(document, tablePath, indices, { ...options, kind: "table" });
|
|
51
|
+
}
|
|
52
|
+
export function deleteTableRows(editor, tablePath, indices, options = {}) {
|
|
53
|
+
return deleteGridRows(editor, tablePath, indices, { ...options, kind: "table" });
|
|
54
|
+
}
|
|
55
|
+
export function prepareTableMoveRows(document, tablePath, from, to, count = 1, options = {}) {
|
|
56
|
+
return prepareMoveRows(document, tablePath, from, to, count, { ...options, kind: "table" });
|
|
57
|
+
}
|
|
58
|
+
export function moveTableRows(editor, tablePath, from, to, count = 1, options = {}) {
|
|
59
|
+
return moveGridRows(editor, tablePath, from, to, count, { ...options, kind: "table" });
|
|
60
|
+
}
|
|
61
|
+
export function prepareTableInsertColumns(document, tablePath, at, count = 1, options = {}) {
|
|
62
|
+
return prepareInsertColumns(document, tablePath, at, count, { ...options, kind: "table" });
|
|
63
|
+
}
|
|
64
|
+
export function insertTableColumns(editor, tablePath, at, count = 1, options = {}) {
|
|
65
|
+
return insertGridColumns(editor, tablePath, at, count, { ...options, kind: "table" });
|
|
66
|
+
}
|
|
67
|
+
export function prepareTableDeleteColumns(document, tablePath, indices, options = {}) {
|
|
68
|
+
return prepareDeleteColumns(document, tablePath, indices, { ...options, kind: "table" });
|
|
69
|
+
}
|
|
70
|
+
export function deleteTableColumns(editor, tablePath, indices, options = {}) {
|
|
71
|
+
return deleteGridColumns(editor, tablePath, indices, { ...options, kind: "table" });
|
|
72
|
+
}
|
|
73
|
+
export function prepareTableMoveColumns(document, tablePath, from, to, count = 1, options = {}) {
|
|
74
|
+
return prepareMoveColumns(document, tablePath, from, to, count, { ...options, kind: "table" });
|
|
75
|
+
}
|
|
76
|
+
export function moveTableColumns(editor, tablePath, from, to, count = 1, options = {}) {
|
|
77
|
+
return moveGridColumns(editor, tablePath, from, to, count, { ...options, kind: "table" });
|
|
78
|
+
}
|
|
79
|
+
export function prepareTableSort(document, tablePath, column, options = {}) {
|
|
80
|
+
return prepareSortRows(document, tablePath, column, { ...options, kind: "table" });
|
|
81
|
+
}
|
|
82
|
+
export function sortTableRows(editor, tablePath, column, options = {}) {
|
|
83
|
+
return sortGridRows(editor, tablePath, column, { ...options, kind: "table" });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Turn the header row on or off. On uses the first row as the header, or adds an empty one with `{ use: "new" }`. See {@link prepareSetHeader}. */
|
|
87
|
+
export function prepareTableHeader(document, tablePath, enabled, options = {}) {
|
|
88
|
+
return prepareSetHeader(document, tablePath, enabled, options);
|
|
89
|
+
}
|
|
90
|
+
export function setTableHeader(editor, tablePath, enabled, options = {}) {
|
|
91
|
+
return setGridHeader(editor, tablePath, enabled, options);
|
|
92
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { EditorSession } from "./index.js";
|
|
2
|
+
import type { TemplateFill, TemplateFillApplyResult } from "./templates.js";
|
|
3
|
+
|
|
4
|
+
export interface TemplatePanelOptions {
|
|
5
|
+
editor: EditorSession;
|
|
6
|
+
/**
|
|
7
|
+
* Draw the live preview: return the SVG of one slide of `document` with `variables` (the values typed so far)
|
|
8
|
+
* as an SVG string, or a promise of one. Typically `renderSvg(document, { variables, slideIndex, ... })` from
|
|
9
|
+
* `@openpresentation/opf-render` with the host's fonts. Omit for no preview.
|
|
10
|
+
*/
|
|
11
|
+
renderPreview?: (input: { document: unknown; variables: Record<string, unknown>; slideIndex: number }) => string | Promise<string>;
|
|
12
|
+
/** The slide the preview starts on. Call `refresh()` when it changes. */
|
|
13
|
+
getSlideIndex?: () => number;
|
|
14
|
+
/** The text field a variable token is inserted into: its OPF path and, optionally, the selection as UTF-16 offsets. Omit to hide the insert section. */
|
|
15
|
+
getTarget?: () => { path: string; start?: number; end?: number } | undefined;
|
|
16
|
+
/** Called with every status or error message the panel shows. */
|
|
17
|
+
onStatus?: (message: string, info: { error: boolean }) => void;
|
|
18
|
+
/** Called after the presentation was filled. */
|
|
19
|
+
onApply?: (result: TemplateFillApplyResult) => void;
|
|
20
|
+
/** Read an uploaded image as a data URL (default: a FileReader). */
|
|
21
|
+
readFile?: (file: File) => string | Promise<string>;
|
|
22
|
+
}
|
|
23
|
+
export interface TemplatePanel {
|
|
24
|
+
element: HTMLElement;
|
|
25
|
+
/** The fill session behind the panel (values typed so far). */
|
|
26
|
+
fill: TemplateFill;
|
|
27
|
+
refresh(): void;
|
|
28
|
+
destroy(): void;
|
|
29
|
+
}
|
|
30
|
+
/** Mount the Fill template panel: typed inputs for every variable, filled and unfilled state, a live preview, one undoable fill, and token insertion. */
|
|
31
|
+
export declare function createTemplatePanel(container: HTMLElement, options: TemplatePanelOptions): TemplatePanel;
|