@cueloop/client 0.1.0-alpha.46 → 0.1.0-alpha.48
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 +5 -5
- package/src/App.prototype.test.tsx +155 -0
- package/src/App.tsx +6 -0
- package/src/components/AnnotationCard.tsx +7 -0
- package/src/components/PrototypeSheet.tsx +172 -42
- package/src/components/__snapshots__/stories.test.tsx.snap +28 -28
- package/src/kitty-image.ts +74 -0
- package/src/prototype-browser.ts +25 -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.48",
|
|
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,10 +26,10 @@
|
|
|
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.48",
|
|
30
|
+
"@cueloop/extension-api": "0.1.0-alpha.48",
|
|
31
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.48",
|
|
32
|
+
"@cueloop/schema": "0.1.0-alpha.48",
|
|
33
33
|
"@opentui/core": "^0.5.2",
|
|
34
34
|
"@opentui/keymap": "0.5.2",
|
|
35
35
|
"@opentui/react": "^0.5.2",
|
|
@@ -0,0 +1,155 @@
|
|
|
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
|
+
waitForTextGone,
|
|
25
|
+
} from "./test-support";
|
|
26
|
+
|
|
27
|
+
const HTML = "<main><div class='card'><h2>Pro</h2><p>$24/mo</p></div></main>";
|
|
28
|
+
|
|
29
|
+
const FAKE_ELEMENT: PrototypeElement = {
|
|
30
|
+
selector: "main > div.card",
|
|
31
|
+
quote: "Pro $24/mo",
|
|
32
|
+
// mid-page, so the popover anchors well away from the click point below
|
|
33
|
+
box: { x: 440, y: 320, width: 320, height: 220 },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let rendered = false;
|
|
37
|
+
let scrollDeltas: number[] = [];
|
|
38
|
+
const fakeRenderer: PrototypeRenderer = {
|
|
39
|
+
viewport: { width: 1280, height: 800 },
|
|
40
|
+
screenshot: async () => {
|
|
41
|
+
rendered = true;
|
|
42
|
+
return new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
|
43
|
+
},
|
|
44
|
+
elementAt: async () => FAKE_ELEMENT,
|
|
45
|
+
highlight: async () => undefined,
|
|
46
|
+
scrollBy: async (deltaY) => {
|
|
47
|
+
scrollDeltas.push(deltaY);
|
|
48
|
+
return true;
|
|
49
|
+
},
|
|
50
|
+
close: async () => undefined,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let home: string;
|
|
54
|
+
let server: DaemonServer;
|
|
55
|
+
let session: ReviewSession;
|
|
56
|
+
let restoreUserConfig: () => void;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
|
|
60
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
61
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
62
|
+
server.start();
|
|
63
|
+
session = server.core.sessionCreate({
|
|
64
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
65
|
+
artifact: {
|
|
66
|
+
type: "prototype",
|
|
67
|
+
content: HTML,
|
|
68
|
+
meta: { title: "Pricing cards", prototypePath: "/repo/cards.html" },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
rendered = false;
|
|
72
|
+
scrollDeltas = [];
|
|
73
|
+
setPrototypeRendererFactory(async () => fakeRenderer);
|
|
74
|
+
});
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
setPrototypeRendererFactory(null);
|
|
77
|
+
restoreUserConfig();
|
|
78
|
+
server.stop();
|
|
79
|
+
rmSync(home, { recursive: true, force: true });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function renderApp() {
|
|
83
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
84
|
+
width: 120,
|
|
85
|
+
height: 32,
|
|
86
|
+
});
|
|
87
|
+
await waitForText(setup, "cueloop");
|
|
88
|
+
return setup;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
92
|
+
|
|
93
|
+
async function clickText(setup: Setup, needle: string): Promise<void> {
|
|
94
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
95
|
+
const row = lines.findIndex((line) => line.includes(needle));
|
|
96
|
+
await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
|
|
97
|
+
await setup.renderOnce();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("prototype review", () => {
|
|
101
|
+
test("click an element, comment on it, and it lands in the rail", async () => {
|
|
102
|
+
// Arrange - wait until the fake browser produced its first screenshot, so
|
|
103
|
+
// the sheet is interactive before the click
|
|
104
|
+
const setup = await renderApp();
|
|
105
|
+
await waitForState(setup, () => rendered);
|
|
106
|
+
await settle(setup);
|
|
107
|
+
|
|
108
|
+
// Act
|
|
109
|
+
await setup.mockMouse.click(6, 6);
|
|
110
|
+
await waitForText(setup, "comment");
|
|
111
|
+
await clickText(setup, "comment");
|
|
112
|
+
await waitForText(setup, "write a note");
|
|
113
|
+
// the keymap-suppression flag must propagate before typing so keys reach the
|
|
114
|
+
// textarea, not the global keymap
|
|
115
|
+
await settle(setup);
|
|
116
|
+
await typeText(setup, "tighten the padding");
|
|
117
|
+
await press(setup, "enter");
|
|
118
|
+
|
|
119
|
+
// Assert - the annotation is stored against the element's selector
|
|
120
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
121
|
+
const stored = server.core.sessionGet(session.id).annotations[0]!;
|
|
122
|
+
expect(stored.kind).toBe("comment");
|
|
123
|
+
expect(stored.body).toBe("tighten the padding");
|
|
124
|
+
expect(stored.anchor.selector).toBe("main > div.card");
|
|
125
|
+
|
|
126
|
+
// and it renders in the rail
|
|
127
|
+
await settle(setup);
|
|
128
|
+
const frame = setup.captureCharFrame();
|
|
129
|
+
expect(frame).toContain("tighten the padding");
|
|
130
|
+
expect(frame).toContain("COMMENT");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("wheel over the preview scrolls the page down, then up, and clears a selection", async () => {
|
|
134
|
+
// Arrange
|
|
135
|
+
const setup = await renderApp();
|
|
136
|
+
await waitForState(setup, () => rendered);
|
|
137
|
+
await settle(setup);
|
|
138
|
+
|
|
139
|
+
// open a selection, then scroll: the scroll drops it (it would drift once
|
|
140
|
+
// the page moves under it)
|
|
141
|
+
await setup.mockMouse.click(6, 6);
|
|
142
|
+
await waitForText(setup, "comment");
|
|
143
|
+
await setup.mockMouse.scroll(6, 6, "down");
|
|
144
|
+
await waitForState(setup, () => scrollDeltas.length === 1);
|
|
145
|
+
await waitForTextGone(setup, "comment");
|
|
146
|
+
|
|
147
|
+
// Act - scroll back up
|
|
148
|
+
await setup.mockMouse.scroll(6, 6, "up");
|
|
149
|
+
await waitForState(setup, () => scrollDeltas.length === 2);
|
|
150
|
+
|
|
151
|
+
// Assert - down was a positive delta, up was negative
|
|
152
|
+
expect(scrollDeltas[0]!).toBeGreaterThan(0);
|
|
153
|
+
expect(scrollDeltas[1]!).toBeLessThan(0);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/App.tsx
CHANGED
|
@@ -253,6 +253,7 @@ export function App({
|
|
|
253
253
|
const resolved = session?.status === "resolved";
|
|
254
254
|
const isDiff = session?.artifact.type === "diff";
|
|
255
255
|
const isPrototype = session?.artifact.type === "prototype";
|
|
256
|
+
const [prototypeComposing, setPrototypeComposing] = useState(false);
|
|
256
257
|
// sort position per annotation so the rail interleaves annotation and removal
|
|
257
258
|
// cards in one line-ordered stack: a diff row carries its blockIndex; a plan
|
|
258
259
|
// annotation resolves to the display index it marked
|
|
@@ -589,6 +590,9 @@ export function App({
|
|
|
589
590
|
};
|
|
590
591
|
|
|
591
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;
|
|
592
596
|
// A running in-tab agent terminal owns the keyboard: forward every key to it,
|
|
593
597
|
// with ctrl+] as the detach chord back to the review.
|
|
594
598
|
if (agentTerminal) {
|
|
@@ -1035,6 +1039,8 @@ export function App({
|
|
|
1035
1039
|
onCommentElement={(element, body) =>
|
|
1036
1040
|
controller.annotatePrototype(element.selector, element.quote, body)
|
|
1037
1041
|
}
|
|
1042
|
+
onComposingChange={setPrototypeComposing}
|
|
1043
|
+
hidden={menuOpen || menuDialog !== null}
|
|
1038
1044
|
/>
|
|
1039
1045
|
) : isDiff ? (
|
|
1040
1046
|
// the sheet dims to reading-quiet colors while the wizard has focus
|
|
@@ -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;
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Prototype review surface: renders the HTML
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
import React, { useEffect, useRef, useState } from "react";
|
|
8
|
-
import type {
|
|
12
|
+
import type { BoxRenderable } from "@opentui/core";
|
|
9
13
|
import { useRenderer } from "@opentui/react";
|
|
10
14
|
import type { Theme } from "../theme";
|
|
11
15
|
import { quickActionBody, type QuickAction } from "../config";
|
|
@@ -16,79 +20,181 @@ import { AnnotationCard } from "./AnnotationCard";
|
|
|
16
20
|
import {
|
|
17
21
|
cssBoxToCell,
|
|
18
22
|
imageCellToCss,
|
|
19
|
-
|
|
23
|
+
prototypeRendererFactory,
|
|
20
24
|
type PrototypeElement,
|
|
21
25
|
type PrototypeRenderer,
|
|
22
26
|
type PrototypeViewport,
|
|
23
27
|
} from "../prototype-browser";
|
|
28
|
+
import {
|
|
29
|
+
deleteKittyImage,
|
|
30
|
+
placeKittyImage,
|
|
31
|
+
transmitKittyImage,
|
|
32
|
+
type CellRegion,
|
|
33
|
+
} from "../kitty-image";
|
|
24
34
|
|
|
25
35
|
export interface PrototypeSheetProps {
|
|
26
36
|
prototypePath: string;
|
|
27
37
|
quickActions: QuickAction[];
|
|
28
38
|
canComment: boolean;
|
|
29
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
|
+
/** True while an app overlay (menu, settings) covers the sheet; the image is
|
|
44
|
+
* removed so those overlays are not shown through the graphics layer. */
|
|
45
|
+
hidden?: boolean;
|
|
30
46
|
theme?: Theme;
|
|
31
47
|
}
|
|
32
48
|
|
|
33
49
|
type SheetStatus = "loading" | "ready" | "unsupported" | "error";
|
|
34
50
|
|
|
35
|
-
const DEFAULT_CELL_ASPECT = 2;
|
|
36
51
|
const POPOVER_ROWS = 3;
|
|
52
|
+
const PROTOTYPE_IMAGE_ID = 811;
|
|
53
|
+
/** Page pixels scrolled per wheel notch. */
|
|
54
|
+
const SCROLL_STEP = 240;
|
|
55
|
+
// The capture viewport width in CSS pixels; its height matches the region's
|
|
56
|
+
// cell aspect so the rendered image fills the box instead of letterboxing. The
|
|
57
|
+
// aspect comes from the terminal's reported cell pixels, falling back to a
|
|
58
|
+
// typical monospace ratio when the terminal never reports one.
|
|
59
|
+
const CAPTURE_WIDTH = 1280;
|
|
60
|
+
const FALLBACK_CELL_HEIGHT_OVER_WIDTH = 2.18;
|
|
61
|
+
|
|
62
|
+
function cellHeightOverWidth(renderer: {
|
|
63
|
+
resolution: { width: number; height: number } | null;
|
|
64
|
+
terminalWidth: number;
|
|
65
|
+
terminalHeight: number;
|
|
66
|
+
}): number {
|
|
67
|
+
const resolution = renderer.resolution;
|
|
68
|
+
if (!resolution || renderer.terminalWidth < 1 || renderer.terminalHeight < 1) {
|
|
69
|
+
return FALLBACK_CELL_HEIGHT_OVER_WIDTH;
|
|
70
|
+
}
|
|
71
|
+
return resolution.height / renderer.terminalHeight / (resolution.width / renderer.terminalWidth);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function regionOf(box: BoxRenderable | null): CellRegion | null {
|
|
75
|
+
if (!box || box.width < 1 || box.height < 1) return null;
|
|
76
|
+
return { column: box.x, row: box.y, columns: box.width, rows: box.height };
|
|
77
|
+
}
|
|
37
78
|
|
|
38
79
|
export function PrototypeSheet({
|
|
39
80
|
prototypePath,
|
|
40
81
|
quickActions,
|
|
41
82
|
canComment,
|
|
42
83
|
onCommentElement,
|
|
84
|
+
onComposingChange,
|
|
85
|
+
hidden = false,
|
|
43
86
|
theme,
|
|
44
87
|
}: PrototypeSheetProps): React.ReactNode {
|
|
45
88
|
const tokens = useComponentTheme(theme);
|
|
46
89
|
const renderer = useRenderer();
|
|
47
|
-
const
|
|
90
|
+
const regionRef = useRef<BoxRenderable | null>(null);
|
|
48
91
|
const browserRef = useRef<PrototypeRenderer | null>(null);
|
|
49
92
|
const launchedRef = useRef(false);
|
|
50
|
-
const viewportRef = useRef<PrototypeViewport>({ width:
|
|
93
|
+
const viewportRef = useRef<PrototypeViewport>({ width: CAPTURE_WIDTH, height: 800 });
|
|
94
|
+
const pngRef = useRef<Uint8Array | null>(null);
|
|
95
|
+
const transmittedRef = useRef<Uint8Array | null>(null);
|
|
96
|
+
const paintRef = useRef<() => void>(() => undefined);
|
|
97
|
+
const launchRef = useRef<() => void>(() => undefined);
|
|
98
|
+
const hiddenRef = useRef(hidden);
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
hiddenRef.current = hidden;
|
|
101
|
+
});
|
|
51
102
|
|
|
52
103
|
const [status, setStatus] = useState<SheetStatus>("loading");
|
|
53
104
|
const [errorMessage, setErrorMessage] = useState<string>("");
|
|
54
|
-
const [png, setPng] = useState<Uint8Array | null>(null);
|
|
55
105
|
const [selected, setSelected] = useState<PrototypeElement | null>(null);
|
|
56
106
|
const [overlayCell, setOverlayCell] = useState<{ left: number; top: number } | null>(null);
|
|
57
107
|
const [actionsOpen, setActionsOpen] = useState(false);
|
|
58
108
|
const [composing, setComposing] = useState(false);
|
|
59
109
|
const [draftText, setDraftText] = useState("");
|
|
60
110
|
|
|
111
|
+
const setPng = (png: Uint8Array): void => {
|
|
112
|
+
pngRef.current = png;
|
|
113
|
+
transmittedRef.current = null;
|
|
114
|
+
paintRef.current();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Every frame: start the browser once layout is known, then keep the picture
|
|
118
|
+
// asserted over its cells. The kitty emission (transmit once, cheap re-place
|
|
119
|
+
// after) is skipped when the renderer has no raw writeOut channel (the test
|
|
120
|
+
// harness), but the launch trigger still runs.
|
|
61
121
|
useEffect(() => {
|
|
122
|
+
if (!renderer) return;
|
|
123
|
+
// writeOut is the renderer's raw output channel, ordered against its frame
|
|
124
|
+
// bytes; it is private, so reach it through one narrow cast
|
|
125
|
+
const rawWrite = (renderer as unknown as { writeOut?: (data: string) => void }).writeOut;
|
|
126
|
+
const write =
|
|
127
|
+
typeof rawWrite === "function" ? (chunk: string) => rawWrite.call(renderer, chunk) : null;
|
|
128
|
+
const paint = (): void => {
|
|
129
|
+
if (!write) return;
|
|
130
|
+
// an app overlay is covering the sheet: pull the image so it does not show
|
|
131
|
+
// through the overlay's cells, and re-transmit once the overlay closes
|
|
132
|
+
if (hiddenRef.current) {
|
|
133
|
+
if (transmittedRef.current !== null) {
|
|
134
|
+
deleteKittyImage(write, PROTOTYPE_IMAGE_ID);
|
|
135
|
+
transmittedRef.current = null;
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const region = regionOf(regionRef.current);
|
|
140
|
+
const png = pngRef.current;
|
|
141
|
+
if (!region || !png) return;
|
|
142
|
+
if (transmittedRef.current !== png) {
|
|
143
|
+
transmitKittyImage(write, png, region, PROTOTYPE_IMAGE_ID);
|
|
144
|
+
transmittedRef.current = png;
|
|
145
|
+
} else {
|
|
146
|
+
placeKittyImage(write, region, PROTOTYPE_IMAGE_ID);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
paintRef.current = paint;
|
|
150
|
+
const onFrame = (): void => {
|
|
151
|
+
launchRef.current();
|
|
152
|
+
paint();
|
|
153
|
+
};
|
|
154
|
+
renderer.on("frame", onFrame);
|
|
62
155
|
return () => {
|
|
63
|
-
|
|
156
|
+
renderer.off("frame", onFrame);
|
|
157
|
+
if (write) deleteKittyImage(write, PROTOTYPE_IMAGE_ID);
|
|
64
158
|
};
|
|
65
|
-
}, []);
|
|
159
|
+
}, [renderer]);
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
onComposingChange?.(composing);
|
|
163
|
+
}, [composing, onComposingChange]);
|
|
66
164
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
: DEFAULT_CELL_ASPECT;
|
|
73
|
-
return {
|
|
74
|
-
width: Math.max(320, Math.round(columns * cellWidth)),
|
|
75
|
-
height: Math.max(240, Math.round(rows * cellHeight)),
|
|
165
|
+
const unmountedRef = useRef(false);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
return () => {
|
|
168
|
+
unmountedRef.current = true;
|
|
169
|
+
void browserRef.current?.close();
|
|
76
170
|
};
|
|
77
|
-
};
|
|
171
|
+
}, []);
|
|
78
172
|
|
|
79
173
|
const launch = (): void => {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
if (
|
|
174
|
+
const region = regionOf(regionRef.current);
|
|
175
|
+
const caps = renderer?.capabilities;
|
|
176
|
+
if (launchedRef.current || !region) return;
|
|
177
|
+
if (caps && caps.kitty_graphics === false) {
|
|
83
178
|
setStatus("unsupported");
|
|
84
179
|
return;
|
|
85
180
|
}
|
|
86
181
|
launchedRef.current = true;
|
|
87
|
-
const viewport =
|
|
182
|
+
const viewport = {
|
|
183
|
+
width: CAPTURE_WIDTH,
|
|
184
|
+
height: Math.round(
|
|
185
|
+
(CAPTURE_WIDTH * region.rows * cellHeightOverWidth(renderer!)) / region.columns,
|
|
186
|
+
),
|
|
187
|
+
};
|
|
88
188
|
viewportRef.current = viewport;
|
|
89
189
|
void (async () => {
|
|
90
190
|
try {
|
|
91
|
-
const browser = await
|
|
191
|
+
const browser = await prototypeRendererFactory()({ filePath: prototypePath, viewport });
|
|
192
|
+
// the sheet may have unmounted while Chromium was launching; close the
|
|
193
|
+
// late arrival instead of leaking the process
|
|
194
|
+
if (unmountedRef.current) {
|
|
195
|
+
void browser.close();
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
92
198
|
browserRef.current = browser;
|
|
93
199
|
setPng(await browser.screenshot());
|
|
94
200
|
setStatus("ready");
|
|
@@ -98,6 +204,9 @@ export function PrototypeSheet({
|
|
|
98
204
|
}
|
|
99
205
|
})();
|
|
100
206
|
};
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
launchRef.current = launch;
|
|
209
|
+
});
|
|
101
210
|
|
|
102
211
|
const refresh = async (): Promise<void> => {
|
|
103
212
|
const browser = browserRef.current;
|
|
@@ -116,11 +225,16 @@ export function PrototypeSheet({
|
|
|
116
225
|
.catch(() => undefined);
|
|
117
226
|
};
|
|
118
227
|
|
|
119
|
-
const
|
|
228
|
+
const onRegionMouseDown = (event: { x: number; y: number }): void => {
|
|
120
229
|
const browser = browserRef.current;
|
|
121
|
-
const
|
|
122
|
-
if (status !== "ready" || !browser || !
|
|
123
|
-
const geometry = {
|
|
230
|
+
const region = regionOf(regionRef.current);
|
|
231
|
+
if (status !== "ready" || !browser || !region) return;
|
|
232
|
+
const geometry = {
|
|
233
|
+
x: region.column,
|
|
234
|
+
y: region.row,
|
|
235
|
+
width: region.columns,
|
|
236
|
+
height: region.rows,
|
|
237
|
+
};
|
|
124
238
|
const cssPoint = imageCellToCss(event, geometry, viewportRef.current);
|
|
125
239
|
if (!cssPoint) return;
|
|
126
240
|
void (async () => {
|
|
@@ -143,19 +257,32 @@ export function PrototypeSheet({
|
|
|
143
257
|
});
|
|
144
258
|
};
|
|
145
259
|
|
|
260
|
+
const onRegionScroll = (event: { scroll?: { direction: string } }): void => {
|
|
261
|
+
const browser = browserRef.current;
|
|
262
|
+
if (status !== "ready" || !browser) return;
|
|
263
|
+
const delta = event.scroll?.direction === "up" ? -SCROLL_STEP : SCROLL_STEP;
|
|
264
|
+
// a selection would drift once the page scrolls under it, so drop it
|
|
265
|
+
clearSelection();
|
|
266
|
+
void browser
|
|
267
|
+
.scrollBy(delta)
|
|
268
|
+
.then(() => refresh())
|
|
269
|
+
.catch(() => undefined);
|
|
270
|
+
};
|
|
271
|
+
|
|
146
272
|
const commit = (body: string): void => {
|
|
147
273
|
if (!canComment) return;
|
|
148
274
|
if (selected && body.trim()) onCommentElement(selected, body.trim());
|
|
149
275
|
clearSelection();
|
|
150
276
|
};
|
|
151
277
|
|
|
152
|
-
|
|
153
|
-
|
|
278
|
+
// the overlay carries an opaque fill so the popover and compose card read as
|
|
279
|
+
// solid cards over the image, matching the plan surface
|
|
154
280
|
const overlayStyle = {
|
|
155
281
|
position: "absolute" as const,
|
|
156
|
-
left:
|
|
157
|
-
top:
|
|
282
|
+
left: overlayCell?.left ?? 0,
|
|
283
|
+
top: Math.max(0, overlayCell?.top ?? 0),
|
|
158
284
|
flexDirection: "column" as const,
|
|
285
|
+
backgroundColor: tokens.elevated,
|
|
159
286
|
};
|
|
160
287
|
|
|
161
288
|
return (
|
|
@@ -168,16 +295,14 @@ export function PrototypeSheet({
|
|
|
168
295
|
borderColor: tokens.text,
|
|
169
296
|
}}
|
|
170
297
|
>
|
|
171
|
-
<
|
|
172
|
-
ref={(instance:
|
|
173
|
-
|
|
298
|
+
<box
|
|
299
|
+
ref={(instance: BoxRenderable | null) => {
|
|
300
|
+
regionRef.current = instance;
|
|
174
301
|
}}
|
|
175
|
-
{...(png ? { source: png } : {})}
|
|
176
|
-
fit="fill"
|
|
177
|
-
protocol="auto"
|
|
178
302
|
style={{ flexGrow: 1 }}
|
|
179
303
|
onSizeChange={launch}
|
|
180
|
-
onMouseDown={
|
|
304
|
+
onMouseDown={onRegionMouseDown}
|
|
305
|
+
onMouseScroll={onRegionScroll}
|
|
181
306
|
/>
|
|
182
307
|
{status !== "ready" ? (
|
|
183
308
|
<box style={{ position: "absolute", left: 2, top: 1 }}>
|
|
@@ -191,7 +316,11 @@ export function PrototypeSheet({
|
|
|
191
316
|
actions={quickActions}
|
|
192
317
|
actionIndex={0}
|
|
193
318
|
canCut={false}
|
|
194
|
-
onComment={() =>
|
|
319
|
+
onComment={() => {
|
|
320
|
+
if (!canComment) return;
|
|
321
|
+
onComposingChange?.(true);
|
|
322
|
+
setComposing(true);
|
|
323
|
+
}}
|
|
195
324
|
onCut={() => undefined}
|
|
196
325
|
onOpenActions={() => setActionsOpen(true)}
|
|
197
326
|
onClose={clearSelection}
|
|
@@ -210,6 +339,7 @@ export function PrototypeSheet({
|
|
|
210
339
|
text: draftText,
|
|
211
340
|
onInput: setDraftText,
|
|
212
341
|
onSave: () => commit(draftText),
|
|
342
|
+
onSubmit: () => commit(draftText),
|
|
213
343
|
onCancel: clearSelection,
|
|
214
344
|
}}
|
|
215
345
|
theme={theme}
|
|
@@ -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
|
|
|
@@ -1825,33 +1853,5 @@ exports[`stories catalog theme-context/ProviderSwap renders, snapshots, and carr
|
|
|
1825
1853
|
|
|
1826
1854
|
|
|
1827
1855
|
|
|
1828
|
-
"
|
|
1829
|
-
`;
|
|
1830
|
-
|
|
1831
|
-
exports[`stories catalog PrototypeSheet/Unsupported renders, snapshots, and carries its colors: PrototypeSheet/Unsupported 1`] = `
|
|
1832
|
-
"╭──────────────────────────────────────────────────────────────────────────────╮
|
|
1833
|
-
│ │
|
|
1834
|
-
│ rendering prototype… │
|
|
1835
|
-
│ │
|
|
1836
|
-
│ │
|
|
1837
|
-
│ │
|
|
1838
|
-
│ │
|
|
1839
|
-
│ │
|
|
1840
|
-
│ │
|
|
1841
|
-
│ │
|
|
1842
|
-
│ │
|
|
1843
|
-
│ │
|
|
1844
|
-
│ │
|
|
1845
|
-
│ │
|
|
1846
|
-
│ │
|
|
1847
|
-
│ │
|
|
1848
|
-
│ │
|
|
1849
|
-
│ │
|
|
1850
|
-
│ │
|
|
1851
|
-
│ │
|
|
1852
|
-
│ │
|
|
1853
|
-
│ │
|
|
1854
|
-
│ │
|
|
1855
|
-
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
1856
1856
|
"
|
|
1857
1857
|
`;
|
|
@@ -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
|
+
}
|
package/src/prototype-browser.ts
CHANGED
|
@@ -31,6 +31,8 @@ export interface PrototypeRenderer {
|
|
|
31
31
|
screenshot(): Promise<Uint8Array>;
|
|
32
32
|
elementAt(cssX: number, cssY: number): Promise<PrototypeElement | null>;
|
|
33
33
|
highlight(selector: string | null): Promise<void>;
|
|
34
|
+
/** Scroll the page by a pixel delta; returns whether the scroll position moved. */
|
|
35
|
+
scrollBy(deltaY: number): Promise<boolean>;
|
|
34
36
|
close(): Promise<void>;
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -79,6 +81,20 @@ export function cssBoxToCell(
|
|
|
79
81
|
};
|
|
80
82
|
}
|
|
81
83
|
|
|
84
|
+
export type PrototypeRendererFactory = (options: LaunchOptions) => Promise<PrototypeRenderer>;
|
|
85
|
+
|
|
86
|
+
let rendererFactory: PrototypeRendererFactory | null = null;
|
|
87
|
+
|
|
88
|
+
/** Override the renderer factory so tests can inject a browserless fake. */
|
|
89
|
+
export function setPrototypeRendererFactory(factory: PrototypeRendererFactory | null): void {
|
|
90
|
+
rendererFactory = factory;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The active factory - the injected fake in tests, else real headless Chromium. */
|
|
94
|
+
export function prototypeRendererFactory(): PrototypeRendererFactory {
|
|
95
|
+
return rendererFactory ?? launchPrototypeRenderer;
|
|
96
|
+
}
|
|
97
|
+
|
|
82
98
|
export async function launchPrototypeRenderer(options: LaunchOptions): Promise<PrototypeRenderer> {
|
|
83
99
|
const puppeteer = (await import("puppeteer-core")).default;
|
|
84
100
|
const { pathToFileURL } = await import("node:url");
|
|
@@ -116,6 +132,9 @@ export async function launchPrototypeRenderer(options: LaunchOptions): Promise<P
|
|
|
116
132
|
async highlight(selector) {
|
|
117
133
|
await page.evaluate(highlightScript, selector);
|
|
118
134
|
},
|
|
135
|
+
async scrollBy(deltaY) {
|
|
136
|
+
return (await page.evaluate(scrollByScript, deltaY)) as boolean;
|
|
137
|
+
},
|
|
119
138
|
async close() {
|
|
120
139
|
await browser.close();
|
|
121
140
|
},
|
|
@@ -187,6 +206,12 @@ function elementAtScript(x: number, y: number): unknown {
|
|
|
187
206
|
return { selector, quote, box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height } };
|
|
188
207
|
}
|
|
189
208
|
|
|
209
|
+
function scrollByScript(deltaY: number): boolean {
|
|
210
|
+
const before = window.scrollY;
|
|
211
|
+
window.scrollBy(0, deltaY);
|
|
212
|
+
return window.scrollY !== before;
|
|
213
|
+
}
|
|
214
|
+
|
|
190
215
|
function highlightScript(selector: string | null): void {
|
|
191
216
|
const ATTRIBUTE = "data-cueloop-selected";
|
|
192
217
|
const STYLE_ID = "cueloop-highlight-style";
|