Skip to content
File

Blob: src/client/components/editor/extensions/context-aware-select-all.ts

typescript95 lines
1import { Extension } from "@tiptap/core";
2import type { Node as PMNode } from "@tiptap/pm/model";
3import { TextSelection, type Selection } from "@tiptap/pm/state";
4import { CellSelection, TableMap } from "@tiptap/pm/tables";
5import { allCellsSelection } from "./table/selection";
6 
7type SelectStep = { kind: "text"; from: number; to: number } | { kind: "cells"; tablePos: number; table: PMNode };
8 
9// Order matters only conceptually — the walk visits $from.depth inward-to-outward,
10// so the innermost container (e.g. paragraph) naturally becomes the first rung
11// and parents follow. Including leaf text-blocks like paragraph / heading keeps
12// the first Ctrl+A press inside a single block's text, which in turn avoids
13// backspace crossing a block boundary and violating a parent's `block+` schema
14// (e.g. detailsContent) — the common case that would otherwise eject the cursor
15// out of the details block.
16const CONTAINER_TEXT_TYPES = new Set([
17 "paragraph",
18 "heading",
19 "codeBlock",
20 "detailsSummary",
21 "detailsContent",
22 "details",
23 "callout",
24 "tableCell",
25 "tableHeader",
26 "taskItem",
27 "listItem",
28 "taskList",
29 "bulletList",
30 "orderedList",
31 "blockquote",
32]);
33 
34function buildSteps(selection: Selection, doc: PMNode): SelectStep[] {
35 const { $from } = selection;
36 const steps: SelectStep[] = [];
37 
38 for (let depth = $from.depth; depth > 0; depth--) {
39 const node = $from.node(depth);
40 const name = node.type.name;
41 
42 if (name === "table") {
43 steps.push({ kind: "cells", tablePos: $from.before(depth), table: node });
44 continue;
45 }
46 
47 if (CONTAINER_TEXT_TYPES.has(name)) {
48 steps.push({ kind: "text", from: $from.start(depth), to: $from.end(depth) });
49 }
50 }
51 
52 steps.push({ kind: "text", from: 0, to: doc.content.size });
53 return steps;
54}
55 
56function stepMatchesSelection(selection: Selection, step: SelectStep): boolean {
57 if (step.kind === "text") {
58 return selection instanceof TextSelection && selection.from === step.from && selection.to === step.to;
59 }
60 if (!(selection instanceof CellSelection)) return false;
61 const map = TableMap.get(step.table);
62 const start = step.tablePos + 1;
63 const first = start + map.positionAt(0, 0, step.table);
64 const last = start + map.positionAt(map.height - 1, map.width - 1, step.table);
65 const anchor = selection.$anchorCell.pos;
66 const head = selection.$headCell.pos;
67 return (anchor === first && head === last) || (anchor === last && head === first);
68}
69 
70export const ContextAwareSelectAll = Extension.create({
71 name: "contextAwareSelectAll",
72 
73 addKeyboardShortcuts() {
74 return {
75 "Mod-a": () => {
76 const { state, view } = this.editor;
77 const steps = buildSteps(state.selection, state.doc);
78 for (const step of steps) {
79 if (stepMatchesSelection(state.selection, step)) continue;
80 if (step.kind === "text") {
81 const nextSelection = TextSelection.create(state.doc, step.from, step.to);
82 view.dispatch(state.tr.setSelection(nextSelection));
83 return true;
84 }
85 const nextSelection = allCellsSelection(state.doc, step.tablePos, step.table);
86 if (!nextSelection) continue;
87 view.dispatch(state.tr.setSelection(nextSelection));
88 return true;
89 }
90 return false;
91 },
92 };
93 },
94});