@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.66

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.
Files changed (109) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.diff.test.tsx +8 -73
  4. package/src/App.export.test.tsx +2 -2
  5. package/src/App.plan-review.test.tsx +60 -412
  6. package/src/App.prototype.test.tsx +1 -8
  7. package/src/App.readonly.test.tsx +85 -62
  8. package/src/App.rendering.test.tsx +5 -6
  9. package/src/App.submit-confirm.test.tsx +32 -70
  10. package/src/App.test.tsx +136 -66
  11. package/src/App.toast.test.tsx +17 -8
  12. package/src/App.tsx +243 -284
  13. package/src/App.walk.test.tsx +1 -1
  14. package/src/app-screens.tsx +67 -52
  15. package/src/app-view-model.ts +6 -100
  16. package/src/components/AnnotationCard.stories.tsx +1 -1
  17. package/src/components/AnnotationCard.tsx +2 -2
  18. package/src/components/AppShell.stories.tsx +84 -0
  19. package/src/components/AppShell.tsx +127 -0
  20. package/src/components/Breadcrumb.stories.tsx +1 -1
  21. package/src/components/ChangesColumn.stories.tsx +39 -0
  22. package/src/components/ChangesColumn.tsx +147 -0
  23. package/src/components/CodeBlock.stories.tsx +1 -1
  24. package/src/components/CompletionOverlay.stories.tsx +1 -1
  25. package/src/components/ConfirmCard.stories.tsx +1 -1
  26. package/src/components/ConfirmCard.tsx +3 -4
  27. package/src/components/ConfirmDialog.stories.tsx +1 -1
  28. package/src/components/DiffSheet.stories.tsx +1 -1
  29. package/src/components/InboxList.stories.tsx +23 -8
  30. package/src/components/InboxList.tsx +45 -19
  31. package/src/components/KeybindsDialog.stories.tsx +1 -1
  32. package/src/components/KeybindsDialog.tsx +61 -17
  33. package/src/components/MarkerPopover.stories.tsx +1 -1
  34. package/src/components/MenuBar.stories.tsx +3 -12
  35. package/src/components/MenuBar.tsx +25 -56
  36. package/src/components/PromptDialog.stories.tsx +1 -1
  37. package/src/components/PrototypeSheet.stories.tsx +1 -1
  38. package/src/components/ReviewPanel.stories.tsx +15 -1
  39. package/src/components/ReviewRail.stories.tsx +15 -1
  40. package/src/components/ReviewRail.tsx +13 -6
  41. package/src/components/SettingsDialog.stories.tsx +7 -1
  42. package/src/components/SettingsDialog.tsx +112 -66
  43. package/src/components/SettingsRows.stories.tsx +1 -1
  44. package/src/components/ShellHeader.stories.tsx +50 -0
  45. package/src/components/ShellHeader.tsx +137 -0
  46. package/src/components/ThreadFooter.stories.tsx +30 -0
  47. package/src/components/ThreadFooter.tsx +49 -0
  48. package/src/components/ThreadView.selection.test.tsx +812 -0
  49. package/src/components/ThreadView.slash.test.tsx +113 -0
  50. package/src/components/ThreadView.stories.tsx +26 -0
  51. package/src/components/ThreadView.tsx +1708 -0
  52. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  53. package/src/components/ThreadsSidebar.tsx +57 -0
  54. package/src/components/Toast.stories.tsx +1 -1
  55. package/src/components/TreePane.stories.tsx +106 -0
  56. package/src/components/TreePane.tsx +156 -0
  57. package/src/components/WalkWizard.stories.tsx +1 -1
  58. package/src/components/__snapshots__/stories.test.tsx.snap +463 -305
  59. package/src/components/agent-launcher.stories.tsx +1 -1
  60. package/src/components/file-tree.test.ts +45 -0
  61. package/src/components/file-tree.ts +57 -0
  62. package/src/components/pages/Workspace.stories.tsx +181 -0
  63. package/src/components/primitives/Button.stories.tsx +1 -1
  64. package/src/components/primitives/Card.stories.tsx +1 -1
  65. package/src/components/primitives/Dialog.stories.tsx +1 -1
  66. package/src/components/primitives/IconButton.stories.tsx +36 -0
  67. package/src/components/primitives/IconButton.tsx +43 -0
  68. package/src/components/primitives/StatusBar.stories.tsx +1 -1
  69. package/src/components/primitives/Tabs.stories.tsx +1 -1
  70. package/src/components/primitives/Toolbar.stories.tsx +1 -1
  71. package/src/components/primitives/Tree.stories.tsx +124 -0
  72. package/src/components/primitives/Tree.tsx +87 -0
  73. package/src/components/primitives/icons.ts +38 -0
  74. package/src/components/primitives/tree-model.test.ts +99 -0
  75. package/src/components/primitives/tree-model.ts +137 -0
  76. package/src/components/quick-actions-editor.stories.tsx +1 -1
  77. package/src/components/session-tree.test.ts +114 -0
  78. package/src/components/session-tree.ts +94 -0
  79. package/src/components/stories-app.tsx +142 -82
  80. package/src/components/story-tree.test.ts +100 -0
  81. package/src/components/story.ts +55 -0
  82. package/src/components/theme-context.stories.tsx +1 -1
  83. package/src/diff-hunk-curate.ts +4 -104
  84. package/src/index.ts +1 -0
  85. package/src/intent-dispatch.test.ts +136 -3
  86. package/src/intent-dispatch.ts +92 -24
  87. package/src/key-bindings.test.ts +1 -1
  88. package/src/keymap.test.ts +4 -4
  89. package/src/keymap.ts +14 -7
  90. package/src/session-controller.curate.test.ts +89 -2
  91. package/src/session-controller.share.test.ts +195 -55
  92. package/src/session-controller.tree.test.ts +228 -0
  93. package/src/session-controller.ts +331 -72
  94. package/src/share.ts +114 -16
  95. package/src/test-support.test.ts +193 -1
  96. package/src/test-support.ts +64 -0
  97. package/src/theme.ts +4 -4
  98. package/src/thread-chords.test.ts +154 -0
  99. package/src/thread-chords.ts +144 -0
  100. package/src/thread-selection.test.ts +210 -0
  101. package/src/thread-selection.ts +243 -0
  102. package/src/tree-view.test.ts +128 -0
  103. package/src/tree-view.ts +148 -0
  104. package/src/use-settings-dialog.tsx +9 -0
  105. package/src/view-plan.ts +29 -12
  106. package/src/App.review-panel.test.tsx +0 -143
  107. package/src/components/PlanSheet.stories.tsx +0 -113
  108. package/src/components/PlanSheet.tsx +0 -419
  109. package/src/components/plan-sheet-run-style.ts +0 -88
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The session-level chords of the thread view. Letters in the thread view
3
+ * type a comment, so everything that acts on the session as a whole - submit,
4
+ * share, edit, the rail - lives on ctrl chords (cmd where the terminal
5
+ * delivers it). The guided walk is a diff-review mode and has no chord here. Pure: the view reports whether a composer is open, this maps
6
+ * a key to an intent or to nothing.
7
+ */
8
+
9
+ import type { Intent } from "./keymap";
10
+
11
+ export interface ThreadChordKey {
12
+ name: string;
13
+ ctrl?: boolean;
14
+ meta?: boolean;
15
+ super?: boolean;
16
+ }
17
+
18
+ export interface ThreadChordContext {
19
+ /** A composer owns the keyboard: chords never fire while one is open. */
20
+ composing: boolean;
21
+ /** Collaborators annotate only; submit, edit, and share are the owner's. */
22
+ isOwner: boolean;
23
+ /** A resolved session has nothing left to submit. */
24
+ resolved: boolean;
25
+ /** The rail shows the tree: next / previous move its selection instead of the cards. */
26
+ treeActive: boolean;
27
+ }
28
+
29
+ /** The cheatsheet rows for the chords, in the same order they are resolved. */
30
+ export const THREAD_CHORD_ENTRIES = [
31
+ { keys: "⌃enter", label: "submit the review" },
32
+ { keys: "⌃e", label: "edit in $EDITOR" },
33
+ { keys: "⌃s", label: "share" },
34
+ { keys: "⌃r", label: "cycle the rail" },
35
+ ] as const;
36
+
37
+ /** The rail and curation chords: option (alt) plus the plan sheet's old letter. */
38
+ export const RAIL_CHORD_ENTRIES = [
39
+ { keys: "⌥n / ⌥p", label: "next / previous card" },
40
+ { keys: "⌥e", label: "edit the card" },
41
+ { keys: "⌥⌫", label: "delete the card" },
42
+ { keys: "⌥r", label: "rename the author" },
43
+ { keys: "⌥x", label: "cut the block" },
44
+ { keys: "⌥u", label: "restore the last cut" },
45
+ { keys: "⌥w / ⌥s", label: "widen / narrow the rail" },
46
+ ] as const;
47
+
48
+ /** The tree chords: option plus a letter, all on the rail's Tree tab. */
49
+ export const TREE_CHORD_ENTRIES = [
50
+ { keys: "⌥t", label: "show / hide the tree" },
51
+ { keys: "⌥n / ⌥p", label: "next / previous entry" },
52
+ { keys: "⌥g", label: "go to the entry" },
53
+ { keys: "⌥b", label: "branch off the tip" },
54
+ { keys: "⌥l", label: "label a checkpoint" },
55
+ { keys: "⌥f", label: "fork the path" },
56
+ { keys: "⌥h", label: "fork and hand off" },
57
+ ] as const;
58
+
59
+ export function resolveThreadChord(
60
+ key: ThreadChordKey,
61
+ context: ThreadChordContext,
62
+ ): Intent | null {
63
+ if (context.composing) return null;
64
+ const modified = Boolean(key.ctrl || key.meta || key.super);
65
+
66
+ if (!modified) return null;
67
+ if (key.name === "return" || key.name === "enter") {
68
+ if (!context.isOwner) return READ_ONLY;
69
+
70
+ return context.resolved ? null : { type: "openSubmit" };
71
+ }
72
+ if (key.ctrl) return resolveSessionChord(key.name, context);
73
+ if (key.meta) return resolveRailChord(key.name, context);
74
+
75
+ return null;
76
+ }
77
+
78
+ /** The answers a blocked primitive gets - the same words the keymap uses. */
79
+ const READ_ONLY: Intent = { type: "status", message: "observer - read-only" };
80
+ const RESOLVED: Intent = { type: "status", message: "review submitted - read-only" };
81
+
82
+ /** Editing, deleting, cutting, and restoring change the review: gated by role and by a verdict. */
83
+ function mutating(intent: Intent, context: ThreadChordContext): Intent {
84
+ if (!context.isOwner) return READ_ONLY;
85
+ if (context.resolved) return RESOLVED;
86
+
87
+ return intent;
88
+ }
89
+
90
+ function resolveSessionChord(name: string, context: ThreadChordContext): Intent | null {
91
+ switch (name) {
92
+ case "e":
93
+ return mutating({ type: "edit" }, context);
94
+ case "s":
95
+ return context.isOwner ? { type: "share" } : READ_ONLY;
96
+ case "r":
97
+ return { type: "cycleReviewPanel" };
98
+ default:
99
+ return null;
100
+ }
101
+ }
102
+
103
+ // option arrives as meta in this terminal stack; the letters mirror the
104
+ // plan sheet's keymap so the rail keeps its muscle memory
105
+ function resolveRailChord(name: string, context: ThreadChordContext): Intent | null {
106
+ switch (name) {
107
+ case "n":
108
+ return context.treeActive ? { type: "treeMove", direction: 1 } : { type: "nextAnnotation" };
109
+ case "p":
110
+ return context.treeActive ? { type: "treeMove", direction: -1 } : { type: "prevAnnotation" };
111
+ case "t":
112
+ return { type: "toggleTree" };
113
+ // the tree is the owner's: a move, a branch, or a label changes what everyone sees
114
+ case "g":
115
+ return mutating({ type: "treeGo" }, context);
116
+ case "b":
117
+ return mutating({ type: "treeBranch" }, context);
118
+ case "l":
119
+ return mutating({ type: "treeLabel" }, context);
120
+ // a fork can be taken from a resolved review: only the role gates it
121
+ case "f":
122
+ return context.isOwner ? { type: "treeFork" } : READ_ONLY;
123
+ case "h":
124
+ return context.isOwner ? { type: "treeForkShare" } : READ_ONLY;
125
+ case "e":
126
+ return mutating({ type: "editCard" }, context);
127
+ case "backspace":
128
+ return mutating({ type: "removeAnnotation" }, context);
129
+ // renaming an author is a local display choice, open to every role
130
+ case "r":
131
+ return { type: "openRename" };
132
+ case "x":
133
+ return mutating({ type: "cut" }, context);
134
+ case "u":
135
+ return mutating({ type: "restoreCuration" }, context);
136
+ // letters only: escape-prefixed punctuation reads as control sequences, not keys
137
+ case "w":
138
+ return { type: "resizeReviewPanel", direction: 1 };
139
+ case "s":
140
+ return { type: "resizeReviewPanel", direction: -1 };
141
+ default:
142
+ return null;
143
+ }
144
+ }
@@ -0,0 +1,210 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ isCollapsed,
4
+ isDoubleClick,
5
+ nextWordStart,
6
+ orderedSpan,
7
+ positionAt,
8
+ previousWordStart,
9
+ snapSpanToWords,
10
+ snapToWords,
11
+ spanRangeInBlock,
12
+ tightenSpan,
13
+ wordRangeAt,
14
+ type LineGeometry,
15
+ } from "./thread-selection";
16
+
17
+ const TEXT = "All persistence lives in a new module.";
18
+ const BULLET = "store.ts - the store";
19
+ // block 3 wraps onto two rows, a blank row, then bullet block 4 on one row
20
+ const LINES: LineGeometry[] = [
21
+ { blockIndex: 3, start: 0, end: 21, x: 4, y: 10 },
22
+ { blockIndex: 3, start: 22, end: 38, x: 4, y: 11 },
23
+ { blockIndex: 4, start: 0, end: 20, x: 6, y: 13 },
24
+ ];
25
+ const textLengthOf = (blockIndex: number): number =>
26
+ blockIndex === 3 ? TEXT.length : BULLET.length;
27
+
28
+ describe("positionAt", () => {
29
+ test("maps a cell inside a line to the character under it", () => {
30
+ // Act
31
+ const position = positionAt(LINES, 4 + 6, 10);
32
+
33
+ // Assert: column 6 of the first line is the "r" in "persistence"
34
+ expect(position).toEqual({ blockIndex: 3, char: 6 });
35
+ expect(TEXT[6]).toBe("r");
36
+ });
37
+
38
+ test("past the right edge resolves to the position after the line's last char", () => {
39
+ // Assert
40
+ expect(positionAt(LINES, 90, 10)).toEqual({ blockIndex: 3, char: 21 });
41
+ });
42
+
43
+ test("a row without text resolves to the end of the closest line above", () => {
44
+ // Assert: the blank row between the blocks, and a row below everything
45
+ expect(positionAt(LINES, 30, 12)).toEqual({ blockIndex: 3, char: 38 });
46
+ expect(positionAt(LINES, 30, 40)).toEqual({ blockIndex: 4, char: 20 });
47
+ });
48
+
49
+ test("rows above all text resolve to the first character", () => {
50
+ // Assert
51
+ expect(positionAt(LINES, 30, 2)).toEqual({ blockIndex: 3, char: 0 });
52
+ });
53
+
54
+ test("a row of another block maps into that block", () => {
55
+ // Act
56
+ const position = positionAt(LINES, 6 + 3, 13);
57
+
58
+ // Assert
59
+ expect(position).toEqual({ blockIndex: 4, char: 3 });
60
+ expect(positionAt([], 5, 10)).toBeNull();
61
+ });
62
+ });
63
+
64
+ describe("orderedSpan and tightenSpan", () => {
65
+ test("a backward drag normalizes to document order", () => {
66
+ // Act
67
+ const span = orderedSpan({
68
+ anchor: { blockIndex: 4, char: 5 },
69
+ head: { blockIndex: 3, char: 6 },
70
+ });
71
+
72
+ // Assert
73
+ expect(span).toEqual({
74
+ start: { blockIndex: 3, char: 6 },
75
+ end: { blockIndex: 4, char: 5 },
76
+ });
77
+ });
78
+
79
+ test("a collapsed selection is no span", () => {
80
+ // Arrange
81
+ const selection = {
82
+ anchor: { blockIndex: 3, char: 6 },
83
+ head: { blockIndex: 3, char: 6 },
84
+ };
85
+
86
+ // Assert
87
+ expect(isCollapsed(selection)).toBe(true);
88
+ expect(orderedSpan(selection)).toBeNull();
89
+ });
90
+
91
+ test("a span opening at a block's end moves onto the next block", () => {
92
+ // Act: the pointer sat on the blank row above the bullet when the press landed
93
+ const tightened = tightenSpan(
94
+ {
95
+ start: { blockIndex: 3, char: TEXT.length },
96
+ end: { blockIndex: 4, char: 5 },
97
+ },
98
+ textLengthOf,
99
+ );
100
+
101
+ // Assert
102
+ expect(tightened).toEqual({
103
+ start: { blockIndex: 4, char: 0 },
104
+ end: { blockIndex: 4, char: 5 },
105
+ });
106
+ });
107
+
108
+ test("a span closing at a block's start moves back onto the previous block", () => {
109
+ // Act
110
+ const tightened = tightenSpan(
111
+ { start: { blockIndex: 3, char: 10 }, end: { blockIndex: 4, char: 0 } },
112
+ textLengthOf,
113
+ );
114
+
115
+ // Assert
116
+ expect(tightened).toEqual({
117
+ start: { blockIndex: 3, char: 10 },
118
+ end: { blockIndex: 3, char: TEXT.length },
119
+ });
120
+ });
121
+
122
+ test("a span with nothing left in it is null", () => {
123
+ // Assert
124
+ expect(
125
+ tightenSpan(
126
+ {
127
+ start: { blockIndex: 3, char: TEXT.length },
128
+ end: { blockIndex: 4, char: 0 },
129
+ },
130
+ textLengthOf,
131
+ ),
132
+ ).toBeNull();
133
+ });
134
+ });
135
+
136
+ describe("spanRangeInBlock", () => {
137
+ const span = {
138
+ start: { blockIndex: 3, char: 22 },
139
+ end: { blockIndex: 5, char: 4 },
140
+ };
141
+
142
+ test("the first block runs from the start to its end, middles fully, the last to the end char", () => {
143
+ // Assert
144
+ expect(spanRangeInBlock(span, 3, TEXT.length)).toEqual({
145
+ start: 22,
146
+ end: TEXT.length,
147
+ });
148
+ expect(spanRangeInBlock(span, 4, BULLET.length)).toEqual({
149
+ start: 0,
150
+ end: BULLET.length,
151
+ });
152
+ expect(spanRangeInBlock(span, 5, 30)).toEqual({ start: 0, end: 4 });
153
+ });
154
+
155
+ test("blocks outside the span have no range", () => {
156
+ // Assert
157
+ expect(spanRangeInBlock(span, 2, 10)).toBeNull();
158
+ expect(spanRangeInBlock(span, 6, 10)).toBeNull();
159
+ });
160
+ });
161
+
162
+ describe("word rules", () => {
163
+ test("the boundary rule attributes trailing whitespace to the preceding word", () => {
164
+ // Assert: char 3 is the space after "All"
165
+ expect(wordRangeAt(TEXT, 3)).toEqual({ start: 0, end: 3 });
166
+ });
167
+
168
+ test("snapToWords grows a partial range to whole words on both sides", () => {
169
+ // Assert: "rsistence liv" -> "persistence lives"
170
+ expect(snapToWords(TEXT, { start: 6, end: 19 })).toEqual({
171
+ start: 4,
172
+ end: 21,
173
+ });
174
+ });
175
+
176
+ test("snapSpanToWords snaps each end of a cross-block span within its own block", () => {
177
+ // Act: "rsistence ... st" -> "persistence ... store.ts"
178
+ const snapped = snapSpanToWords(
179
+ { start: { blockIndex: 3, char: 6 }, end: { blockIndex: 4, char: 2 } },
180
+ (blockIndex) => (blockIndex === 3 ? TEXT : BULLET),
181
+ );
182
+
183
+ // Assert
184
+ expect(snapped).toEqual({
185
+ start: { blockIndex: 3, char: 4 },
186
+ end: { blockIndex: 4, char: 8 },
187
+ });
188
+ });
189
+
190
+ test("word starts step in both directions and stop at the ends", () => {
191
+ // Assert
192
+ expect(nextWordStart(TEXT, 0)).toBe(4);
193
+ expect(nextWordStart(TEXT, 31)).toBeNull();
194
+ expect(previousWordStart(TEXT, 22)).toBe(16);
195
+ expect(previousWordStart(TEXT, 0)).toBeNull();
196
+ });
197
+ });
198
+
199
+ describe("isDoubleClick", () => {
200
+ test("same cell within the window counts, a moved or late press does not", () => {
201
+ // Arrange
202
+ const first = { time: 1000, x: 5, y: 7 };
203
+
204
+ // Assert
205
+ expect(isDoubleClick(first, { time: 1300, x: 5, y: 7 })).toBe(true);
206
+ expect(isDoubleClick(first, { time: 1300, x: 6, y: 7 })).toBe(false);
207
+ expect(isDoubleClick(first, { time: 1500, x: 5, y: 7 })).toBe(false);
208
+ expect(isDoubleClick(null, { time: 1300, x: 5, y: 7 })).toBe(false);
209
+ });
210
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The thread view's text-selection model: a pure, renderer-free description
3
+ * of where the caret is and what is marked, plus the hit-testing that maps a
4
+ * pointer cell onto a character. Character-precise (partial words), spanning
5
+ * wrapped lines and consecutive blocks, normalized for backward drags. Word snapping
6
+ * happens only where the grammar asks for it: the typing anchor for a bare
7
+ * caret, and double-click word mode.
8
+ *
9
+ * Isolated on purpose: the interaction can be unit-tested and benchmarked
10
+ * without a terminal, and the view layer only wires events to it.
11
+ */
12
+
13
+ export interface TextPosition {
14
+ blockIndex: number;
15
+ char: number;
16
+ }
17
+
18
+ /** Where one visual line of a block sits on screen, in cells. */
19
+ export interface LineGeometry {
20
+ blockIndex: number;
21
+ /** Char range of the block's text this line shows. */
22
+ start: number;
23
+ end: number;
24
+ x: number;
25
+ y: number;
26
+ }
27
+
28
+ /** The held selection: `anchor` is where the press landed, `head` follows the pointer. */
29
+ export interface Selection {
30
+ anchor: TextPosition;
31
+ head: TextPosition;
32
+ }
33
+
34
+ export interface CharRange {
35
+ start: number;
36
+ end: number;
37
+ }
38
+
39
+ /** An ordered stretch of text, possibly across consecutive blocks. */
40
+ export interface TextSpan {
41
+ start: TextPosition;
42
+ end: TextPosition;
43
+ }
44
+
45
+ /** Document order: block first, then character. */
46
+ export function comparePositions(left: TextPosition, right: TextPosition): number {
47
+ return left.blockIndex - right.blockIndex || left.char - right.char;
48
+ }
49
+
50
+ /** Word boundaries of a block's text. */
51
+ export function wordRanges(text: string): CharRange[] {
52
+ const ranges: CharRange[] = [];
53
+ const matcher = /\S+/g;
54
+ let match: RegExpExecArray | null;
55
+
56
+ while ((match = matcher.exec(text)) !== null) {
57
+ ranges.push({ start: match.index, end: match.index + match[0].length });
58
+ }
59
+
60
+ return ranges;
61
+ }
62
+
63
+ /**
64
+ * The marked word for a char offset: the word containing it, or - when the
65
+ * caret sits in the whitespace immediately after a word - that preceding
66
+ * word, never the next one.
67
+ */
68
+ export function wordIndexAt(text: string, char: number): number {
69
+ const words = wordRanges(text);
70
+
71
+ for (let index = 0; index < words.length; index++) {
72
+ if (char < words[index]!.start) return Math.max(0, index - 1);
73
+ if (char < words[index]!.end) return index;
74
+ }
75
+
76
+ return Math.max(0, words.length - 1);
77
+ }
78
+
79
+ /** The word range the boundary rule assigns to a char offset; null for empty text. */
80
+ export function wordRangeAt(text: string, char: number): CharRange | null {
81
+ const words = wordRanges(text);
82
+
83
+ return words[wordIndexAt(text, char)] ?? null;
84
+ }
85
+
86
+ /**
87
+ * Map a pointer cell to a text position over every registered line. A row
88
+ * without text (a blank row, a card) resolves to the end of the closest line
89
+ * above it, rows above all text to the first character, and a cell past a
90
+ * line's last cell to the position after that line's last character, so a
91
+ * drag that runs off the right edge still selects to the line end.
92
+ */
93
+ export function positionAt(lines: LineGeometry[], x: number, y: number): TextPosition | null {
94
+ if (lines.length === 0) return null;
95
+ const ordered = lines.toSorted((left, right) => left.y - right.y || left.x - right.x);
96
+ const onRow = ordered.find((line) => line.y === y);
97
+
98
+ if (onRow) {
99
+ const column = Math.max(0, Math.min(onRow.end - onRow.start, x - onRow.x));
100
+
101
+ return { blockIndex: onRow.blockIndex, char: onRow.start + column };
102
+ }
103
+ const above = ordered.findLast((line) => line.y < y);
104
+
105
+ if (above) return { blockIndex: above.blockIndex, char: above.end };
106
+ const first = ordered[0]!;
107
+
108
+ return { blockIndex: first.blockIndex, char: first.start };
109
+ }
110
+
111
+ /** True when nothing is held: the caret is a point, not a range. */
112
+ export function isCollapsed(selection: Selection): boolean {
113
+ return comparePositions(selection.anchor, selection.head) === 0;
114
+ }
115
+
116
+ /** The selection in document order, or null when collapsed. */
117
+ export function orderedSpan(selection: Selection): TextSpan | null {
118
+ if (isCollapsed(selection)) return null;
119
+
120
+ return comparePositions(selection.anchor, selection.head) < 0
121
+ ? { start: selection.anchor, end: selection.head }
122
+ : { start: selection.head, end: selection.anchor };
123
+ }
124
+
125
+ /**
126
+ * Pull a span's edges off block boundaries: opening at a block's end or
127
+ * closing at a block's start marks nothing there, so the edge moves onto the
128
+ * neighbouring block. Null when nothing remains.
129
+ */
130
+ export function tightenSpan(
131
+ span: TextSpan,
132
+ textLengthOf: (blockIndex: number) => number,
133
+ ): TextSpan | null {
134
+ let { start, end } = span;
135
+
136
+ while (start.blockIndex < end.blockIndex && start.char >= textLengthOf(start.blockIndex)) {
137
+ start = { blockIndex: start.blockIndex + 1, char: 0 };
138
+ }
139
+ while (end.blockIndex > start.blockIndex && end.char === 0) {
140
+ end = {
141
+ blockIndex: end.blockIndex - 1,
142
+ char: textLengthOf(end.blockIndex - 1),
143
+ };
144
+ }
145
+ const clampedEnd = {
146
+ blockIndex: end.blockIndex,
147
+ char: Math.min(end.char, textLengthOf(end.blockIndex)),
148
+ };
149
+
150
+ return comparePositions(start, clampedEnd) < 0 ? { start, end: clampedEnd } : null;
151
+ }
152
+
153
+ /** The part of a span that falls on one block, as a char range; null when none. */
154
+ export function spanRangeInBlock(
155
+ span: TextSpan,
156
+ blockIndex: number,
157
+ textLength: number,
158
+ ): CharRange | null {
159
+ if (blockIndex < span.start.blockIndex || blockIndex > span.end.blockIndex) return null;
160
+ const start = blockIndex === span.start.blockIndex ? span.start.char : 0;
161
+ const end = blockIndex === span.end.blockIndex ? Math.min(span.end.char, textLength) : textLength;
162
+
163
+ return end > start ? { start, end } : null;
164
+ }
165
+
166
+ /** Grow a char range outward to the word boundaries it touches (double-click word mode). */
167
+ export function snapToWords(text: string, range: CharRange): CharRange {
168
+ const words = wordRanges(text);
169
+ const first = words.find((word) => range.start < word.end) ?? words[words.length - 1];
170
+ const last = words.toReversed().find((word) => range.end > word.start) ?? words[0];
171
+
172
+ if (!first || !last) return range;
173
+
174
+ return {
175
+ start: Math.min(first.start, range.start),
176
+ end: Math.max(last.end, range.end),
177
+ };
178
+ }
179
+
180
+ /** Snap both ends of a span to word boundaries, each within its own block. */
181
+ export function snapSpanToWords(span: TextSpan, textOf: (blockIndex: number) => string): TextSpan {
182
+ if (span.start.blockIndex === span.end.blockIndex) {
183
+ const snapped = snapToWords(textOf(span.start.blockIndex), {
184
+ start: span.start.char,
185
+ end: span.end.char,
186
+ });
187
+
188
+ return {
189
+ start: { blockIndex: span.start.blockIndex, char: snapped.start },
190
+ end: { blockIndex: span.end.blockIndex, char: snapped.end },
191
+ };
192
+ }
193
+ const startText = textOf(span.start.blockIndex);
194
+ const endText = textOf(span.end.blockIndex);
195
+
196
+ return {
197
+ start: {
198
+ blockIndex: span.start.blockIndex,
199
+ char: snapToWords(startText, {
200
+ start: span.start.char,
201
+ end: startText.length,
202
+ }).start,
203
+ },
204
+ end: {
205
+ blockIndex: span.end.blockIndex,
206
+ char: snapToWords(endText, { start: 0, end: span.end.char }).end,
207
+ },
208
+ };
209
+ }
210
+
211
+ export interface ClickStamp {
212
+ time: number;
213
+ x: number;
214
+ y: number;
215
+ }
216
+
217
+ /** Two presses on the same cell inside this window read as a double-click. */
218
+ export const DOUBLE_CLICK_WINDOW_MS = 400;
219
+
220
+ export function isDoubleClick(previous: ClickStamp | null, current: ClickStamp): boolean {
221
+ return (
222
+ previous !== null &&
223
+ previous.x === current.x &&
224
+ previous.y === current.y &&
225
+ current.time - previous.time <= DOUBLE_CLICK_WINDOW_MS
226
+ );
227
+ }
228
+
229
+ /** Next word start after `char`, or null past the last word. */
230
+ export function nextWordStart(text: string, char: number): number | null {
231
+ const next = wordRanges(text).find((word) => word.start > char);
232
+
233
+ return next ? next.start : null;
234
+ }
235
+
236
+ /** Previous word start before `char`, or null before the first word. */
237
+ export function previousWordStart(text: string, char: number): number | null {
238
+ const previous = wordRanges(text)
239
+ .toReversed()
240
+ .find((word) => word.start < char);
241
+
242
+ return previous ? previous.start : null;
243
+ }