@schlessera/brain-ui-react 0.12.1 → 0.13.1
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/dist/components/chat/brain-markdown.d.ts.map +1 -1
- package/dist/components/chat/brain-markdown.js +12 -5
- package/dist/components/chat/brain-markdown.js.map +1 -1
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +2 -1
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/chat/mermaid-viewer.d.ts +11 -1
- package/dist/components/chat/mermaid-viewer.d.ts.map +1 -1
- package/dist/components/chat/mermaid-viewer.js +8 -250
- package/dist/components/chat/mermaid-viewer.js.map +1 -1
- package/dist/components/chat/message-bubble.d.ts.map +1 -1
- package/dist/components/chat/message-bubble.js +2 -1
- package/dist/components/chat/message-bubble.js.map +1 -1
- package/dist/components/chat/session-drawer.d.ts.map +1 -1
- package/dist/components/chat/session-drawer.js +11 -3
- package/dist/components/chat/session-drawer.js.map +1 -1
- package/dist/components/chat/share-card.d.ts +9 -0
- package/dist/components/chat/share-card.d.ts.map +1 -0
- package/dist/components/chat/share-card.js +58 -0
- package/dist/components/chat/share-card.js.map +1 -0
- package/dist/components/dev/share-harness.d.ts +4 -0
- package/dist/components/dev/share-harness.d.ts.map +1 -0
- package/dist/components/dev/share-harness.js +104 -0
- package/dist/components/dev/share-harness.js.map +1 -0
- package/dist/components/files/file-viewer-binary.d.ts.map +1 -1
- package/dist/components/files/file-viewer-binary.js +2 -1
- package/dist/components/files/file-viewer-binary.js.map +1 -1
- package/dist/components/files/file-viewer.d.ts.map +1 -1
- package/dist/components/files/file-viewer.js +11 -2
- package/dist/components/files/file-viewer.js.map +1 -1
- package/dist/components/images/image-share.d.ts +18 -0
- package/dist/components/images/image-share.d.ts.map +1 -0
- package/dist/components/images/image-share.js +47 -0
- package/dist/components/images/image-share.js.map +1 -0
- package/dist/components/images/image-viewer.d.ts +17 -0
- package/dist/components/images/image-viewer.d.ts.map +1 -0
- package/dist/components/images/image-viewer.js +20 -0
- package/dist/components/images/image-viewer.js.map +1 -0
- package/dist/components/images/zoomable-image.d.ts +35 -0
- package/dist/components/images/zoomable-image.d.ts.map +1 -0
- package/dist/components/images/zoomable-image.js +40 -0
- package/dist/components/images/zoomable-image.js.map +1 -0
- package/dist/components/viewer/zoom-viewer.d.ts +26 -0
- package/dist/components/viewer/zoom-viewer.d.ts.map +1 -0
- package/dist/components/viewer/zoom-viewer.js +254 -0
- package/dist/components/viewer/zoom-viewer.js.map +1 -0
- package/dist/hooks/use-share-intake.d.ts +6 -0
- package/dist/hooks/use-share-intake.d.ts.map +1 -0
- package/dist/hooks/use-share-intake.js +153 -0
- package/dist/hooks/use-share-intake.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts +13 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +24 -1
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/image-optimize.d.ts +62 -0
- package/dist/lib/image-optimize.d.ts.map +1 -0
- package/dist/lib/image-optimize.js +152 -0
- package/dist/lib/image-optimize.js.map +1 -0
- package/dist/lib/image-source.d.ts +13 -0
- package/dist/lib/image-source.d.ts.map +1 -0
- package/dist/lib/image-source.js +57 -0
- package/dist/lib/image-source.js.map +1 -0
- package/dist/lib/share-intake.d.ts +49 -0
- package/dist/lib/share-intake.d.ts.map +1 -0
- package/dist/lib/share-intake.js +203 -0
- package/dist/lib/share-intake.js.map +1 -0
- package/dist/stores/chat-store.d.ts +7 -1
- package/dist/stores/chat-store.d.ts.map +1 -1
- package/dist/stores/chat-store.js +13 -3
- package/dist/stores/chat-store.js.map +1 -1
- package/dist/stores/share-store.d.ts +29 -0
- package/dist/stores/share-store.d.ts.map +1 -0
- package/dist/stores/share-store.js +19 -0
- package/dist/stores/share-store.js.map +1 -0
- package/dist/styles.css +1 -1
- package/package.json +2 -2
- package/src/components/chat/brain-markdown.tsx +15 -9
- package/src/components/chat/chat-page.tsx +6 -0
- package/src/components/chat/mermaid-viewer.tsx +15 -310
- package/src/components/chat/message-bubble.tsx +6 -2
- package/src/components/chat/session-drawer.tsx +19 -3
- package/src/components/chat/share-card.tsx +185 -0
- package/src/components/dev/share-harness.tsx +243 -0
- package/src/components/files/file-viewer-binary.tsx +10 -1
- package/src/components/files/file-viewer.tsx +11 -2
- package/src/components/images/image-share.ts +57 -0
- package/src/components/images/image-viewer.tsx +50 -0
- package/src/components/images/zoomable-image.tsx +93 -0
- package/src/components/viewer/zoom-viewer.tsx +354 -0
- package/src/hooks/use-share-intake.ts +190 -0
- package/src/hooks/use-websocket.ts +24 -1
- package/src/index.ts +21 -1
- package/src/lib/image-optimize.ts +195 -0
- package/src/lib/image-source.ts +53 -0
- package/src/lib/share-intake.ts +231 -0
- package/src/stores/chat-store.ts +25 -4
- package/src/stores/share-store.ts +54 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import { Minus, Plus, Maximize2, X } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Full-screen, pan-and-zoom view of one visual thing — a diagram, an image,
|
|
7
|
+
* anything with a natural pixel size. Callers supply the content and any extra
|
|
8
|
+
* toolbar actions; everything about the gesture surface lives here.
|
|
9
|
+
*
|
|
10
|
+
* Hand-rolled on pointer events rather than pulling a pan/zoom library: the
|
|
11
|
+
* whole surface is two transforms and the repo has no other dependency of that
|
|
12
|
+
* shape. Touch gestures work because the stage sets `touch-action: none` —
|
|
13
|
+
* the browser hands us every pointer instead of scrolling the page, which is
|
|
14
|
+
* also why no preventDefault is needed on the touch path. Wheel is the one
|
|
15
|
+
* exception: React's onWheel is passive, so it is bound manually.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const MIN_SCALE = 0.15;
|
|
19
|
+
const MAX_SCALE = 12;
|
|
20
|
+
|
|
21
|
+
interface Point {
|
|
22
|
+
x: number;
|
|
23
|
+
y: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function clampScale(s: number): number {
|
|
27
|
+
return Math.min(MAX_SCALE, Math.max(MIN_SCALE, s));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ZoomViewerProps {
|
|
31
|
+
children: React.ReactNode;
|
|
32
|
+
onClose: () => void;
|
|
33
|
+
/**
|
|
34
|
+
* Extra toolbar nodes, placed left of the close button — typically a share
|
|
35
|
+
* menu. Rendered inside the header, above the stage.
|
|
36
|
+
*/
|
|
37
|
+
actions?: React.ReactNode;
|
|
38
|
+
/**
|
|
39
|
+
* Changes when the content's natural size may have changed (a streaming
|
|
40
|
+
* diagram growing, an image finishing its decode). Re-fits, but only while
|
|
41
|
+
* the user has not panned or zoomed — after that nothing moves behind them.
|
|
42
|
+
*/
|
|
43
|
+
refitKey?: unknown;
|
|
44
|
+
/**
|
|
45
|
+
* Ceiling on the scale `fit()` may choose. Small diagrams may grow to fill
|
|
46
|
+
* the stage (blowing one up 8x reads as broken, hence a cap), but a raster
|
|
47
|
+
* image has real pixels: opening it above 100% only shows interpolation, so
|
|
48
|
+
* image callers pass 1.
|
|
49
|
+
*/
|
|
50
|
+
maxFitScale?: number;
|
|
51
|
+
/** Aria label for the overlay. */
|
|
52
|
+
label?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ZoomViewer({
|
|
56
|
+
children,
|
|
57
|
+
onClose,
|
|
58
|
+
actions,
|
|
59
|
+
refitKey,
|
|
60
|
+
maxFitScale = 2.5,
|
|
61
|
+
label = "Zoomed view",
|
|
62
|
+
}: ZoomViewerProps) {
|
|
63
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
64
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
const [scale, setScale] = useState(1);
|
|
66
|
+
const [offset, setOffset] = useState<Point>({ x: 0, y: 0 });
|
|
67
|
+
const [ready, setReady] = useState(false);
|
|
68
|
+
|
|
69
|
+
// Live gesture state lives in refs: pointer maths must not wait for a render.
|
|
70
|
+
const pointers = useRef(new Map<number, Point>());
|
|
71
|
+
const pinch = useRef<{ dist: number; center: Point } | null>(null);
|
|
72
|
+
const panFrom = useRef<{ pointer: Point; offset: Point } | null>(null);
|
|
73
|
+
const view = useRef({ scale: 1, offset: { x: 0, y: 0 } });
|
|
74
|
+
/** The scale `fit()` last produced — the baseline double-click toggles against. */
|
|
75
|
+
const fitScale = useRef(1);
|
|
76
|
+
/** Set once the user pans/zooms; after that nothing may re-fit behind their back. */
|
|
77
|
+
const touched = useRef(false);
|
|
78
|
+
|
|
79
|
+
const apply = useCallback((next: { scale: number; offset: Point }) => {
|
|
80
|
+
view.current = next;
|
|
81
|
+
setScale(next.scale);
|
|
82
|
+
setOffset(next.offset);
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
/** Natural (untransformed) size of the content — offsetWidth ignores transforms. */
|
|
86
|
+
const natural = useCallback((): { w: number; h: number } | null => {
|
|
87
|
+
const el = contentRef.current;
|
|
88
|
+
if (!el || !el.offsetWidth || !el.offsetHeight) return null;
|
|
89
|
+
return { w: el.offsetWidth, h: el.offsetHeight };
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const fit = useCallback(() => {
|
|
93
|
+
const stage = stageRef.current;
|
|
94
|
+
const size = natural();
|
|
95
|
+
if (!stage || !size) return;
|
|
96
|
+
const pad = 32;
|
|
97
|
+
const sx = (stage.clientWidth - pad * 2) / size.w;
|
|
98
|
+
const sy = (stage.clientHeight - pad * 2) / size.h;
|
|
99
|
+
const next = clampScale(Math.min(maxFitScale, Math.min(sx, sy)));
|
|
100
|
+
fitScale.current = next;
|
|
101
|
+
apply({
|
|
102
|
+
scale: next,
|
|
103
|
+
offset: {
|
|
104
|
+
x: (stage.clientWidth - size.w * next) / 2,
|
|
105
|
+
y: (stage.clientHeight - size.h * next) / 2,
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}, [apply, natural, maxFitScale]);
|
|
109
|
+
|
|
110
|
+
useLayoutEffect(() => {
|
|
111
|
+
// Re-runs when the content itself changes, which happens while a message is
|
|
112
|
+
// still streaming — so a user who has already panned keeps their view.
|
|
113
|
+
if (!touched.current) fit();
|
|
114
|
+
setReady(true);
|
|
115
|
+
}, [fit, refitKey]);
|
|
116
|
+
|
|
117
|
+
/** Zoom about a stage-local anchor, so the point under the cursor/fingers stays put. */
|
|
118
|
+
const zoomAt = useCallback(
|
|
119
|
+
(factor: number, anchor: Point) => {
|
|
120
|
+
// Any deliberate zoom counts as touching the view — including the
|
|
121
|
+
// toolbar buttons and the wheel. Without this, content that is still
|
|
122
|
+
// streaming would re-fit itself out from under the reader's zoom.
|
|
123
|
+
touched.current = true;
|
|
124
|
+
const { scale: prev, offset: prevOffset } = view.current;
|
|
125
|
+
const next = clampScale(prev * factor);
|
|
126
|
+
if (next === prev) return;
|
|
127
|
+
const ratio = next / prev;
|
|
128
|
+
apply({
|
|
129
|
+
scale: next,
|
|
130
|
+
offset: {
|
|
131
|
+
x: anchor.x - (anchor.x - prevOffset.x) * ratio,
|
|
132
|
+
y: anchor.y - (anchor.y - prevOffset.y) * ratio,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
[apply]
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const zoomCenter = useCallback(
|
|
140
|
+
(factor: number) => {
|
|
141
|
+
const stage = stageRef.current;
|
|
142
|
+
if (!stage) return;
|
|
143
|
+
zoomAt(factor, { x: stage.clientWidth / 2, y: stage.clientHeight / 2 });
|
|
144
|
+
},
|
|
145
|
+
[zoomAt]
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const stagePoint = (e: { clientX: number; clientY: number }): Point => {
|
|
149
|
+
const rect = stageRef.current?.getBoundingClientRect();
|
|
150
|
+
return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Wheel must be bound manually: React's onWheel is passive, so it cannot
|
|
154
|
+
// preventDefault, and the page behind would scroll (or the browser would
|
|
155
|
+
// pinch-zoom the whole PWA on a ctrl+wheel trackpad gesture).
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const stage = stageRef.current;
|
|
158
|
+
if (!stage) return;
|
|
159
|
+
const onWheel = (e: WheelEvent) => {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
// A trackpad pinch arrives as ctrlKey+wheel with small deltas; a mouse
|
|
162
|
+
// wheel arrives as coarse notches. Both map to the same exponential.
|
|
163
|
+
const factor = Math.exp(-e.deltaY * (e.ctrlKey ? 0.01 : 0.0015));
|
|
164
|
+
zoomAt(factor, stagePoint(e));
|
|
165
|
+
};
|
|
166
|
+
stage.addEventListener("wheel", onWheel, { passive: false });
|
|
167
|
+
return () => stage.removeEventListener("wheel", onWheel);
|
|
168
|
+
}, [zoomAt]);
|
|
169
|
+
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
const onKey = (e: KeyboardEvent) => {
|
|
172
|
+
if (e.key === "Escape") onClose();
|
|
173
|
+
else if (e.key === "+" || e.key === "=") zoomCenter(1.25);
|
|
174
|
+
else if (e.key === "-" || e.key === "_") zoomCenter(0.8);
|
|
175
|
+
else if (e.key === "0") fit();
|
|
176
|
+
};
|
|
177
|
+
document.addEventListener("keydown", onKey);
|
|
178
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
179
|
+
}, [onClose, zoomCenter, fit]);
|
|
180
|
+
|
|
181
|
+
// Lock body scroll while open.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
const prev = document.body.style.overflow;
|
|
184
|
+
document.body.style.overflow = "hidden";
|
|
185
|
+
return () => {
|
|
186
|
+
document.body.style.overflow = prev;
|
|
187
|
+
};
|
|
188
|
+
}, []);
|
|
189
|
+
|
|
190
|
+
// Re-fit on rotation / resize only while the view is untouched, so a resize
|
|
191
|
+
// never yanks content the user has deliberately panned into place.
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
const onResize = () => {
|
|
194
|
+
if (!touched.current) fit();
|
|
195
|
+
};
|
|
196
|
+
window.addEventListener("resize", onResize);
|
|
197
|
+
return () => window.removeEventListener("resize", onResize);
|
|
198
|
+
}, [fit]);
|
|
199
|
+
|
|
200
|
+
const onPointerDown = (e: React.PointerEvent) => {
|
|
201
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
202
|
+
pointers.current.set(e.pointerId, stagePoint(e));
|
|
203
|
+
if (pointers.current.size === 2) {
|
|
204
|
+
const [a, b] = [...pointers.current.values()];
|
|
205
|
+
pinch.current = {
|
|
206
|
+
dist: Math.hypot(a.x - b.x, a.y - b.y),
|
|
207
|
+
center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
|
208
|
+
};
|
|
209
|
+
panFrom.current = null;
|
|
210
|
+
} else if (pointers.current.size === 1) {
|
|
211
|
+
panFrom.current = { pointer: stagePoint(e), offset: view.current.offset };
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const onPointerMove = (e: React.PointerEvent) => {
|
|
216
|
+
if (!pointers.current.has(e.pointerId)) return;
|
|
217
|
+
const p = stagePoint(e);
|
|
218
|
+
pointers.current.set(e.pointerId, p);
|
|
219
|
+
|
|
220
|
+
if (pointers.current.size >= 2 && pinch.current) {
|
|
221
|
+
const [a, b] = [...pointers.current.values()];
|
|
222
|
+
const dist = Math.hypot(a.x - b.x, a.y - b.y);
|
|
223
|
+
const center = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
224
|
+
if (pinch.current.dist > 0) {
|
|
225
|
+
touched.current = true;
|
|
226
|
+
// Pan and zoom in one step: the midpoint drift moves the content, the
|
|
227
|
+
// distance ratio scales it about that same midpoint.
|
|
228
|
+
const drift = {
|
|
229
|
+
x: center.x - pinch.current.center.x,
|
|
230
|
+
y: center.y - pinch.current.center.y,
|
|
231
|
+
};
|
|
232
|
+
// Committed through apply(), not written straight to the ref: two
|
|
233
|
+
// fingers moving in parallel (or a pinch already clamped at min/max)
|
|
234
|
+
// produce no scale change, and zoomAt() returns early — the drift
|
|
235
|
+
// would then be stranded in the ref and nothing would pan.
|
|
236
|
+
apply({
|
|
237
|
+
scale: view.current.scale,
|
|
238
|
+
offset: {
|
|
239
|
+
x: view.current.offset.x + drift.x,
|
|
240
|
+
y: view.current.offset.y + drift.y,
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
zoomAt(dist / pinch.current.dist, center);
|
|
244
|
+
}
|
|
245
|
+
pinch.current = { dist, center };
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (panFrom.current) {
|
|
250
|
+
touched.current = true;
|
|
251
|
+
apply({
|
|
252
|
+
scale: view.current.scale,
|
|
253
|
+
offset: {
|
|
254
|
+
x: panFrom.current.offset.x + (p.x - panFrom.current.pointer.x),
|
|
255
|
+
y: panFrom.current.offset.y + (p.y - panFrom.current.pointer.y),
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const endPointer = (e: React.PointerEvent) => {
|
|
262
|
+
pointers.current.delete(e.pointerId);
|
|
263
|
+
if (pointers.current.size >= 2) {
|
|
264
|
+
// Still pinching, but with a DIFFERENT pair (a stray third touch got
|
|
265
|
+
// lifted). Rebase the baseline, or the next move would compare the new
|
|
266
|
+
// pair's spread against the old pair's and jump.
|
|
267
|
+
const [a, b] = [...pointers.current.values()];
|
|
268
|
+
pinch.current = {
|
|
269
|
+
dist: Math.hypot(a.x - b.x, a.y - b.y),
|
|
270
|
+
center: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
|
|
271
|
+
};
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
pinch.current = null;
|
|
275
|
+
if (pointers.current.size === 1) {
|
|
276
|
+
// Lifting one finger of a pinch must not teleport the content: re-anchor
|
|
277
|
+
// the pan to whichever pointer is still down.
|
|
278
|
+
const [remaining] = [...pointers.current.values()];
|
|
279
|
+
panFrom.current = { pointer: remaining, offset: view.current.offset };
|
|
280
|
+
} else if (pointers.current.size === 0) {
|
|
281
|
+
panFrom.current = null;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const onDoubleClick = (e: React.MouseEvent) => {
|
|
286
|
+
touched.current = true;
|
|
287
|
+
// Compared against the FIT scale, not against 1: content that fits at 125%
|
|
288
|
+
// would otherwise read as "already zoomed" and double-click would re-fit it
|
|
289
|
+
// to exactly where it already was.
|
|
290
|
+
if (view.current.scale > fitScale.current * 1.05) fit();
|
|
291
|
+
else zoomAt(2, stagePoint(e));
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const btn =
|
|
295
|
+
"flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground";
|
|
296
|
+
|
|
297
|
+
// Portalled to <body>: message bubbles animate through framer-motion, and a
|
|
298
|
+
// transformed ancestor becomes the containing block for `fixed`, which would
|
|
299
|
+
// pin this overlay to the bubble instead of the viewport.
|
|
300
|
+
return createPortal(
|
|
301
|
+
<div
|
|
302
|
+
role="dialog"
|
|
303
|
+
aria-modal="true"
|
|
304
|
+
aria-label={label}
|
|
305
|
+
className="fixed inset-0 z-50 flex flex-col bg-background/95 backdrop-blur-sm"
|
|
306
|
+
>
|
|
307
|
+
<div
|
|
308
|
+
className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2"
|
|
309
|
+
style={{ paddingTop: "calc(0.5rem + env(safe-area-inset-top))" }}
|
|
310
|
+
>
|
|
311
|
+
<span className="px-1 font-[family-name:var(--font-mono)] text-xs tabular-nums text-muted-foreground">
|
|
312
|
+
{Math.round(scale * 100)}%
|
|
313
|
+
</span>
|
|
314
|
+
<div className="flex items-center gap-1">
|
|
315
|
+
<button type="button" title="Zoom out" onClick={() => zoomCenter(0.8)} className={btn}>
|
|
316
|
+
<Minus className="h-4 w-4" />
|
|
317
|
+
</button>
|
|
318
|
+
<button type="button" title="Zoom in" onClick={() => zoomCenter(1.25)} className={btn}>
|
|
319
|
+
<Plus className="h-4 w-4" />
|
|
320
|
+
</button>
|
|
321
|
+
<button type="button" title="Fit to screen" onClick={fit} className={btn}>
|
|
322
|
+
<Maximize2 className="h-4 w-4" />
|
|
323
|
+
</button>
|
|
324
|
+
{actions}
|
|
325
|
+
<button type="button" title="Close" onClick={onClose} className={btn}>
|
|
326
|
+
<X className="h-4 w-4" />
|
|
327
|
+
</button>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
<div
|
|
332
|
+
ref={stageRef}
|
|
333
|
+
className="relative flex-1 cursor-grab touch-none overflow-hidden active:cursor-grabbing [&_img]:max-w-none [&_svg]:max-w-none"
|
|
334
|
+
onPointerDown={onPointerDown}
|
|
335
|
+
onPointerMove={onPointerMove}
|
|
336
|
+
onPointerUp={endPointer}
|
|
337
|
+
onPointerCancel={endPointer}
|
|
338
|
+
onDoubleClick={onDoubleClick}
|
|
339
|
+
>
|
|
340
|
+
<div
|
|
341
|
+
ref={contentRef}
|
|
342
|
+
className="absolute left-0 top-0 origin-top-left"
|
|
343
|
+
style={{
|
|
344
|
+
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
|
345
|
+
visibility: ready ? "visible" : "hidden",
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
{children}
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
</div>,
|
|
352
|
+
document.body
|
|
353
|
+
);
|
|
354
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { useCallback, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
SHARE_STASH_TTL_MS,
|
|
4
|
+
type ShareIntakeResult,
|
|
5
|
+
} from "@schlessera/brain-ui-sdk/protocol";
|
|
6
|
+
import {
|
|
7
|
+
getShareStore,
|
|
8
|
+
pruneStoredShares,
|
|
9
|
+
readShareLaunchParams,
|
|
10
|
+
type StoredShare,
|
|
11
|
+
} from "@schlessera/brain-ui-sdk/share-target";
|
|
12
|
+
import { useChatStore } from "../stores/chat-store.js";
|
|
13
|
+
import { useConnectionStore } from "../stores/connection-store.js";
|
|
14
|
+
import { useShareStore } from "../stores/share-store.js";
|
|
15
|
+
import { detectClientEnvironment } from "../lib/client-environment.js";
|
|
16
|
+
import {
|
|
17
|
+
buildSharePrompt,
|
|
18
|
+
describeShareError,
|
|
19
|
+
describeUploadError,
|
|
20
|
+
dropShareClaim,
|
|
21
|
+
persistShareClaim,
|
|
22
|
+
readShareClaims,
|
|
23
|
+
shareImagesToAttachments,
|
|
24
|
+
uploadShare,
|
|
25
|
+
} from "../lib/share-intake.js";
|
|
26
|
+
import { sendClientMessage } from "./use-websocket.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Pick up shares the service worker stashed, and file them once confirmed.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately thin: every decision lives in `lib/share-intake.ts`, which is
|
|
32
|
+
* testable without a DOM. What is left here is the browser-only part — reading
|
|
33
|
+
* the launch URL, rewriting it, and driving the stores.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Move `?share=<id>` out of the URL and into a durable claim.
|
|
38
|
+
*
|
|
39
|
+
* The parameter cannot stay: the shell reloads the page when a new service
|
|
40
|
+
* worker takes over, a reload preserves the query string, and a second pass
|
|
41
|
+
* over the same id while the first upload is in flight would file the share
|
|
42
|
+
* twice. (The claim is belt; `store.take()` is braces — it hands the record to
|
|
43
|
+
* exactly one caller.)
|
|
44
|
+
*/
|
|
45
|
+
function claimFromLocation(): { claimed: string[]; error?: string } {
|
|
46
|
+
if (typeof window === "undefined") return { claimed: [] };
|
|
47
|
+
const { shareId, error } = readShareLaunchParams(window.location.href);
|
|
48
|
+
if (shareId) {
|
|
49
|
+
persistShareClaim(shareId);
|
|
50
|
+
const url = new URL(window.location.href);
|
|
51
|
+
url.searchParams.delete("share");
|
|
52
|
+
url.searchParams.delete("share_error");
|
|
53
|
+
history.replaceState(null, "", url.toString());
|
|
54
|
+
} else if (error) {
|
|
55
|
+
const url = new URL(window.location.href);
|
|
56
|
+
url.searchParams.delete("share_error");
|
|
57
|
+
history.replaceState(null, "", url.toString());
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
claimed: readShareClaims(),
|
|
61
|
+
...(error ? { error: describeShareError(error) } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useShareIntake(): {
|
|
66
|
+
confirm: (record: StoredShare) => Promise<void>;
|
|
67
|
+
dismiss: (record: StoredShare) => void;
|
|
68
|
+
} {
|
|
69
|
+
const enqueue = useShareStore((s) => s.enqueue);
|
|
70
|
+
const remove = useShareStore((s) => s.remove);
|
|
71
|
+
const setBusy = useShareStore((s) => s.setBusy);
|
|
72
|
+
const setError = useShareStore((s) => s.setError);
|
|
73
|
+
const setNotes = useShareStore((s) => s.setNotes);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
let disposed = false;
|
|
77
|
+
const { claimed, error } = claimFromLocation();
|
|
78
|
+
if (error) setError(error);
|
|
79
|
+
|
|
80
|
+
void (async () => {
|
|
81
|
+
const store = getShareStore();
|
|
82
|
+
try {
|
|
83
|
+
for (const id of claimed) {
|
|
84
|
+
const record = await store.take(id);
|
|
85
|
+
dropShareClaim(id);
|
|
86
|
+
if (record && !disposed) enqueue(record);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Orphans: a share whose landing page never ran (the app opened
|
|
90
|
+
// offline on the fallback page, or the tab was closed before the
|
|
91
|
+
// parameter was read) leaves a record nobody holds the id for. The
|
|
92
|
+
// stash is the only place it still exists.
|
|
93
|
+
for (const record of await store.list()) {
|
|
94
|
+
const taken = await store.take(record.id);
|
|
95
|
+
if (taken && !disposed) enqueue(taken);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The service worker's own prune runs after a stash, so it can never
|
|
99
|
+
// reach the newest record. This pass can.
|
|
100
|
+
await pruneStoredShares(store, SHARE_STASH_TTL_MS);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (!disposed) {
|
|
103
|
+
setError(err instanceof Error ? err.message : "Could not read stashed shares");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
disposed = true;
|
|
110
|
+
};
|
|
111
|
+
}, [enqueue, setError]);
|
|
112
|
+
|
|
113
|
+
const confirm = useCallback(
|
|
114
|
+
async (record: StoredShare) => {
|
|
115
|
+
const share = useShareStore.getState();
|
|
116
|
+
if (share.busy) return;
|
|
117
|
+
if (useConnectionStore.getState().wsStatus !== "connected") {
|
|
118
|
+
setError("Not connected yet — this will work as soon as the app reconnects.");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
setBusy(true);
|
|
123
|
+
setError(null);
|
|
124
|
+
setNotes([]);
|
|
125
|
+
try {
|
|
126
|
+
const outcome = await uploadShare(record);
|
|
127
|
+
if (!outcome.ok) {
|
|
128
|
+
setError(describeUploadError(outcome));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { attachments, errors } = await shareImagesToAttachments(record.files);
|
|
133
|
+
if (errors.length) setNotes(errors);
|
|
134
|
+
|
|
135
|
+
const prompt = buildSharePrompt(outcome.result as ShareIntakeResult);
|
|
136
|
+
const chat = useChatStore.getState();
|
|
137
|
+
// Always start from a fresh draft: `activeSessionId` is restored from
|
|
138
|
+
// localStorage at store creation, so on a cold boot it already points
|
|
139
|
+
// at whatever session was last open.
|
|
140
|
+
chat.clearMessages();
|
|
141
|
+
// Ownership of the preview URLs transfers to the message here — the
|
|
142
|
+
// chat store revokes them. Nothing below may revoke them again.
|
|
143
|
+
chat.addUserMessage(
|
|
144
|
+
null,
|
|
145
|
+
prompt,
|
|
146
|
+
"typed",
|
|
147
|
+
attachments.length
|
|
148
|
+
? attachments.map((a) => ({
|
|
149
|
+
previewUrl: a.previewUrl,
|
|
150
|
+
mediaType: a.attachment.mediaType,
|
|
151
|
+
}))
|
|
152
|
+
: undefined
|
|
153
|
+
);
|
|
154
|
+
chat.startAssistantMessage(null);
|
|
155
|
+
|
|
156
|
+
const sent = sendClientMessage({
|
|
157
|
+
type: "chat_message",
|
|
158
|
+
text: prompt,
|
|
159
|
+
...(attachments.length
|
|
160
|
+
? { attachments: attachments.map((a) => a.attachment) }
|
|
161
|
+
: {}),
|
|
162
|
+
...(detectClientEnvironment() ? { client: detectClientEnvironment() } : {}),
|
|
163
|
+
});
|
|
164
|
+
if (!sent) {
|
|
165
|
+
setError("The connection dropped before that could be sent.");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
remove(record.id);
|
|
170
|
+
dropShareClaim(record.id);
|
|
171
|
+
} finally {
|
|
172
|
+
setBusy(false);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
[remove, setBusy, setError, setNotes]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const dismiss = useCallback(
|
|
179
|
+
(record: StoredShare) => {
|
|
180
|
+
remove(record.id);
|
|
181
|
+
dropShareClaim(record.id);
|
|
182
|
+
setError(null);
|
|
183
|
+
// Already taken out of the stash when it was claimed; dropping the queue
|
|
184
|
+
// entry is what discards it.
|
|
185
|
+
},
|
|
186
|
+
[remove, setError]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
return { confirm, dismiss };
|
|
190
|
+
}
|
|
@@ -106,7 +106,11 @@ export function handleServerMessage(msg: ServerMessage) {
|
|
|
106
106
|
// session's pre-binding frames) apply to the buffer in view.
|
|
107
107
|
const frameSessionId = (msg as { sessionId?: string }).sessionId;
|
|
108
108
|
if (frameSessionId) {
|
|
109
|
-
|
|
109
|
+
// `detail` on a queued status is the host's queue-pressure note; it rides
|
|
110
|
+
// along so the session's badge can show WHY it is queued deep.
|
|
111
|
+
const queueNote =
|
|
112
|
+
msg.type === "status" && msg.status === "queued" ? msg.detail : undefined;
|
|
113
|
+
state.setRunState(frameSessionId, runStateForFrame(msg), queueNote);
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
// Resolve the target buffer key for this frame.
|
|
@@ -380,6 +384,25 @@ function handleStatusChange(status: "connecting" | "connected" | "disconnected")
|
|
|
380
384
|
// Singleton client - survives React re-renders
|
|
381
385
|
let wsClient: WSClient | null = null;
|
|
382
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Send on the live socket from outside a component.
|
|
389
|
+
*
|
|
390
|
+
* `useWebSocket()` OWNS the socket — its construction guard is a per-instance
|
|
391
|
+
* ref, so a second caller would build a second client, overwrite this
|
|
392
|
+
* singleton, and orphan the first; unmounting either one would then close the
|
|
393
|
+
* socket for both. Anything that needs to send but not to own (the share
|
|
394
|
+
* intake) goes through here instead of calling the hook again.
|
|
395
|
+
*
|
|
396
|
+
* Returns false when there is no open socket, since `WSClient.send` drops
|
|
397
|
+
* silently in that case and a caller that just staged an upload needs to know.
|
|
398
|
+
*/
|
|
399
|
+
export function sendClientMessage(msg: ClientMessage): boolean {
|
|
400
|
+
if (!wsClient) return false;
|
|
401
|
+
if (useConnectionStore.getState().wsStatus !== "connected") return false;
|
|
402
|
+
wsClient.send(msg);
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
383
406
|
export function useWebSocket() {
|
|
384
407
|
const initialized = useRef(false);
|
|
385
408
|
|
package/src/index.ts
CHANGED
|
@@ -46,9 +46,29 @@ export { useConnectionStore } from "./stores/connection-store.js";
|
|
|
46
46
|
export { useProviderStore } from "./stores/provider-store.js";
|
|
47
47
|
export { useVoiceStore } from "./voice/voice-store.js";
|
|
48
48
|
|
|
49
|
+
// A stand-in for the system share sheet, so the share-target path can be
|
|
50
|
+
// exercised without reinstalling the PWA. Meant for a dev route — it lists and
|
|
51
|
+
// deletes stashed shares — so the shell should gate it behind a DEV check
|
|
52
|
+
// rather than linking it from the app.
|
|
53
|
+
export { ShareHarness } from "./components/dev/share-harness.js";
|
|
54
|
+
|
|
49
55
|
// Connectivity probes.
|
|
50
56
|
export { useVpnStatus } from "./hooks/use-vpn-status.js";
|
|
51
|
-
export {
|
|
57
|
+
export {
|
|
58
|
+
useWebSocket,
|
|
59
|
+
sendClientMessage,
|
|
60
|
+
handleServerMessage,
|
|
61
|
+
runStateForFrame,
|
|
62
|
+
} from "./hooks/use-websocket.js";
|
|
63
|
+
|
|
64
|
+
// Share intake. The shell needs `hasPendingShare` for its service-worker
|
|
65
|
+
// reload guard: reloading mid-intake would file a share twice or lose it.
|
|
66
|
+
export {
|
|
67
|
+
useShareStore,
|
|
68
|
+
hasPendingShare,
|
|
69
|
+
type ShareIntakeState,
|
|
70
|
+
} from "./stores/share-store.js";
|
|
71
|
+
export { ShareIntake } from "./components/chat/share-card.js";
|
|
52
72
|
|
|
53
73
|
// API surface (typed REST client + backend URL helpers).
|
|
54
74
|
export { api } from "./lib/api-client.js";
|