@cueloop/client 0.1.0-alpha.68 → 0.1.0-alpha.69
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/package.json +7 -6
- package/src/App.appearance.test.tsx +8 -6
- package/src/App.diff.test.tsx +3 -3
- package/src/App.export.test.tsx +9 -3
- package/src/App.plan-review.test.tsx +10 -8
- package/src/App.prototype-markdown.test.tsx +92 -0
- package/src/App.prototype.test.tsx +17 -28
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +3 -4
- package/src/App.test.tsx +14 -18
- package/src/App.tsx +117 -42
- package/src/App.walk.test.tsx +2 -2
- package/src/App.workbench.test.tsx +85 -14
- package/src/app-screens.tsx +26 -10
- package/src/app-view-model.ts +17 -3
- package/src/components/AnnotatableFileView.stories.tsx +42 -0
- package/src/components/AnnotatableFileView.test.tsx +102 -0
- package/src/components/AnnotatableFileView.tsx +126 -0
- package/src/components/AnnotationCards.tsx +6 -6
- package/src/components/AppShell.tsx +62 -15
- package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
- package/src/components/BareWorkbenchFileView.tsx +69 -0
- package/src/components/ChangesColumn.tsx +3 -2
- package/src/components/{DiffSheet.scroll.test.tsx → DiffContentView.scroll.test.tsx} +2 -2
- package/src/components/{DiffSheet.split.test.tsx → DiffContentView.split.test.tsx} +3 -3
- package/src/components/{DiffSheet.stories.tsx → DiffContentView.stories.tsx} +3 -3
- package/src/components/{DiffSheet.syntax.test.tsx → DiffContentView.syntax.test.tsx} +2 -2
- package/src/components/{DiffSheet.tsx → DiffContentView.tsx} +187 -28
- package/src/components/DiscussionMarkerRail.tsx +2 -2
- package/src/components/EditorGrid.tabs.test.tsx +17 -4
- package/src/components/EditorGrid.tsx +49 -19
- package/src/components/FileContentsView.test.tsx +3 -3
- package/src/components/FileContentsView.tsx +37 -17
- package/src/components/{SurfaceScrollbar.stories.tsx → OverlayScrollbar.stories.tsx} +5 -6
- package/src/components/{SurfaceScrollbar.test.tsx → OverlayScrollbar.test.tsx} +41 -13
- package/src/components/{SurfaceScrollbar.tsx → OverlayScrollbar.tsx} +49 -13
- package/src/components/ProjectTreeView.tsx +3 -2
- package/src/components/{PrototypeSheet.stories.tsx → PrototypeContentView.stories.tsx} +4 -5
- package/src/components/{PrototypeSheet.tsx → PrototypeContentView.tsx} +58 -58
- package/src/components/ScrollArea.stories.tsx +20 -0
- package/src/components/ScrollArea.tsx +44 -0
- package/src/components/SettingsDialog.tsx +3 -2
- package/src/components/{InboxList.stories.tsx → ThreadTree.stories.tsx} +5 -5
- package/src/components/{InboxList.tsx → ThreadTree.tsx} +6 -6
- package/src/components/ThreadsSidebar.tsx +5 -4
- package/src/components/TreePane.tsx +3 -2
- package/src/components/WelcomePlayground.stories.tsx +12 -0
- package/src/components/WelcomePlayground.test.tsx +47 -0
- package/src/components/WelcomePlayground.tsx +140 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +163 -283
- package/src/components/{diff-sheet-layout.test.ts → diff-content-view-layout.test.ts} +1 -1
- package/src/components/{diff-sheet-layout.ts → diff-content-view-layout.ts} +1 -1
- package/src/config.test.ts +19 -6
- package/src/config.ts +26 -6
- package/src/intent-dispatch.test.ts +2 -0
- package/src/intent-dispatch.ts +1 -1
- package/src/key-bindings.test.ts +1 -1
- package/src/key-bindings.ts +5 -1
- package/src/keymap.ts +1 -1
- package/src/prototype-browser.ts +8 -0
- package/src/prototype-pixels.tsx +35 -0
- package/src/ready-signal.test.ts +85 -0
- package/src/ready-signal.ts +52 -0
- package/src/session-controller.live-diff.test.ts +198 -0
- package/src/session-controller.ts +141 -26
- package/src/slash-palette.test.ts +54 -0
- package/src/slash-palette.ts +32 -18
- package/src/test-support.ts +42 -3
- package/src/theme-presets.ts +20 -0
- package/src/theme.ts +18 -0
- package/src/thread-chords.ts +1 -1
- package/src/use-annotation-surface.tsx +12 -2
- package/src/use-settings-dialog.tsx +4 -4
- package/src/view-diff.test.ts +44 -1
- package/src/view-diff.ts +81 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +0 -49
- package/src/components/AnnotationCard.stories.tsx +0 -94
- package/src/components/AnnotationCard.tsx +0 -250
- package/src/components/MarkerPopover.stories.tsx +0 -58
- package/src/components/MarkerPopover.tsx +0 -109
- package/src/components/WelcomeSurface.stories.tsx +0 -12
- package/src/components/WelcomeSurface.tsx +0 -52
package/src/view-diff.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { makeAnchor, type Annotation } from "@cueloop/schema";
|
|
3
|
+
import { diffRowAnchor, diffRowBlocks, diffRows, fileRowRange, fileTargetMarks } from "./view-diff";
|
|
3
4
|
|
|
4
5
|
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
5
6
|
index 111..222 100644
|
|
@@ -67,3 +68,45 @@ describe("diffRowAnchor", () => {
|
|
|
67
68
|
expect(anchor.suffix.length).toBeGreaterThan(0);
|
|
68
69
|
});
|
|
69
70
|
});
|
|
71
|
+
|
|
72
|
+
describe("fileTargetMarks", () => {
|
|
73
|
+
// two files whose added line is byte-identical; a note on one must never mark the other
|
|
74
|
+
const TWO_FILE = `diff --git a/a.ts b/a.ts
|
|
75
|
+
--- a/a.ts
|
|
76
|
+
+++ b/a.ts
|
|
77
|
+
@@ -1,1 +1,2 @@
|
|
78
|
+
const keep = 0;
|
|
79
|
+
+const dup = 1;
|
|
80
|
+
diff --git a/b.ts b/b.ts
|
|
81
|
+
--- a/b.ts
|
|
82
|
+
+++ b/b.ts
|
|
83
|
+
@@ -1,1 +1,2 @@
|
|
84
|
+
const keep = 0;
|
|
85
|
+
+const dup = 1;
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
test("a file-target note marks only its own file, never identical text in another", () => {
|
|
89
|
+
// Arrange - anchor the added line inside a.ts, file-relative like the controller does
|
|
90
|
+
const rows = diffRows(TWO_FILE);
|
|
91
|
+
const range = fileRowRange(rows, "a.ts")!;
|
|
92
|
+
const fileRows = rows.slice(range.start, range.end);
|
|
93
|
+
const addRelative = fileRows.findIndex((row) => row.kind === "add");
|
|
94
|
+
const quote = fileRows[addRelative]!.text.replace(/\n$/, "");
|
|
95
|
+
const anchor = makeAnchor(diffRowBlocks(fileRows), addRelative, 0, quote.length, addRelative);
|
|
96
|
+
const annotation: Annotation = {
|
|
97
|
+
id: "a1",
|
|
98
|
+
kind: "comment",
|
|
99
|
+
anchor,
|
|
100
|
+
target: { kind: "file", path: "a.ts", rev: "worktree" },
|
|
101
|
+
body: "note",
|
|
102
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Act
|
|
106
|
+
const marked = [...fileTargetMarks([annotation], rows).keys()];
|
|
107
|
+
|
|
108
|
+
// Assert - every marked row sits inside a.ts's range, and b.ts's identical line is untouched
|
|
109
|
+
expect(marked.length).toBeGreaterThan(0);
|
|
110
|
+
expect(marked.every((index) => index >= range.start && index < range.end)).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
package/src/view-diff.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs";
|
|
9
9
|
import {
|
|
10
|
+
annotationTarget,
|
|
10
11
|
isAddressed,
|
|
11
12
|
resolveAnchor,
|
|
12
13
|
type Annotation,
|
|
@@ -167,6 +168,86 @@ export function marksByRows(
|
|
|
167
168
|
return marksByIndex;
|
|
168
169
|
}
|
|
169
170
|
|
|
171
|
+
/**
|
|
172
|
+
* A whole file rendered as context rows, so the diff sheet can show and annotate a plain file the
|
|
173
|
+
* same way it does a diff: every line is unchanged, numbered on both sides (the file view collapses
|
|
174
|
+
* that to one number). A trailing newline does not add an empty final row.
|
|
175
|
+
*/
|
|
176
|
+
export function fileContentsRows(path: string, contents: string): DiffRow[] {
|
|
177
|
+
const lines = contents.replace(/\n$/, "").split("\n");
|
|
178
|
+
|
|
179
|
+
return lines.map((text, index) => ({
|
|
180
|
+
kind: "ctx",
|
|
181
|
+
text,
|
|
182
|
+
file: path,
|
|
183
|
+
oldLine: index + 1,
|
|
184
|
+
newLine: index + 1,
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The contiguous row range a file occupies in the aggregate diff, or null when it is not shown. */
|
|
189
|
+
export function fileRowRange(rows: DiffRow[], path: string): { start: number; end: number } | null {
|
|
190
|
+
const start = rows.findIndex((row) => row.file === path);
|
|
191
|
+
|
|
192
|
+
if (start === -1) return null;
|
|
193
|
+
let end = start;
|
|
194
|
+
|
|
195
|
+
while (end < rows.length && rows[end]!.file === path) end++;
|
|
196
|
+
|
|
197
|
+
return { start, end };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Marks for file-target notes on the aggregate working-tree diff. Each note resolves against only
|
|
202
|
+
* its own file's rows, then the row index and span shift back to aggregate coordinates - so a quote
|
|
203
|
+
* never attaches to the same text in another file or on the opposite diff side.
|
|
204
|
+
*/
|
|
205
|
+
export function fileTargetMarks(
|
|
206
|
+
annotations: Annotation[],
|
|
207
|
+
rows: DiffRow[],
|
|
208
|
+
focusedId?: string,
|
|
209
|
+
): Map<number, Mark[]> {
|
|
210
|
+
const byFile = new Map<string, Annotation[]>();
|
|
211
|
+
|
|
212
|
+
for (const annotation of annotations) {
|
|
213
|
+
const target = annotationTarget(annotation);
|
|
214
|
+
|
|
215
|
+
if (target.kind !== "file") continue;
|
|
216
|
+
const forPath = byFile.get(target.path) ?? [];
|
|
217
|
+
|
|
218
|
+
forPath.push(annotation);
|
|
219
|
+
byFile.set(target.path, forPath);
|
|
220
|
+
}
|
|
221
|
+
const result = new Map<number, Mark[]>();
|
|
222
|
+
|
|
223
|
+
for (const [path, fileAnnotations] of byFile) {
|
|
224
|
+
const range = fileRowRange(rows, path);
|
|
225
|
+
|
|
226
|
+
if (!range) continue;
|
|
227
|
+
const base = range.start;
|
|
228
|
+
const fileMarks = marksByRows(fileAnnotations, rows.slice(range.start, range.end), focusedId);
|
|
229
|
+
|
|
230
|
+
for (const [relativeRow, marks] of fileMarks) {
|
|
231
|
+
result.set(
|
|
232
|
+
relativeRow + base,
|
|
233
|
+
marks.map((mark) =>
|
|
234
|
+
mark.span
|
|
235
|
+
? {
|
|
236
|
+
...mark,
|
|
237
|
+
span: {
|
|
238
|
+
start: { ...mark.span.start, blockIndex: mark.span.start.blockIndex + base },
|
|
239
|
+
end: { ...mark.span.end, blockIndex: mark.span.end.blockIndex + base },
|
|
240
|
+
},
|
|
241
|
+
}
|
|
242
|
+
: mark,
|
|
243
|
+
),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
170
251
|
/**
|
|
171
252
|
* Comments per file path across the diff: each discussion counts its comments (root plus
|
|
172
253
|
* replies) toward the file its span ends in. Feeds the changed-files tree and tab badges, so a
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The composer's auto-grow row math as a pure function: hard newlines and soft
|
|
3
|
-
* wrapping both add rows, growth stops at the cap, and a non-positive width
|
|
4
|
-
* (before layout is known) degrades to a hard-newline count. The live textarea
|
|
5
|
-
* scrolls internally past the cap, so this only needs to get the growth curve
|
|
6
|
-
* and the ceiling right.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { describe, expect, test } from "bun:test";
|
|
10
|
-
import { COMPOSE_MAX_ROWS, composeRowCount } from "./AnnotationCard";
|
|
11
|
-
|
|
12
|
-
describe("composeRowCount", () => {
|
|
13
|
-
const width = 10;
|
|
14
|
-
|
|
15
|
-
test("empty and short text are one row", () => {
|
|
16
|
-
expect(composeRowCount("", width)).toBe(1);
|
|
17
|
-
expect(composeRowCount("hi", width)).toBe(1);
|
|
18
|
-
expect(composeRowCount("exactly-10", width)).toBe(1); // length 10 / width 10
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("hard newlines add rows", () => {
|
|
22
|
-
expect(composeRowCount("a\nb", width)).toBe(2);
|
|
23
|
-
expect(composeRowCount("a\nb\nc", width)).toBe(3);
|
|
24
|
-
// a blank trailing line still occupies a row
|
|
25
|
-
expect(composeRowCount("a\n", width)).toBe(2);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test("a soft-wrapped long line grows the box", () => {
|
|
29
|
-
expect(composeRowCount("a".repeat(11), width)).toBe(2); // ceil(11/10)
|
|
30
|
-
expect(composeRowCount("a".repeat(25), width)).toBe(3); // ceil(25/10)
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
test("wrapping and hard newlines combine", () => {
|
|
34
|
-
// 1 row for "short" plus ceil(21/10)=3 rows for the long line
|
|
35
|
-
expect(composeRowCount("short\n" + "b".repeat(21), width)).toBe(4);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test("growth is capped", () => {
|
|
39
|
-
expect(composeRowCount("a".repeat(100), width)).toBe(COMPOSE_MAX_ROWS);
|
|
40
|
-
expect(composeRowCount("l\ni\nn\ne\ns\nx", width)).toBe(COMPOSE_MAX_ROWS);
|
|
41
|
-
expect(composeRowCount("a\nb", width, 1)).toBe(1); // custom cap
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("a non-positive width counts hard newlines only, never divides by zero", () => {
|
|
45
|
-
expect(composeRowCount("a".repeat(100), 0)).toBe(1);
|
|
46
|
-
expect(composeRowCount("a\nb\nc", 0)).toBe(3);
|
|
47
|
-
expect(composeRowCount("a".repeat(100), -5)).toBe(1);
|
|
48
|
-
});
|
|
49
|
-
});
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { DARK } from "../theme";
|
|
3
|
-
import type { Story, StoryMeta } from "./story";
|
|
4
|
-
import { AnnotationCard } from "./AnnotationCard";
|
|
5
|
-
|
|
6
|
-
export const meta: StoryMeta = { title: "Cards/AnnotationCard" };
|
|
7
|
-
|
|
8
|
-
const draftCallbacks = { onInput: () => {}, onSave: () => {}, onCancel: () => {} };
|
|
9
|
-
|
|
10
|
-
export const CommentDraft: Story = {
|
|
11
|
-
render: () => (
|
|
12
|
-
<AnnotationCard
|
|
13
|
-
kind="comment"
|
|
14
|
-
quote="persists sessions"
|
|
15
|
-
draft={{ text: "Which daemon?", ...draftCallbacks }}
|
|
16
|
-
/>
|
|
17
|
-
),
|
|
18
|
-
expectedColors: [DARK.blue],
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export const SavedSelected: Story = {
|
|
22
|
-
render: () => (
|
|
23
|
-
<AnnotationCard
|
|
24
|
-
kind="comment"
|
|
25
|
-
quote="persists sessions"
|
|
26
|
-
saved={{
|
|
27
|
-
body: "Which daemon owns this?",
|
|
28
|
-
isSelected: true,
|
|
29
|
-
isOrphan: false,
|
|
30
|
-
author: "me",
|
|
31
|
-
tone: DARK.blue,
|
|
32
|
-
editing: null,
|
|
33
|
-
onPress: () => {},
|
|
34
|
-
}}
|
|
35
|
-
/>
|
|
36
|
-
),
|
|
37
|
-
expectedColors: [DARK.blue],
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export const SavedCollaborator: Story = {
|
|
41
|
-
render: () => (
|
|
42
|
-
<AnnotationCard
|
|
43
|
-
kind="comment"
|
|
44
|
-
quote="persists sessions"
|
|
45
|
-
saved={{
|
|
46
|
-
body: "Which daemon owns this?",
|
|
47
|
-
isSelected: false,
|
|
48
|
-
isOrphan: false,
|
|
49
|
-
author: "Priya",
|
|
50
|
-
tone: DARK.blue,
|
|
51
|
-
editing: null,
|
|
52
|
-
onPress: () => {},
|
|
53
|
-
}}
|
|
54
|
-
/>
|
|
55
|
-
),
|
|
56
|
-
expectedColors: [DARK.blue],
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
export const SavedOrphan: Story = {
|
|
60
|
-
render: () => (
|
|
61
|
-
<AnnotationCard
|
|
62
|
-
kind="comment"
|
|
63
|
-
quote="a removed passage"
|
|
64
|
-
saved={{
|
|
65
|
-
body: "The passage is gone.",
|
|
66
|
-
isSelected: false,
|
|
67
|
-
isOrphan: true,
|
|
68
|
-
author: "me",
|
|
69
|
-
tone: DARK.blue,
|
|
70
|
-
editing: null,
|
|
71
|
-
onPress: () => {},
|
|
72
|
-
}}
|
|
73
|
-
/>
|
|
74
|
-
),
|
|
75
|
-
expectedColors: [DARK.blue],
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
export const SavedEditing: Story = {
|
|
79
|
-
render: () => (
|
|
80
|
-
<AnnotationCard
|
|
81
|
-
kind="comment"
|
|
82
|
-
quote="move the store"
|
|
83
|
-
saved={{
|
|
84
|
-
body: "old body",
|
|
85
|
-
isSelected: true,
|
|
86
|
-
isOrphan: false,
|
|
87
|
-
author: "me",
|
|
88
|
-
tone: DARK.blue,
|
|
89
|
-
editing: { text: "new body", ...draftCallbacks },
|
|
90
|
-
onPress: () => {},
|
|
91
|
-
}}
|
|
92
|
-
/>
|
|
93
|
-
),
|
|
94
|
-
};
|
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* One annotation component in three modes: a draft composer (bordered card
|
|
3
|
-
* inline in the document flow), a saved rail card, and the rail card editing
|
|
4
|
-
* its body in place. Compose, saved, and re-edit share this rendering path,
|
|
5
|
-
* so the kind-color and quote treatment can never drift between them.
|
|
6
|
-
*
|
|
7
|
-
* Bodies are multiline: the composer is a textarea (wrap, undo/redo). Plain ⏎
|
|
8
|
-
* stays the save key while ⌥/Alt+⏎ and shift+⏎ insert a new line (the Slack
|
|
9
|
-
* convention). The box auto-grows with the wrapped text up to a cap, then
|
|
10
|
-
* scrolls internally.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import React, { useEffect, useRef, useState } from "react";
|
|
14
|
-
import type { KeyBinding, TextareaRenderable } from "@opentui/core";
|
|
15
|
-
import type { Theme } from "../theme";
|
|
16
|
-
import { useComponentTheme } from "./theme-context";
|
|
17
|
-
import { Card, cardHeight } from "./primitives/Card";
|
|
18
|
-
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
19
|
-
import { Button } from "./primitives/Button";
|
|
20
|
-
import { Toolbar } from "./primitives/Toolbar";
|
|
21
|
-
import { truncateToSingleLine } from "./truncate-text";
|
|
22
|
-
|
|
23
|
-
export interface AnnotationDraft {
|
|
24
|
-
text: string;
|
|
25
|
-
onInput: (text: string) => void;
|
|
26
|
-
onSave: () => void;
|
|
27
|
-
onCancel: () => void;
|
|
28
|
-
/**
|
|
29
|
-
* When set, ⏎ in the textarea saves through this handler. The plan and diff
|
|
30
|
-
* composers leave it unset because the app keymap owns ⏎ there; the prototype
|
|
31
|
-
* composer sets it because the app keymap is suppressed while it is open.
|
|
32
|
-
*/
|
|
33
|
-
onSubmit?: () => void;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface AnnotationSaved {
|
|
37
|
-
body: string;
|
|
38
|
-
isSelected: boolean;
|
|
39
|
-
isOrphan: boolean;
|
|
40
|
-
/** Border-title author: a collaborator's display name, or "me" for the reviewer's own note. */
|
|
41
|
-
author: string;
|
|
42
|
-
/** Border + title color for this card (comment cards read blue). */
|
|
43
|
-
tone: string;
|
|
44
|
-
/** Non-null while the card body is being rewritten in place. */
|
|
45
|
-
editing: AnnotationDraft | null;
|
|
46
|
-
onPress: () => void;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface AnnotationCardProps {
|
|
50
|
-
id?: string;
|
|
51
|
-
kind: string;
|
|
52
|
-
quote: string;
|
|
53
|
-
draft?: AnnotationDraft;
|
|
54
|
-
saved?: AnnotationSaved;
|
|
55
|
-
theme?: Theme;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* ⏎ submits (which keeps the textarea from inserting its default newline; the
|
|
60
|
-
* grammar owns the actual save). ⌥/Alt+⏎ and shift+⏎ insert a newline so the
|
|
61
|
-
* body can grow multiline without leaving the composer.
|
|
62
|
-
*
|
|
63
|
-
* Option/Alt reaches a binding as `meta`, not `option`: a binding key here is
|
|
64
|
-
* built from name+ctrl+shift+meta+super only, so the newline binding matches on
|
|
65
|
-
* `meta`. This also overrides the textarea default that maps meta+⏎ to submit.
|
|
66
|
-
*/
|
|
67
|
-
const COMPOSE_KEY_BINDINGS: KeyBinding[] = [
|
|
68
|
-
{ name: "return", action: "submit" },
|
|
69
|
-
{ name: "return", meta: true, action: "newline" },
|
|
70
|
-
{ name: "return", shift: true, action: "newline" },
|
|
71
|
-
];
|
|
72
|
-
|
|
73
|
-
/** The composer never grows past this many rows; beyond it the textarea scrolls. */
|
|
74
|
-
export const COMPOSE_MAX_ROWS = 4;
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Visible row count for the composer, so a soft-wrapped long line grows the box
|
|
78
|
-
* just like a hard newline does. Each logical line takes `ceil(length /
|
|
79
|
-
* contentWidth)` visual rows (at least one), summed over the hard-newline lines
|
|
80
|
-
* and capped. A non-positive width (before layout is known) counts hard
|
|
81
|
-
* newlines only. Beyond the cap the textarea scrolls internally and keeps the
|
|
82
|
-
* caret line in view.
|
|
83
|
-
*/
|
|
84
|
-
export function composeRowCount(
|
|
85
|
-
text: string,
|
|
86
|
-
contentWidth: number,
|
|
87
|
-
cap: number = COMPOSE_MAX_ROWS,
|
|
88
|
-
): number {
|
|
89
|
-
const usableWidth = contentWidth > 0 ? contentWidth : Number.MAX_SAFE_INTEGER;
|
|
90
|
-
let visualRowCount = 0;
|
|
91
|
-
|
|
92
|
-
for (const line of text.split("\n")) {
|
|
93
|
-
visualRowCount += Math.max(1, Math.ceil(line.length / usableWidth));
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return Math.min(cap, Math.max(1, visualRowCount));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function DraftEditor({
|
|
100
|
-
draft,
|
|
101
|
-
rows,
|
|
102
|
-
onRowsChange,
|
|
103
|
-
theme,
|
|
104
|
-
}: {
|
|
105
|
-
draft: AnnotationDraft;
|
|
106
|
-
/** Current visible height in rows; owned by the parent so the card frame agrees. */
|
|
107
|
-
rows: number;
|
|
108
|
-
onRowsChange: (rows: number) => void;
|
|
109
|
-
theme?: Theme;
|
|
110
|
-
}): React.ReactNode {
|
|
111
|
-
const tokens = useComponentTheme(theme);
|
|
112
|
-
const editorRef = useRef<TextareaRenderable | null>(null);
|
|
113
|
-
// re-edit opens with the caret after the existing body, like a text field
|
|
114
|
-
const initialTextLength = useRef(draft.text.length);
|
|
115
|
-
|
|
116
|
-
useEffect(() => {
|
|
117
|
-
const editor = editorRef.current;
|
|
118
|
-
|
|
119
|
-
if (!editor) return;
|
|
120
|
-
editor.cursorOffset = initialTextLength.current;
|
|
121
|
-
// the box is laid out now, so its width is known: size to the wrapped body
|
|
122
|
-
onRowsChange(composeRowCount(editor.plainText, editor.width));
|
|
123
|
-
}, [onRowsChange]);
|
|
124
|
-
|
|
125
|
-
return (
|
|
126
|
-
<>
|
|
127
|
-
<textarea
|
|
128
|
-
ref={editorRef}
|
|
129
|
-
focused
|
|
130
|
-
initialValue={draft.text}
|
|
131
|
-
placeholder="write a note..."
|
|
132
|
-
keyBindings={COMPOSE_KEY_BINDINGS}
|
|
133
|
-
{...(draft.onSubmit ? { onSubmit: draft.onSubmit } : {})}
|
|
134
|
-
onContentChange={() => {
|
|
135
|
-
const editor = editorRef.current;
|
|
136
|
-
|
|
137
|
-
if (!editor) return;
|
|
138
|
-
draft.onInput(editor.plainText);
|
|
139
|
-
onRowsChange(composeRowCount(editor.plainText, editor.width));
|
|
140
|
-
}}
|
|
141
|
-
style={{
|
|
142
|
-
height: rows,
|
|
143
|
-
backgroundColor: "transparent",
|
|
144
|
-
focusedBackgroundColor: "transparent",
|
|
145
|
-
textColor: tokens.text,
|
|
146
|
-
focusedTextColor: tokens.text,
|
|
147
|
-
}}
|
|
148
|
-
/>
|
|
149
|
-
<Toolbar>
|
|
150
|
-
<Button variant="solid" marginRight={2} onPress={draft.onSave} theme={theme}>
|
|
151
|
-
{" Save "}
|
|
152
|
-
</Button>
|
|
153
|
-
<Button onPress={draft.onCancel} theme={theme}>
|
|
154
|
-
{" Cancel "}
|
|
155
|
-
</Button>
|
|
156
|
-
</Toolbar>
|
|
157
|
-
</>
|
|
158
|
-
);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export function AnnotationCard({
|
|
162
|
-
id,
|
|
163
|
-
kind,
|
|
164
|
-
quote,
|
|
165
|
-
draft,
|
|
166
|
-
saved,
|
|
167
|
-
theme,
|
|
168
|
-
}: AnnotationCardProps): React.ReactNode {
|
|
169
|
-
const tokens = useComponentTheme(theme);
|
|
170
|
-
// the composer wears the same blue as the saved comment card it becomes
|
|
171
|
-
const kindColor = tokens.blue;
|
|
172
|
-
const activeDraft = draft ?? saved?.editing ?? null;
|
|
173
|
-
// The composer's visible height, grown from the wrapped text. It is shared
|
|
174
|
-
// with the card frame (contentRows) so the border never clips the textarea
|
|
175
|
-
// or the toolbar. Seeded from hard newlines, then corrected once the mounted
|
|
176
|
-
// textarea reports its measured width.
|
|
177
|
-
const [editorRowCount, setEditorRowCount] = useState(() =>
|
|
178
|
-
// Width 0 is the "layout not known yet" contract: count hard newlines only,
|
|
179
|
-
// until the mounted textarea reports its measured width below.
|
|
180
|
-
composeRowCount(activeDraft?.text ?? "", 0),
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
if (draft) {
|
|
184
|
-
const primitive = "comment on";
|
|
185
|
-
|
|
186
|
-
return (
|
|
187
|
-
<Card
|
|
188
|
-
title={` ${primitive} "${truncateToSingleLine(quote, 20)}" `}
|
|
189
|
-
contentRows={editorRowCount + 1}
|
|
190
|
-
borderColor={kindColor}
|
|
191
|
-
backgroundColor="transparent"
|
|
192
|
-
theme={theme}
|
|
193
|
-
>
|
|
194
|
-
<DraftEditor
|
|
195
|
-
draft={draft}
|
|
196
|
-
rows={editorRowCount}
|
|
197
|
-
onRowsChange={setEditorRowCount}
|
|
198
|
-
theme={theme}
|
|
199
|
-
/>
|
|
200
|
-
</Card>
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
const card = saved!;
|
|
204
|
-
// border = "what + who" (+ an orphaned flag when the anchor no longer resolves)
|
|
205
|
-
const flags = card.isOrphan ? ["ORPHANED"] : [];
|
|
206
|
-
const title = ` ${[kind.toUpperCase(), card.author, ...flags].join(" · ")} `;
|
|
207
|
-
// every saved card is bordered, so the border + side padding always eat this width
|
|
208
|
-
const borderInset = 4;
|
|
209
|
-
// a bordered box must declare a height or it collapses: quote + body, or the editor
|
|
210
|
-
const contentRows = card.editing ? editorRowCount + 2 : 2;
|
|
211
|
-
|
|
212
|
-
return (
|
|
213
|
-
<box
|
|
214
|
-
id={id}
|
|
215
|
-
title={title}
|
|
216
|
-
style={{
|
|
217
|
-
flexDirection: "column",
|
|
218
|
-
height: cardHeight(contentRows),
|
|
219
|
-
border: true,
|
|
220
|
-
borderStyle: FRAME_BORDER_STYLE,
|
|
221
|
-
// the border (and its title) always wears the card's tone - salmon for
|
|
222
|
-
// own notes, blue for a collaborator's; the card background stays
|
|
223
|
-
// transparent so it sits flat on the terminal theme. Selection reads
|
|
224
|
-
// from the quote line taking the card's tone (below) and from the
|
|
225
|
-
// matching text highlight in the document.
|
|
226
|
-
borderColor: card.tone,
|
|
227
|
-
backgroundColor: "transparent",
|
|
228
|
-
paddingLeft: 1,
|
|
229
|
-
paddingRight: 1,
|
|
230
|
-
}}
|
|
231
|
-
onMouseUp={card.onPress}
|
|
232
|
-
>
|
|
233
|
-
<text
|
|
234
|
-
fg={card.isSelected ? card.tone : tokens.textDim}
|
|
235
|
-
>{`"${truncateToSingleLine(quote, 26 - borderInset)}"`}</text>
|
|
236
|
-
{card.editing ? (
|
|
237
|
-
<DraftEditor
|
|
238
|
-
draft={card.editing}
|
|
239
|
-
rows={editorRowCount}
|
|
240
|
-
onRowsChange={setEditorRowCount}
|
|
241
|
-
theme={theme}
|
|
242
|
-
/>
|
|
243
|
-
) : (
|
|
244
|
-
<text fg={card.isOrphan ? tokens.textDim : tokens.textMuted}>
|
|
245
|
-
{truncateToSingleLine(card.body, 28 - borderInset)}
|
|
246
|
-
</text>
|
|
247
|
-
)}
|
|
248
|
-
</box>
|
|
249
|
-
);
|
|
250
|
-
}
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import React from "react";
|
|
2
|
-
import { DARK } from "../theme";
|
|
3
|
-
import { DEFAULT_QUICK_ACTIONS } from "../config";
|
|
4
|
-
import type { Story, StoryMeta } from "./story";
|
|
5
|
-
import { MarkerPopover } from "./MarkerPopover";
|
|
6
|
-
|
|
7
|
-
export const meta: StoryMeta = { title: "Overlays/MarkerPopover" };
|
|
8
|
-
|
|
9
|
-
const callbacks = {
|
|
10
|
-
onComment: () => {},
|
|
11
|
-
onCut: () => {},
|
|
12
|
-
onOpenActions: () => {},
|
|
13
|
-
onClose: () => {},
|
|
14
|
-
onPickAction: () => {},
|
|
15
|
-
onBack: () => {},
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export const Toolbar: Story = {
|
|
19
|
-
render: () => (
|
|
20
|
-
<MarkerPopover
|
|
21
|
-
view="toolbar"
|
|
22
|
-
actions={DEFAULT_QUICK_ACTIONS}
|
|
23
|
-
actionIndex={0}
|
|
24
|
-
canCut
|
|
25
|
-
{...callbacks}
|
|
26
|
-
/>
|
|
27
|
-
),
|
|
28
|
-
expectedColors: [DARK.accent, DARK.red, DARK.textMuted, DARK.textDim],
|
|
29
|
-
size: { width: 60, height: 4 },
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
export const ToolbarNoCut: Story = {
|
|
33
|
-
render: () => (
|
|
34
|
-
<MarkerPopover
|
|
35
|
-
view="toolbar"
|
|
36
|
-
actions={DEFAULT_QUICK_ACTIONS}
|
|
37
|
-
actionIndex={0}
|
|
38
|
-
canCut={false}
|
|
39
|
-
{...callbacks}
|
|
40
|
-
/>
|
|
41
|
-
),
|
|
42
|
-
expectedColors: [DARK.accent, DARK.textMuted, DARK.textDim],
|
|
43
|
-
size: { width: 60, height: 4 },
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
export const ActionsList: Story = {
|
|
47
|
-
render: () => (
|
|
48
|
-
<MarkerPopover
|
|
49
|
-
view="actions"
|
|
50
|
-
actions={DEFAULT_QUICK_ACTIONS}
|
|
51
|
-
actionIndex={2}
|
|
52
|
-
canCut
|
|
53
|
-
{...callbacks}
|
|
54
|
-
/>
|
|
55
|
-
),
|
|
56
|
-
expectedColors: [DARK.accent, DARK.textMuted, DARK.textDim],
|
|
57
|
-
size: { width: 60, height: 8 },
|
|
58
|
-
};
|