Skip to content
File

Blob: src/client/components/editor/extensions/table/state.ts

typescript278 lines
1import type { Node as PMNode } from "@tiptap/pm/model";
2import { PluginKey, type EditorState, type Transaction } from "@tiptap/pm/state";
3import { TableMap, isInTable } from "@tiptap/pm/tables";
4 
5export interface CornerOpenMenuState {
6 kind: "corner";
7 index: null;
8 tablePos: number;
9 tableKey: string;
10}
11 
12export interface TargetedOpenMenuState {
13 kind: "row" | "col";
14 index: number;
15 tablePos: number;
16 tableKey: string;
17 anchorCellPos: number;
18}
19 
20export type OpenMenuState = CornerOpenMenuState | TargetedOpenMenuState;
21 
22export interface TableHandlesPluginState {
23 openMenu: OpenMenuState | null;
24 isTyping: boolean;
25}
26 
27export interface TableHandlesMeta {
28 openMenu?: OpenMenuState | "close";
29 isTyping?: boolean;
30}
31 
32export interface ActiveCellInfo {
33 tablePos: number;
34 tableKey: string;
35 row: number;
36 col: number;
37}
38 
39interface ResolvedTableCell {
40 table: PMNode;
41 tablePos: number;
42 tableKey: string;
43 cellPos: number;
44 row: number;
45 col: number;
46 rowCount: number;
47 colCount: number;
48}
49 
50export type ResolvedOpenMenuState =
51 | (CornerOpenMenuState & {
52 table: PMNode;
53 rowCount: number;
54 colCount: number;
55 })
56 | (TargetedOpenMenuState & ResolvedTableCell);
57 
58export const tableHandlesKey = new PluginKey<TableHandlesPluginState>("tableHandles");
59 
60export function createTableHandlesPluginState(): TableHandlesPluginState {
61 return { openMenu: null, isTyping: false };
62}
63 
64export function tableKeyFromPos(pos: number): string {
65 return `t-${pos}`;
66}
67 
68export function tableHandleSelector(openMenu: OpenMenuState | null): string | null {
69 if (!openMenu) return null;
70 return openMenu.kind === "corner"
71 ? `[data-table-handle-kind="corner"][data-table-key="${openMenu.tableKey}"]`
72 : `[data-table-handle-kind="${openMenu.kind}"][data-table-key="${openMenu.tableKey}"][data-index="${openMenu.index}"]`;
73}
74 
75export function applyTableHandlesState(tr: Transaction, value: TableHandlesPluginState): TableHandlesPluginState {
76 const meta = tr.getMeta(tableHandlesKey) as TableHandlesMeta | undefined;
77 let next = value;
78 
79 if (meta) {
80 if (meta.openMenu !== undefined) {
81 next = { ...next, openMenu: meta.openMenu === "close" ? null : meta.openMenu };
82 }
83 if (meta.isTyping !== undefined) {
84 next = { ...next, isTyping: meta.isTyping };
85 }
86 }
87 
88 if (!next.openMenu) return next;
89 
90 const mappedTablePos = tr.mapping.map(next.openMenu.tablePos);
91 const baseMenu =
92 next.openMenu.kind === "corner"
93 ? { ...next.openMenu, tablePos: mappedTablePos, tableKey: tableKeyFromPos(mappedTablePos) }
94 : mapTargetedOpenMenu(tr, next.openMenu, mappedTablePos);
95 if (!baseMenu) {
96 return { ...next, openMenu: null };
97 }
98 
99 const resolved = resolveOpenMenuState(tr.doc, baseMenu);
100 if (!resolved) return { ...next, openMenu: null };
101 
102 return {
103 ...next,
104 openMenu: stripResolvedOpenMenu(resolved),
105 };
106}
107 
108export function activeCellInfo(state: EditorState): ActiveCellInfo | null {
109 if (!isInTable(state)) return null;
110 
111 const $pos = state.selection.$from;
112 for (let depth = $pos.depth; depth > 0; depth--) {
113 const node = $pos.node(depth);
114 const role = node.type.spec.tableRole;
115 if (role !== "cell" && role !== "header_cell") continue;
116 
117 const tableDepth = depth - 2;
118 if (tableDepth < 0) return null;
119 
120 const table = $pos.node(tableDepth);
121 if (table.type.spec.tableRole !== "table") return null;
122 
123 const tablePos = $pos.before(tableDepth);
124 const cell = resolveTableCell(state.doc, tablePos, $pos.before(depth));
125 if (!cell) return null;
126 
127 return {
128 tablePos: cell.tablePos,
129 tableKey: cell.tableKey,
130 row: cell.row,
131 col: cell.col,
132 };
133 }
134 
135 return null;
136}
137 
138export function isPrintableKey(event: KeyboardEvent): boolean {
139 if (event.ctrlKey || event.metaKey || event.altKey) return false;
140 const { key } = event;
141 if (key === "Backspace" || key === "Delete" || key === "Enter") return true;
142 return key.length === 1;
143}
144 
145function cellPosAt(tablePos: number, table: PMNode, row: number, col: number): number | null {
146 const map = TableMap.get(table);
147 if (row < 0 || row >= map.height || col < 0 || col >= map.width) return null;
148 return tablePos + 1 + map.positionAt(row, col, table);
149}
150 
151export function createOpenMenuState(
152 kind: OpenMenuState["kind"],
153 index: number | null,
154 tablePos: number,
155 table: PMNode,
156): OpenMenuState | null {
157 if (kind === "corner") {
158 return { kind, index: null, tablePos, tableKey: tableKeyFromPos(tablePos) };
159 }
160 if (index === null) return null;
161 
162 const anchorCellPos = findAnchorCellPos(kind, tablePos, table, index);
163 if (anchorCellPos === null) return null;
164 
165 return {
166 kind,
167 index,
168 tablePos,
169 tableKey: tableKeyFromPos(tablePos),
170 anchorCellPos,
171 };
172}
173 
174export function resolveOpenMenuState(doc: PMNode, openMenu: OpenMenuState): ResolvedOpenMenuState | null {
175 const table = doc.nodeAt(openMenu.tablePos);
176 if (!table || table.type.spec.tableRole !== "table") return null;
177 
178 const map = TableMap.get(table);
179 if (openMenu.kind === "corner") {
180 return {
181 ...openMenu,
182 table,
183 rowCount: map.height,
184 colCount: map.width,
185 };
186 }
187 
188 const cell = resolveTableCell(doc, openMenu.tablePos, openMenu.anchorCellPos);
189 if (!cell) return null;
190 
191 return {
192 ...openMenu,
193 ...cell,
194 index: openMenu.kind === "row" ? cell.row : cell.col,
195 };
196}
197 
198function resolveTableCell(doc: PMNode, tablePos: number, cellPos: number): ResolvedTableCell | null {
199 const table = doc.nodeAt(tablePos);
200 if (!table || table.type.spec.tableRole !== "table") return null;
201 
202 const map = TableMap.get(table);
203 const tableStart = tablePos + 1;
204 const cellStartInTable = cellPos - tableStart;
205 const mapIdx = map.map.indexOf(cellStartInTable);
206 if (mapIdx < 0) return null;
207 
208 return {
209 table,
210 tablePos,
211 tableKey: tableKeyFromPos(tablePos),
212 cellPos,
213 row: Math.floor(mapIdx / map.width),
214 col: mapIdx % map.width,
215 rowCount: map.height,
216 colCount: map.width,
217 };
218}
219 
220function mapTargetedOpenMenu(
221 tr: Transaction,
222 openMenu: TargetedOpenMenuState,
223 mappedTablePos: number,
224): TargetedOpenMenuState | null {
225 const mappedAnchor = tr.mapping.mapResult(openMenu.anchorCellPos, 1);
226 if (mappedAnchor.deleted) return null;
227 
228 return {
229 ...openMenu,
230 tablePos: mappedTablePos,
231 tableKey: tableKeyFromPos(mappedTablePos),
232 anchorCellPos: mappedAnchor.pos,
233 };
234}
235 
236function stripResolvedOpenMenu(openMenu: ResolvedOpenMenuState): OpenMenuState {
237 if (openMenu.kind === "corner") {
238 return {
239 kind: "corner",
240 index: null,
241 tablePos: openMenu.tablePos,
242 tableKey: openMenu.tableKey,
243 };
244 }
245 
246 return {
247 kind: openMenu.kind,
248 index: openMenu.index,
249 tablePos: openMenu.tablePos,
250 tableKey: openMenu.tableKey,
251 anchorCellPos: openMenu.anchorCellPos,
252 };
253}
254 
255function findAnchorCellPos(
256 kind: TargetedOpenMenuState["kind"],
257 tablePos: number,
258 table: PMNode,
259 index: number,
260): number | null {
261 const map = TableMap.get(table);
262 if (kind === "row") {
263 for (let col = 0; col < map.width; col++) {
264 const cellPos = cellPosAt(tablePos, table, index, col);
265 if (cellPos === null) continue;
266 if (map.findCell(cellPos - tablePos - 1).top === index) return cellPos;
267 }
268 return cellPosAt(tablePos, table, index, 0);
269 }
270 
271 for (let row = 0; row < map.height; row++) {
272 const cellPos = cellPosAt(tablePos, table, row, index);
273 if (cellPos === null) continue;
274 if (map.findCell(cellPos - tablePos - 1).left === index) return cellPos;
275 }
276 return cellPosAt(tablePos, table, 0, index);
277}