File
Blob: src/client/components/editor/extensions/context-aware-select-all.ts
| 1 | import { Extension } from "@tiptap/core"; |
| 2 | import type { Node as PMNode } from "@tiptap/pm/model"; |
| 3 | import { TextSelection, type Selection } from "@tiptap/pm/state"; |
| 4 | import { CellSelection, TableMap } from "@tiptap/pm/tables"; |
| 5 | import { allCellsSelection } from "./table/selection"; |
| 6 | |
| 7 | type 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. |
| 16 | const 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 | |
| 34 | function 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 | |
| 56 | function 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 | |
| 70 | export 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 | }); |