@cueloop/client 0.1.0-alpha.45 → 0.1.0-alpha.47
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 +6 -5
- package/src/App.prototype.test.tsx +125 -0
- package/src/App.tsx +18 -2
- package/src/components/AnnotationCard.tsx +7 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +323 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +28 -0
- package/src/intent-dispatch.test.ts +1 -0
- package/src/kitty-image.ts +74 -0
- package/src/prototype-browser.test.ts +55 -0
- package/src/prototype-browser.ts +232 -0
- package/src/session-controller.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.47",
|
|
4
4
|
"description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -26,16 +26,17 @@
|
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
30
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
31
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
32
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
29
|
+
"@cueloop/daemon": "0.1.0-alpha.47",
|
|
30
|
+
"@cueloop/extension-api": "0.1.0-alpha.47",
|
|
31
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.47",
|
|
32
|
+
"@cueloop/schema": "0.1.0-alpha.47",
|
|
33
33
|
"@opentui/core": "^0.5.2",
|
|
34
34
|
"@opentui/keymap": "0.5.2",
|
|
35
35
|
"@opentui/react": "^0.5.2",
|
|
36
36
|
"@opentui/ssh": "0.5.2",
|
|
37
37
|
"@pierre/diffs": "^1.3.5",
|
|
38
38
|
"diff": "^8",
|
|
39
|
+
"puppeteer-core": "^25.8.0",
|
|
39
40
|
"react": "^19.2.0"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Prototype review end to end: a click on the rendered page selects a DOM element, the marker actions bar opens, a typed comment saves through the controller, and the annotation shows in the rail. The headless browser is replaced with a fake so no Chrome or kitty output is needed. Char-frame assertions over the real App and a real in-process daemon. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import React from "react";
|
|
8
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
9
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
10
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
import {
|
|
13
|
+
setPrototypeRendererFactory,
|
|
14
|
+
type PrototypeElement,
|
|
15
|
+
type PrototypeRenderer,
|
|
16
|
+
} from "./prototype-browser";
|
|
17
|
+
import {
|
|
18
|
+
isolateUserConfig,
|
|
19
|
+
press,
|
|
20
|
+
settle,
|
|
21
|
+
typeText,
|
|
22
|
+
waitForState,
|
|
23
|
+
waitForText,
|
|
24
|
+
} from "./test-support";
|
|
25
|
+
|
|
26
|
+
const HTML = "<main><div class='card'><h2>Pro</h2><p>$24/mo</p></div></main>";
|
|
27
|
+
|
|
28
|
+
const FAKE_ELEMENT: PrototypeElement = {
|
|
29
|
+
selector: "main > div.card",
|
|
30
|
+
quote: "Pro $24/mo",
|
|
31
|
+
// mid-page, so the popover anchors well away from the click point below
|
|
32
|
+
box: { x: 440, y: 320, width: 320, height: 220 },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let rendered = false;
|
|
36
|
+
const fakeRenderer: PrototypeRenderer = {
|
|
37
|
+
viewport: { width: 1280, height: 800 },
|
|
38
|
+
screenshot: async () => {
|
|
39
|
+
rendered = true;
|
|
40
|
+
return new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
41
|
+
},
|
|
42
|
+
elementAt: async () => FAKE_ELEMENT,
|
|
43
|
+
highlight: async () => undefined,
|
|
44
|
+
close: async () => undefined,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
let home: string;
|
|
48
|
+
let server: DaemonServer;
|
|
49
|
+
let session: ReviewSession;
|
|
50
|
+
let restoreUserConfig: () => void;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
|
|
54
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
55
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
56
|
+
server.start();
|
|
57
|
+
session = server.core.sessionCreate({
|
|
58
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
59
|
+
artifact: {
|
|
60
|
+
type: "prototype",
|
|
61
|
+
content: HTML,
|
|
62
|
+
meta: { title: "Pricing cards", prototypePath: "/repo/cards.html" },
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
rendered = false;
|
|
66
|
+
setPrototypeRendererFactory(async () => fakeRenderer);
|
|
67
|
+
});
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
setPrototypeRendererFactory(null);
|
|
70
|
+
restoreUserConfig();
|
|
71
|
+
server.stop();
|
|
72
|
+
rmSync(home, { recursive: true, force: true });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
async function renderApp() {
|
|
76
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
77
|
+
width: 120,
|
|
78
|
+
height: 32,
|
|
79
|
+
});
|
|
80
|
+
await waitForText(setup, "cueloop");
|
|
81
|
+
return setup;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
85
|
+
|
|
86
|
+
async function clickText(setup: Setup, needle: string): Promise<void> {
|
|
87
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
88
|
+
const row = lines.findIndex((line) => line.includes(needle));
|
|
89
|
+
await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
|
|
90
|
+
await setup.renderOnce();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("prototype review", () => {
|
|
94
|
+
test("click an element, comment on it, and it lands in the rail", async () => {
|
|
95
|
+
// Arrange - wait until the fake browser produced its first screenshot, so
|
|
96
|
+
// the sheet is interactive before the click
|
|
97
|
+
const setup = await renderApp();
|
|
98
|
+
await waitForState(setup, () => rendered);
|
|
99
|
+
await settle(setup);
|
|
100
|
+
|
|
101
|
+
// Act
|
|
102
|
+
await setup.mockMouse.click(6, 6);
|
|
103
|
+
await waitForText(setup, "comment");
|
|
104
|
+
await clickText(setup, "comment");
|
|
105
|
+
await waitForText(setup, "write a note");
|
|
106
|
+
// the keymap-suppression flag must propagate before typing so keys reach the
|
|
107
|
+
// textarea, not the global keymap
|
|
108
|
+
await settle(setup);
|
|
109
|
+
await typeText(setup, "tighten the padding");
|
|
110
|
+
await press(setup, "enter");
|
|
111
|
+
|
|
112
|
+
// Assert - the annotation is stored against the element's selector
|
|
113
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
114
|
+
const stored = server.core.sessionGet(session.id).annotations[0]!;
|
|
115
|
+
expect(stored.kind).toBe("comment");
|
|
116
|
+
expect(stored.body).toBe("tighten the padding");
|
|
117
|
+
expect(stored.anchor.selector).toBe("main > div.card");
|
|
118
|
+
|
|
119
|
+
// and it renders in the rail
|
|
120
|
+
await settle(setup);
|
|
121
|
+
const frame = setup.captureCharFrame();
|
|
122
|
+
expect(frame).toContain("tighten the padding");
|
|
123
|
+
expect(frame).toContain("COMMENT");
|
|
124
|
+
});
|
|
125
|
+
});
|
package/src/App.tsx
CHANGED
|
@@ -58,6 +58,7 @@ import { CLIENT_VERSION } from "./version";
|
|
|
58
58
|
import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
|
|
59
59
|
import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
|
|
60
60
|
import { DiffSheet } from "./components/DiffSheet";
|
|
61
|
+
import { PrototypeSheet } from "./components/PrototypeSheet";
|
|
61
62
|
import { type ReviewRailHandle } from "./components/ReviewRail";
|
|
62
63
|
import type { AgentTerminalHandle } from "./components/agent-launcher";
|
|
63
64
|
import { ReviewPanel } from "./components/ReviewPanel";
|
|
@@ -251,6 +252,8 @@ export function App({
|
|
|
251
252
|
}, [marks]);
|
|
252
253
|
const resolved = session?.status === "resolved";
|
|
253
254
|
const isDiff = session?.artifact.type === "diff";
|
|
255
|
+
const isPrototype = session?.artifact.type === "prototype";
|
|
256
|
+
const [prototypeComposing, setPrototypeComposing] = useState(false);
|
|
254
257
|
// sort position per annotation so the rail interleaves annotation and removal
|
|
255
258
|
// cards in one line-ordered stack: a diff row carries its blockIndex; a plan
|
|
256
259
|
// annotation resolves to the display index it marked
|
|
@@ -587,6 +590,9 @@ export function App({
|
|
|
587
590
|
};
|
|
588
591
|
|
|
589
592
|
useKeyboard((key) => {
|
|
593
|
+
// The prototype compose textarea owns the keyboard while open: let it receive
|
|
594
|
+
// the typed note instead of the global keymap acting on each letter.
|
|
595
|
+
if (prototypeComposing) return;
|
|
590
596
|
// A running in-tab agent terminal owns the keyboard: forward every key to it,
|
|
591
597
|
// with ctrl+] as the detach chord back to the review.
|
|
592
598
|
if (agentTerminal) {
|
|
@@ -1025,7 +1031,17 @@ export function App({
|
|
|
1025
1031
|
<box style={{ width: railFootprint }} />
|
|
1026
1032
|
</box>
|
|
1027
1033
|
<box style={{ flexGrow: 1, flexDirection: "row" }}>
|
|
1028
|
-
{
|
|
1034
|
+
{isPrototype ? (
|
|
1035
|
+
<PrototypeSheet
|
|
1036
|
+
prototypePath={activeSession.artifact.meta.prototypePath ?? ""}
|
|
1037
|
+
quickActions={quickActions}
|
|
1038
|
+
canComment={isOwner && !resolved}
|
|
1039
|
+
onCommentElement={(element, body) =>
|
|
1040
|
+
controller.annotatePrototype(element.selector, element.quote, body)
|
|
1041
|
+
}
|
|
1042
|
+
onComposingChange={setPrototypeComposing}
|
|
1043
|
+
/>
|
|
1044
|
+
) : isDiff ? (
|
|
1029
1045
|
// the sheet dims to reading-quiet colors while the wizard has focus
|
|
1030
1046
|
<DiffSheet
|
|
1031
1047
|
rows={rows}
|
|
@@ -1061,7 +1077,7 @@ export function App({
|
|
|
1061
1077
|
session: activeSession,
|
|
1062
1078
|
authorNames,
|
|
1063
1079
|
selectedId: focusedAnnotationId,
|
|
1064
|
-
resolvedIds: isDiff ? null : resolvedIds,
|
|
1080
|
+
resolvedIds: isDiff || isPrototype ? null : resolvedIds,
|
|
1065
1081
|
curationItems,
|
|
1066
1082
|
selectedCurationId,
|
|
1067
1083
|
annotationPositions,
|
|
@@ -25,6 +25,12 @@ export interface AnnotationDraft {
|
|
|
25
25
|
onInput: (text: string) => void;
|
|
26
26
|
onSave: () => void;
|
|
27
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;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export interface AnnotationSaved {
|
|
@@ -119,6 +125,7 @@ function DraftEditor({
|
|
|
119
125
|
initialValue={draft.text}
|
|
120
126
|
placeholder="write a note..."
|
|
121
127
|
keyBindings={COMPOSE_KEY_BINDINGS}
|
|
128
|
+
{...(draft.onSubmit ? { onSubmit: draft.onSubmit } : {})}
|
|
122
129
|
onContentChange={() => {
|
|
123
130
|
const editor = editorRef.current;
|
|
124
131
|
if (!editor) return;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { PrototypeSheet } from "./PrototypeSheet";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "PrototypeSheet" };
|
|
7
|
+
|
|
8
|
+
// The story renderer reports no kitty graphics, so the sheet shows its fallback.
|
|
9
|
+
export const Unsupported: Story = {
|
|
10
|
+
render: () => (
|
|
11
|
+
<PrototypeSheet
|
|
12
|
+
prototypePath="/tmp/example.html"
|
|
13
|
+
quickActions={[]}
|
|
14
|
+
canComment={true}
|
|
15
|
+
onCommentElement={() => {}}
|
|
16
|
+
/>
|
|
17
|
+
),
|
|
18
|
+
expectedColors: [DARK.textDim],
|
|
19
|
+
size: { width: 80, height: 24 },
|
|
20
|
+
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prototype review surface: renders the HTML screenshot into a reserved cell
|
|
3
|
+
* region via the kitty graphics protocol, and turns a click into a DOM-element
|
|
4
|
+
* selection, so the shared marker actions bar and compose card annotate an
|
|
5
|
+
* element rather than a text span. The image is painted directly (not through
|
|
6
|
+
* OpenTUI's ImageRenderable, which does not display in every terminal): the
|
|
7
|
+
* region's cells are never repainted, and the picture is re-placed after each
|
|
8
|
+
* frame so it survives OpenTUI's own draws.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
12
|
+
import type { BoxRenderable } from "@opentui/core";
|
|
13
|
+
import { useRenderer } from "@opentui/react";
|
|
14
|
+
import type { Theme } from "../theme";
|
|
15
|
+
import { quickActionBody, type QuickAction } from "../config";
|
|
16
|
+
import { useComponentTheme } from "./theme-context";
|
|
17
|
+
import { FRAME_BORDER_STYLE } from "./primitives/frame";
|
|
18
|
+
import { MarkerPopover } from "./MarkerPopover";
|
|
19
|
+
import { AnnotationCard } from "./AnnotationCard";
|
|
20
|
+
import {
|
|
21
|
+
cssBoxToCell,
|
|
22
|
+
imageCellToCss,
|
|
23
|
+
prototypeRendererFactory,
|
|
24
|
+
type PrototypeElement,
|
|
25
|
+
type PrototypeRenderer,
|
|
26
|
+
type PrototypeViewport,
|
|
27
|
+
} from "../prototype-browser";
|
|
28
|
+
import {
|
|
29
|
+
deleteKittyImage,
|
|
30
|
+
placeKittyImage,
|
|
31
|
+
transmitKittyImage,
|
|
32
|
+
type CellRegion,
|
|
33
|
+
} from "../kitty-image";
|
|
34
|
+
|
|
35
|
+
export interface PrototypeSheetProps {
|
|
36
|
+
prototypePath: string;
|
|
37
|
+
quickActions: QuickAction[];
|
|
38
|
+
canComment: boolean;
|
|
39
|
+
onCommentElement: (element: PrototypeElement, body: string) => void;
|
|
40
|
+
/** Signals when the inline compose owns the keyboard, so the app suppresses
|
|
41
|
+
* its global keymap and the compose textarea receives the typed note. */
|
|
42
|
+
onComposingChange?: (active: boolean) => void;
|
|
43
|
+
theme?: Theme;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type SheetStatus = "loading" | "ready" | "unsupported" | "error";
|
|
47
|
+
|
|
48
|
+
const POPOVER_ROWS = 3;
|
|
49
|
+
const PROTOTYPE_IMAGE_ID = 811;
|
|
50
|
+
// The capture viewport width in CSS pixels; its height matches the region's
|
|
51
|
+
// cell aspect so the rendered image fills the box instead of letterboxing. The
|
|
52
|
+
// aspect comes from the terminal's reported cell pixels, falling back to a
|
|
53
|
+
// typical monospace ratio when the terminal never reports one.
|
|
54
|
+
const CAPTURE_WIDTH = 1280;
|
|
55
|
+
const FALLBACK_CELL_HEIGHT_OVER_WIDTH = 2.18;
|
|
56
|
+
|
|
57
|
+
function cellHeightOverWidth(renderer: {
|
|
58
|
+
resolution: { width: number; height: number } | null;
|
|
59
|
+
terminalWidth: number;
|
|
60
|
+
terminalHeight: number;
|
|
61
|
+
}): number {
|
|
62
|
+
const resolution = renderer.resolution;
|
|
63
|
+
if (!resolution || renderer.terminalWidth < 1 || renderer.terminalHeight < 1) {
|
|
64
|
+
return FALLBACK_CELL_HEIGHT_OVER_WIDTH;
|
|
65
|
+
}
|
|
66
|
+
return resolution.height / renderer.terminalHeight / (resolution.width / renderer.terminalWidth);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function regionOf(box: BoxRenderable | null): CellRegion | null {
|
|
70
|
+
if (!box || box.width < 1 || box.height < 1) return null;
|
|
71
|
+
return { column: box.x, row: box.y, columns: box.width, rows: box.height };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function PrototypeSheet({
|
|
75
|
+
prototypePath,
|
|
76
|
+
quickActions,
|
|
77
|
+
canComment,
|
|
78
|
+
onCommentElement,
|
|
79
|
+
onComposingChange,
|
|
80
|
+
theme,
|
|
81
|
+
}: PrototypeSheetProps): React.ReactNode {
|
|
82
|
+
const tokens = useComponentTheme(theme);
|
|
83
|
+
const renderer = useRenderer();
|
|
84
|
+
const regionRef = useRef<BoxRenderable | null>(null);
|
|
85
|
+
const browserRef = useRef<PrototypeRenderer | null>(null);
|
|
86
|
+
const launchedRef = useRef(false);
|
|
87
|
+
const viewportRef = useRef<PrototypeViewport>({ width: CAPTURE_WIDTH, height: 800 });
|
|
88
|
+
const pngRef = useRef<Uint8Array | null>(null);
|
|
89
|
+
const transmittedRef = useRef<Uint8Array | null>(null);
|
|
90
|
+
const paintRef = useRef<() => void>(() => undefined);
|
|
91
|
+
const launchRef = useRef<() => void>(() => undefined);
|
|
92
|
+
|
|
93
|
+
const [status, setStatus] = useState<SheetStatus>("loading");
|
|
94
|
+
const [errorMessage, setErrorMessage] = useState<string>("");
|
|
95
|
+
const [selected, setSelected] = useState<PrototypeElement | null>(null);
|
|
96
|
+
const [overlayCell, setOverlayCell] = useState<{ left: number; top: number } | null>(null);
|
|
97
|
+
const [actionsOpen, setActionsOpen] = useState(false);
|
|
98
|
+
const [composing, setComposing] = useState(false);
|
|
99
|
+
const [draftText, setDraftText] = useState("");
|
|
100
|
+
|
|
101
|
+
const setPng = (png: Uint8Array): void => {
|
|
102
|
+
pngRef.current = png;
|
|
103
|
+
transmittedRef.current = null;
|
|
104
|
+
paintRef.current();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Every frame: start the browser once layout is known, then keep the picture
|
|
108
|
+
// asserted over its cells. The kitty emission (transmit once, cheap re-place
|
|
109
|
+
// after) is skipped when the renderer has no raw writeOut channel (the test
|
|
110
|
+
// harness), but the launch trigger still runs.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!renderer) return;
|
|
113
|
+
// writeOut is the renderer's raw output channel, ordered against its frame
|
|
114
|
+
// bytes; it is private, so reach it through one narrow cast
|
|
115
|
+
const rawWrite = (renderer as unknown as { writeOut?: (data: string) => void }).writeOut;
|
|
116
|
+
const write =
|
|
117
|
+
typeof rawWrite === "function" ? (chunk: string) => rawWrite.call(renderer, chunk) : null;
|
|
118
|
+
const paint = (): void => {
|
|
119
|
+
if (!write) return;
|
|
120
|
+
const region = regionOf(regionRef.current);
|
|
121
|
+
const png = pngRef.current;
|
|
122
|
+
if (!region || !png) return;
|
|
123
|
+
if (transmittedRef.current !== png) {
|
|
124
|
+
transmitKittyImage(write, png, region, PROTOTYPE_IMAGE_ID);
|
|
125
|
+
transmittedRef.current = png;
|
|
126
|
+
} else {
|
|
127
|
+
placeKittyImage(write, region, PROTOTYPE_IMAGE_ID);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
paintRef.current = paint;
|
|
131
|
+
const onFrame = (): void => {
|
|
132
|
+
launchRef.current();
|
|
133
|
+
paint();
|
|
134
|
+
};
|
|
135
|
+
renderer.on("frame", onFrame);
|
|
136
|
+
return () => {
|
|
137
|
+
renderer.off("frame", onFrame);
|
|
138
|
+
if (write) deleteKittyImage(write, PROTOTYPE_IMAGE_ID);
|
|
139
|
+
};
|
|
140
|
+
}, [renderer]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
onComposingChange?.(composing);
|
|
144
|
+
}, [composing, onComposingChange]);
|
|
145
|
+
|
|
146
|
+
const unmountedRef = useRef(false);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
return () => {
|
|
149
|
+
unmountedRef.current = true;
|
|
150
|
+
void browserRef.current?.close();
|
|
151
|
+
};
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
const launch = (): void => {
|
|
155
|
+
const region = regionOf(regionRef.current);
|
|
156
|
+
const caps = renderer?.capabilities;
|
|
157
|
+
if (launchedRef.current || !region) return;
|
|
158
|
+
if (caps && caps.kitty_graphics === false) {
|
|
159
|
+
setStatus("unsupported");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
launchedRef.current = true;
|
|
163
|
+
const viewport = {
|
|
164
|
+
width: CAPTURE_WIDTH,
|
|
165
|
+
height: Math.round(
|
|
166
|
+
(CAPTURE_WIDTH * region.rows * cellHeightOverWidth(renderer!)) / region.columns,
|
|
167
|
+
),
|
|
168
|
+
};
|
|
169
|
+
viewportRef.current = viewport;
|
|
170
|
+
void (async () => {
|
|
171
|
+
try {
|
|
172
|
+
const browser = await prototypeRendererFactory()({ filePath: prototypePath, viewport });
|
|
173
|
+
// the sheet may have unmounted while Chromium was launching; close the
|
|
174
|
+
// late arrival instead of leaking the process
|
|
175
|
+
if (unmountedRef.current) {
|
|
176
|
+
void browser.close();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
browserRef.current = browser;
|
|
180
|
+
setPng(await browser.screenshot());
|
|
181
|
+
setStatus("ready");
|
|
182
|
+
} catch (error) {
|
|
183
|
+
setErrorMessage(error instanceof Error ? error.message : String(error));
|
|
184
|
+
setStatus("error");
|
|
185
|
+
}
|
|
186
|
+
})();
|
|
187
|
+
};
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
launchRef.current = launch;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const refresh = async (): Promise<void> => {
|
|
193
|
+
const browser = browserRef.current;
|
|
194
|
+
if (browser) setPng(await browser.screenshot());
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const clearSelection = (): void => {
|
|
198
|
+
setSelected(null);
|
|
199
|
+
setOverlayCell(null);
|
|
200
|
+
setActionsOpen(false);
|
|
201
|
+
setComposing(false);
|
|
202
|
+
setDraftText("");
|
|
203
|
+
void browserRef.current
|
|
204
|
+
?.highlight(null)
|
|
205
|
+
.then(refresh)
|
|
206
|
+
.catch(() => undefined);
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const onRegionMouseDown = (event: { x: number; y: number }): void => {
|
|
210
|
+
const browser = browserRef.current;
|
|
211
|
+
const region = regionOf(regionRef.current);
|
|
212
|
+
if (status !== "ready" || !browser || !region) return;
|
|
213
|
+
const geometry = {
|
|
214
|
+
x: region.column,
|
|
215
|
+
y: region.row,
|
|
216
|
+
width: region.columns,
|
|
217
|
+
height: region.rows,
|
|
218
|
+
};
|
|
219
|
+
const cssPoint = imageCellToCss(event, geometry, viewportRef.current);
|
|
220
|
+
if (!cssPoint) return;
|
|
221
|
+
void (async () => {
|
|
222
|
+
const element = await browser.elementAt(cssPoint.x, cssPoint.y);
|
|
223
|
+
if (!element) return;
|
|
224
|
+
const cell = cssBoxToCell(element.box, geometry, viewportRef.current);
|
|
225
|
+
const topRaw = cell.row - geometry.y;
|
|
226
|
+
setSelected(element);
|
|
227
|
+
setOverlayCell({
|
|
228
|
+
left: Math.max(0, cell.column - geometry.x),
|
|
229
|
+
top: Math.max(0, topRaw < POPOVER_ROWS ? topRaw + 1 : topRaw - POPOVER_ROWS),
|
|
230
|
+
});
|
|
231
|
+
setActionsOpen(false);
|
|
232
|
+
setComposing(false);
|
|
233
|
+
await browser.highlight(element.selector);
|
|
234
|
+
await refresh();
|
|
235
|
+
})().catch((error) => {
|
|
236
|
+
setErrorMessage(error instanceof Error ? error.message : String(error));
|
|
237
|
+
setStatus("error");
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const commit = (body: string): void => {
|
|
242
|
+
if (!canComment) return;
|
|
243
|
+
if (selected && body.trim()) onCommentElement(selected, body.trim());
|
|
244
|
+
clearSelection();
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const overlayStyle = {
|
|
248
|
+
position: "absolute" as const,
|
|
249
|
+
left: overlayCell?.left ?? 0,
|
|
250
|
+
top: Math.max(0, overlayCell?.top ?? 0),
|
|
251
|
+
flexDirection: "column" as const,
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<box
|
|
256
|
+
style={{
|
|
257
|
+
flexGrow: 1,
|
|
258
|
+
flexDirection: "column",
|
|
259
|
+
border: true,
|
|
260
|
+
borderStyle: FRAME_BORDER_STYLE,
|
|
261
|
+
borderColor: tokens.text,
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
<box
|
|
265
|
+
ref={(instance: BoxRenderable | null) => {
|
|
266
|
+
regionRef.current = instance;
|
|
267
|
+
}}
|
|
268
|
+
style={{ flexGrow: 1 }}
|
|
269
|
+
onSizeChange={launch}
|
|
270
|
+
onMouseDown={onRegionMouseDown}
|
|
271
|
+
/>
|
|
272
|
+
{status !== "ready" ? (
|
|
273
|
+
<box style={{ position: "absolute", left: 2, top: 1 }}>
|
|
274
|
+
<text fg={tokens.textDim}>{statusLine(status, errorMessage)}</text>
|
|
275
|
+
</box>
|
|
276
|
+
) : null}
|
|
277
|
+
{selected && !composing ? (
|
|
278
|
+
<box style={overlayStyle}>
|
|
279
|
+
<MarkerPopover
|
|
280
|
+
view={actionsOpen ? "actions" : "toolbar"}
|
|
281
|
+
actions={quickActions}
|
|
282
|
+
actionIndex={0}
|
|
283
|
+
canCut={false}
|
|
284
|
+
onComment={() => {
|
|
285
|
+
if (!canComment) return;
|
|
286
|
+
onComposingChange?.(true);
|
|
287
|
+
setComposing(true);
|
|
288
|
+
}}
|
|
289
|
+
onCut={() => undefined}
|
|
290
|
+
onOpenActions={() => setActionsOpen(true)}
|
|
291
|
+
onClose={clearSelection}
|
|
292
|
+
onPickAction={(index) => commit(quickActionBody(quickActions[index]!))}
|
|
293
|
+
onBack={() => setActionsOpen(false)}
|
|
294
|
+
theme={theme}
|
|
295
|
+
/>
|
|
296
|
+
</box>
|
|
297
|
+
) : null}
|
|
298
|
+
{selected && composing ? (
|
|
299
|
+
<box style={overlayStyle}>
|
|
300
|
+
<AnnotationCard
|
|
301
|
+
kind="comment"
|
|
302
|
+
quote={selected.quote}
|
|
303
|
+
draft={{
|
|
304
|
+
text: draftText,
|
|
305
|
+
onInput: setDraftText,
|
|
306
|
+
onSave: () => commit(draftText),
|
|
307
|
+
onSubmit: () => commit(draftText),
|
|
308
|
+
onCancel: clearSelection,
|
|
309
|
+
}}
|
|
310
|
+
theme={theme}
|
|
311
|
+
/>
|
|
312
|
+
</box>
|
|
313
|
+
) : null}
|
|
314
|
+
</box>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function statusLine(status: SheetStatus, errorMessage: string): string {
|
|
319
|
+
if (status === "loading") return "rendering prototype…";
|
|
320
|
+
if (status === "unsupported")
|
|
321
|
+
return "prototype preview needs a graphics terminal (kitty or ghostty)";
|
|
322
|
+
return `prototype preview failed: ${errorMessage}`;
|
|
323
|
+
}
|
|
@@ -817,6 +817,34 @@ exports[`stories catalog PromptDialog/RenameAuthor renders, snapshots, and carri
|
|
|
817
817
|
|
|
818
818
|
|
|
819
819
|
|
|
820
|
+
"
|
|
821
|
+
`;
|
|
822
|
+
|
|
823
|
+
exports[`stories catalog PrototypeSheet/Unsupported renders, snapshots, and carries its colors: PrototypeSheet/Unsupported 1`] = `
|
|
824
|
+
"╭──────────────────────────────────────────────────────────────────────────────╮
|
|
825
|
+
│ │
|
|
826
|
+
│ rendering prototype… │
|
|
827
|
+
│ │
|
|
828
|
+
│ │
|
|
829
|
+
│ │
|
|
830
|
+
│ │
|
|
831
|
+
│ │
|
|
832
|
+
│ │
|
|
833
|
+
│ │
|
|
834
|
+
│ │
|
|
835
|
+
│ │
|
|
836
|
+
│ │
|
|
837
|
+
│ │
|
|
838
|
+
│ │
|
|
839
|
+
│ │
|
|
840
|
+
│ │
|
|
841
|
+
│ │
|
|
842
|
+
│ │
|
|
843
|
+
│ │
|
|
844
|
+
│ │
|
|
845
|
+
│ │
|
|
846
|
+
│ │
|
|
847
|
+
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
820
848
|
"
|
|
821
849
|
`;
|
|
822
850
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct kitty graphics protocol emission for a cell rectangle. OpenTUI's
|
|
3
|
+
* ImageRenderable does not display in every terminal, so prototype review paints
|
|
4
|
+
* the screenshot itself: transmit the PNG once, then re-place it after each
|
|
5
|
+
* frame over its reserved (never-repainted) cells, cell-addressed so it needs no
|
|
6
|
+
* pixel-resolution report.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface CellRegion {
|
|
10
|
+
/** 0-based screen cell of the region's top-left corner. */
|
|
11
|
+
column: number;
|
|
12
|
+
row: number;
|
|
13
|
+
columns: number;
|
|
14
|
+
rows: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Writes a raw sequence ordered against the renderer's own frame output. */
|
|
18
|
+
export type OrderedWrite = (chunk: string) => void;
|
|
19
|
+
|
|
20
|
+
const ESCAPE_STRING_TERMINATOR = "\x1b\\";
|
|
21
|
+
const CHUNK_BYTES = 4096;
|
|
22
|
+
|
|
23
|
+
/** Save the cursor, run the placement at the region's corner, restore it. */
|
|
24
|
+
function atRegion(region: CellRegion, body: string): string {
|
|
25
|
+
const moveToCorner = `\x1b[${region.row + 1};${region.column + 1}H`;
|
|
26
|
+
return `\x1b7${moveToCorner}${body}\x1b8`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function graphicsCommand(keys: string, payload: string): string {
|
|
30
|
+
return `\x1b_G${keys};${payload}${ESCAPE_STRING_TERMINATOR}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Transmit a PNG and display it filling the region's cells (a=T). f=100 is PNG,
|
|
35
|
+
* C=1 keeps the cursor put so a full-region image cannot scroll the screen, and
|
|
36
|
+
* q=2 suppresses the terminal's replies. Data over 4096 bytes is chunked.
|
|
37
|
+
*/
|
|
38
|
+
export function transmitKittyImage(
|
|
39
|
+
write: OrderedWrite,
|
|
40
|
+
png: Uint8Array,
|
|
41
|
+
region: CellRegion,
|
|
42
|
+
imageId: number,
|
|
43
|
+
): void {
|
|
44
|
+
if (region.columns < 1 || region.rows < 1) return;
|
|
45
|
+
const base64 = Buffer.from(png).toString("base64");
|
|
46
|
+
// z<0 places the image beneath the text layer, so the marker popover and
|
|
47
|
+
// compose card (ordinary cells) still paint over it
|
|
48
|
+
const controlKeys = `a=T,f=100,i=${imageId},q=2,C=1,z=-1,c=${region.columns},r=${region.rows}`;
|
|
49
|
+
let sequence = "";
|
|
50
|
+
for (let offset = 0; offset < base64.length; offset += CHUNK_BYTES) {
|
|
51
|
+
const chunk = base64.slice(offset, offset + CHUNK_BYTES);
|
|
52
|
+
const isMore = offset + CHUNK_BYTES < base64.length ? 1 : 0;
|
|
53
|
+
sequence +=
|
|
54
|
+
offset === 0
|
|
55
|
+
? graphicsCommand(`${controlKeys},m=${isMore}`, chunk)
|
|
56
|
+
: graphicsCommand(`m=${isMore}`, chunk);
|
|
57
|
+
}
|
|
58
|
+
write(atRegion(region, sequence));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Re-place an already-transmitted image over its region, cheaply (a=p, no data). */
|
|
62
|
+
export function placeKittyImage(write: OrderedWrite, region: CellRegion, imageId: number): void {
|
|
63
|
+
if (region.columns < 1 || region.rows < 1) return;
|
|
64
|
+
const command = graphicsCommand(
|
|
65
|
+
`a=p,i=${imageId},q=2,C=1,z=-1,c=${region.columns},r=${region.rows}`,
|
|
66
|
+
"",
|
|
67
|
+
);
|
|
68
|
+
write(atRegion(region, command));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Remove the image so it does not linger after the sheet closes (a=d). */
|
|
72
|
+
export function deleteKittyImage(write: OrderedWrite, imageId: number): void {
|
|
73
|
+
write(graphicsCommand(`a=d,d=i,i=${imageId},q=2`, ""));
|
|
74
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { cssBoxToCell, imageCellToCss } from "./prototype-browser";
|
|
3
|
+
|
|
4
|
+
const IMAGE = { x: 1, y: 1, width: 80, height: 40 };
|
|
5
|
+
const VIEWPORT = { width: 1280, height: 720 };
|
|
6
|
+
|
|
7
|
+
describe("imageCellToCss", () => {
|
|
8
|
+
test("maps a cell inside the image to the center of its viewport pixel band", () => {
|
|
9
|
+
// Act - the image's top-left cell (1,1)
|
|
10
|
+
const css = imageCellToCss({ x: 1, y: 1 }, IMAGE, VIEWPORT)!;
|
|
11
|
+
|
|
12
|
+
// Assert - half a cell into the first band
|
|
13
|
+
expect(css.x).toBeCloseTo((0.5 / 80) * 1280, 5);
|
|
14
|
+
expect(css.y).toBeCloseTo((0.5 / 40) * 720, 5);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("a click below the image's origin lands deeper in the page", () => {
|
|
18
|
+
// Act
|
|
19
|
+
const top = imageCellToCss({ x: 40, y: 2 }, IMAGE, VIEWPORT)!;
|
|
20
|
+
const bottom = imageCellToCss({ x: 40, y: 38 }, IMAGE, VIEWPORT)!;
|
|
21
|
+
|
|
22
|
+
// Assert
|
|
23
|
+
expect(bottom.y).toBeGreaterThan(top.y);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a cell outside the image returns null", () => {
|
|
27
|
+
// Assert - the gutter left of and the row above the image
|
|
28
|
+
expect(imageCellToCss({ x: 0, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
|
|
29
|
+
expect(imageCellToCss({ x: 10, y: 0 }, IMAGE, VIEWPORT)).toBeNull();
|
|
30
|
+
expect(imageCellToCss({ x: 200, y: 10 }, IMAGE, VIEWPORT)).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("cssBoxToCell", () => {
|
|
35
|
+
test("maps a viewport rect back to the image cells covering it", () => {
|
|
36
|
+
// Arrange - a rect covering the right half, lower half of the page
|
|
37
|
+
const box = { x: 640, y: 360, width: 320, height: 180 };
|
|
38
|
+
|
|
39
|
+
// Act
|
|
40
|
+
const cell = cssBoxToCell(box, IMAGE, VIEWPORT);
|
|
41
|
+
|
|
42
|
+
// Assert - x at half width -> 40 cells in from the image origin (1)
|
|
43
|
+
expect(cell.column).toBe(1 + Math.floor((640 / 1280) * 80));
|
|
44
|
+
expect(cell.row).toBe(1 + Math.floor((360 / 720) * 40));
|
|
45
|
+
expect(cell.columns).toBe(Math.round((320 / 1280) * 80));
|
|
46
|
+
expect(cell.rows).toBe(Math.round((180 / 720) * 40));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("a zero-size rect still reserves at least one cell", () => {
|
|
50
|
+
// Assert
|
|
51
|
+
const cell = cssBoxToCell({ x: 0, y: 0, width: 0, height: 0 }, IMAGE, VIEWPORT);
|
|
52
|
+
expect(cell.columns).toBe(1);
|
|
53
|
+
expect(cell.rows).toBe(1);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Headless-Chromium backing for prototype review: render an HTML file to a PNG
|
|
4
|
+
* and resolve a click coordinate to a DOM element. puppeteer-core is imported
|
|
5
|
+
* lazily so plan/diff review and the test suite never load it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface PrototypeElement {
|
|
9
|
+
/** Stable CSS selector - the annotation anchor's authority. */
|
|
10
|
+
selector: string;
|
|
11
|
+
/** Short human label for the rail card (element text, else the selector). */
|
|
12
|
+
quote: string;
|
|
13
|
+
/** Element rectangle in CSS pixels within the captured viewport. */
|
|
14
|
+
box: ElementBox;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ElementBox {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
width: number;
|
|
21
|
+
height: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PrototypeViewport {
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PrototypeRenderer {
|
|
30
|
+
readonly viewport: PrototypeViewport;
|
|
31
|
+
screenshot(): Promise<Uint8Array>;
|
|
32
|
+
elementAt(cssX: number, cssY: number): Promise<PrototypeElement | null>;
|
|
33
|
+
highlight(selector: string | null): Promise<void>;
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LaunchOptions {
|
|
38
|
+
filePath: string;
|
|
39
|
+
viewport: PrototypeViewport;
|
|
40
|
+
/** Absolute path to a Chrome/Chromium binary; falls back to the channel. */
|
|
41
|
+
executablePath?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Cell click -> CSS pixel inside the letterboxed image, or null when outside it. */
|
|
45
|
+
export function imageCellToCss(
|
|
46
|
+
event: { x: number; y: number },
|
|
47
|
+
image: { x: number; y: number; width: number; height: number },
|
|
48
|
+
viewport: PrototypeViewport,
|
|
49
|
+
): { x: number; y: number } | null {
|
|
50
|
+
const columnInImage = event.x - image.x;
|
|
51
|
+
const rowInImage = event.y - image.y;
|
|
52
|
+
if (
|
|
53
|
+
columnInImage < 0 ||
|
|
54
|
+
rowInImage < 0 ||
|
|
55
|
+
columnInImage >= image.width ||
|
|
56
|
+
rowInImage >= image.height
|
|
57
|
+
) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
x: ((columnInImage + 0.5) / image.width) * viewport.width,
|
|
62
|
+
y: ((rowInImage + 0.5) / image.height) * viewport.height,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** CSS pixel rect -> the image cell it maps to, for anchoring overlays. */
|
|
67
|
+
export function cssBoxToCell(
|
|
68
|
+
box: ElementBox,
|
|
69
|
+
image: { x: number; y: number; width: number; height: number },
|
|
70
|
+
viewport: PrototypeViewport,
|
|
71
|
+
): { column: number; row: number; columns: number; rows: number } {
|
|
72
|
+
const scaleColumn = image.width / viewport.width;
|
|
73
|
+
const scaleRow = image.height / viewport.height;
|
|
74
|
+
return {
|
|
75
|
+
column: image.x + Math.floor(box.x * scaleColumn),
|
|
76
|
+
row: image.y + Math.floor(box.y * scaleRow),
|
|
77
|
+
columns: Math.max(1, Math.round(box.width * scaleColumn)),
|
|
78
|
+
rows: Math.max(1, Math.round(box.height * scaleRow)),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type PrototypeRendererFactory = (options: LaunchOptions) => Promise<PrototypeRenderer>;
|
|
83
|
+
|
|
84
|
+
let rendererFactory: PrototypeRendererFactory | null = null;
|
|
85
|
+
|
|
86
|
+
/** Override the renderer factory so tests can inject a browserless fake. */
|
|
87
|
+
export function setPrototypeRendererFactory(factory: PrototypeRendererFactory | null): void {
|
|
88
|
+
rendererFactory = factory;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The active factory - the injected fake in tests, else real headless Chromium. */
|
|
92
|
+
export function prototypeRendererFactory(): PrototypeRendererFactory {
|
|
93
|
+
return rendererFactory ?? launchPrototypeRenderer;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
|
|
97
|
+
const puppeteer = (await import("puppeteer-core")).default;
|
|
98
|
+
const { pathToFileURL } = await import("node:url");
|
|
99
|
+
const browser = await puppeteer.launch({
|
|
100
|
+
executablePath: options.executablePath ?? chromeExecutable(),
|
|
101
|
+
headless: true,
|
|
102
|
+
args: ["--no-sandbox", "--hide-scrollbars", "--force-color-profile=srgb"],
|
|
103
|
+
defaultViewport: {
|
|
104
|
+
width: options.viewport.width,
|
|
105
|
+
height: options.viewport.height,
|
|
106
|
+
deviceScaleFactor: 2,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
// close the browser if page setup fails, so a launch error never leaks Chromium
|
|
110
|
+
const page = await browser.newPage().catch(async (error) => {
|
|
111
|
+
await browser.close();
|
|
112
|
+
throw error;
|
|
113
|
+
});
|
|
114
|
+
await page
|
|
115
|
+
.goto(pathToFileURL(options.filePath).href, { waitUntil: "networkidle0" })
|
|
116
|
+
.catch(async (error) => {
|
|
117
|
+
await browser.close();
|
|
118
|
+
throw error;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
viewport: options.viewport,
|
|
123
|
+
async screenshot() {
|
|
124
|
+
const buffer = await page.screenshot({ type: "png", encoding: "binary" });
|
|
125
|
+
return new Uint8Array(buffer as Buffer);
|
|
126
|
+
},
|
|
127
|
+
async elementAt(cssX, cssY) {
|
|
128
|
+
return (await page.evaluate(elementAtScript, cssX, cssY)) as PrototypeElement | null;
|
|
129
|
+
},
|
|
130
|
+
async highlight(selector) {
|
|
131
|
+
await page.evaluate(highlightScript, selector);
|
|
132
|
+
},
|
|
133
|
+
async close() {
|
|
134
|
+
await browser.close();
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Serialized into the page (self-contained for puppeteer): resolve the click to a component element. */
|
|
140
|
+
function elementAtScript(x: number, y: number): unknown {
|
|
141
|
+
const SEMANTIC = new Set([
|
|
142
|
+
"SECTION",
|
|
143
|
+
"ARTICLE",
|
|
144
|
+
"LI",
|
|
145
|
+
"NAV",
|
|
146
|
+
"HEADER",
|
|
147
|
+
"FOOTER",
|
|
148
|
+
"ASIDE",
|
|
149
|
+
"FORM",
|
|
150
|
+
]);
|
|
151
|
+
const isNamed = (element: Element): boolean =>
|
|
152
|
+
element.classList.length > 0 || SEMANTIC.has(element.tagName);
|
|
153
|
+
// a card wraps several children: climb to the nearest named container holding
|
|
154
|
+
// more than one child, else the nearest named ancestor, else the clicked leaf
|
|
155
|
+
const componentRoot = (start: Element): Element => {
|
|
156
|
+
let namedFallback: Element | null = null;
|
|
157
|
+
let current: Element | null = start;
|
|
158
|
+
while (current && current !== document.body) {
|
|
159
|
+
if (isNamed(current)) {
|
|
160
|
+
if (current.childElementCount > 1) return current;
|
|
161
|
+
namedFallback = namedFallback ?? current;
|
|
162
|
+
}
|
|
163
|
+
current = current.parentElement;
|
|
164
|
+
}
|
|
165
|
+
return namedFallback ?? start;
|
|
166
|
+
};
|
|
167
|
+
const selectorFor = (start: Element): string => {
|
|
168
|
+
const parts: string[] = [];
|
|
169
|
+
let current: Element | null = start;
|
|
170
|
+
while (current && current.nodeType === 1 && current !== document.documentElement) {
|
|
171
|
+
let part = current.tagName.toLowerCase();
|
|
172
|
+
if (current.id) {
|
|
173
|
+
parts.unshift(part + "#" + CSS.escape(current.id));
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
const parent: Element | null = current.parentElement;
|
|
177
|
+
if (parent) {
|
|
178
|
+
const twins = [...parent.children].filter((child) => child.tagName === current!.tagName);
|
|
179
|
+
if (twins.length > 1) part += ":nth-of-type(" + (twins.indexOf(current) + 1) + ")";
|
|
180
|
+
}
|
|
181
|
+
parts.unshift(part);
|
|
182
|
+
current = current.parentElement;
|
|
183
|
+
}
|
|
184
|
+
return parts.join(" > ");
|
|
185
|
+
};
|
|
186
|
+
const hit = document.elementFromPoint(x, y);
|
|
187
|
+
if (!(hit instanceof Element)) return null;
|
|
188
|
+
const node = componentRoot(hit);
|
|
189
|
+
const selector = selectorFor(node);
|
|
190
|
+
if (!selector) return null;
|
|
191
|
+
const text = (node.textContent || "").replace(/\s+/g, " ").trim();
|
|
192
|
+
const tag = node.tagName.toLowerCase();
|
|
193
|
+
const quote = text
|
|
194
|
+
? text.slice(0, 80)
|
|
195
|
+
: node.id
|
|
196
|
+
? tag + "#" + node.id
|
|
197
|
+
: node.classList.length
|
|
198
|
+
? tag + "." + node.classList[0]
|
|
199
|
+
: tag;
|
|
200
|
+
const rect = node.getBoundingClientRect();
|
|
201
|
+
return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function highlightScript(selector: string | null): void {
|
|
205
|
+
const ATTRIBUTE = "data-cueloop-selected";
|
|
206
|
+
const STYLE_ID = "cueloop-highlight-style";
|
|
207
|
+
document
|
|
208
|
+
.querySelectorAll("[" + ATTRIBUTE + "]")
|
|
209
|
+
.forEach((node) => node.removeAttribute(ATTRIBUTE));
|
|
210
|
+
if (!document.getElementById(STYLE_ID)) {
|
|
211
|
+
const style = document.createElement("style");
|
|
212
|
+
style.id = STYLE_ID;
|
|
213
|
+
style.textContent =
|
|
214
|
+
"[" + ATTRIBUTE + "]{outline:2px solid #f38ba8 !important;outline-offset:2px;}";
|
|
215
|
+
document.head.appendChild(style);
|
|
216
|
+
}
|
|
217
|
+
if (selector) {
|
|
218
|
+
const node = document.querySelector(selector);
|
|
219
|
+
if (node) node.setAttribute(ATTRIBUTE, "");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Standard Chrome install locations by platform; puppeteer-core ships no browser. */
|
|
224
|
+
function chromeExecutable(): string {
|
|
225
|
+
if (process.platform === "darwin") {
|
|
226
|
+
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
227
|
+
}
|
|
228
|
+
if (process.platform === "win32") {
|
|
229
|
+
return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
|
230
|
+
}
|
|
231
|
+
return "/usr/bin/google-chrome";
|
|
232
|
+
}
|
|
@@ -197,6 +197,8 @@ export interface ReviewController {
|
|
|
197
197
|
end: number,
|
|
198
198
|
body: string,
|
|
199
199
|
): string | undefined;
|
|
200
|
+
/** Anchor a prototype comment to a DOM element by its selector. */
|
|
201
|
+
annotatePrototype(selector: string, quote: string, body: string): string | undefined;
|
|
200
202
|
/** Rewrite a stored annotation's body in place (the rail-card edit). */
|
|
201
203
|
updateAnnotation(id: string, body: string): void;
|
|
202
204
|
removeAnnotation(id: string): void;
|
|
@@ -679,6 +681,18 @@ class Controller implements ReviewController {
|
|
|
679
681
|
return wire.id;
|
|
680
682
|
}
|
|
681
683
|
|
|
684
|
+
annotatePrototype(selector: string, quote: string, body: string): string | undefined {
|
|
685
|
+
const session = this.snapshot.session;
|
|
686
|
+
if (!session) return undefined;
|
|
687
|
+
const anchor = { quote, prefix: "", suffix: "", selector };
|
|
688
|
+
const wire = { id: newAnnotationId(), kind: "comment", anchor, body };
|
|
689
|
+
const persisted = this.client!.sessionAnnotate(session.id, wire);
|
|
690
|
+
this.apply(persisted);
|
|
691
|
+
this.mirrorAnnotation(persisted, wire);
|
|
692
|
+
this.setStatus("comment added");
|
|
693
|
+
return wire.id;
|
|
694
|
+
}
|
|
695
|
+
|
|
682
696
|
updateAnnotation(id: string, body: string): void {
|
|
683
697
|
const session = this.snapshot.session;
|
|
684
698
|
if (!session) return;
|