@schlessera/brain-ui-react 0.10.0 → 0.12.0

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.
@@ -0,0 +1,253 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Eraser, RotateCcw, X } from "lucide-react";
3
+
4
+ import { API_BASE } from "../../lib/backend.js";
5
+ import { useMaskStore } from "../../stores/mask-store.js";
6
+
7
+ /**
8
+ * Paint-over-the-image mask editor.
9
+ *
10
+ * The agent cannot know which part of a picture someone means, so it asks. The
11
+ * user paints; this produces a PNG the same size as the source image in which
12
+ * **painted pixels are fully transparent** and everything else is opaque —
13
+ * the convention OpenAI's edit endpoint reads, so the bytes go through to the
14
+ * API unmodified.
15
+ *
16
+ * Painting happens on a display-sized canvas and is scaled to the image's true
17
+ * pixel dimensions on export, so a mask drawn on a phone still lines up with a
18
+ * 4K source.
19
+ */
20
+
21
+ interface Stroke {
22
+ points: { x: number; y: number }[];
23
+ /** Brush width in display pixels. */
24
+ width: number;
25
+ }
26
+
27
+ export function MaskEditor({
28
+ onSubmit,
29
+ onCancel,
30
+ }: {
31
+ onSubmit: (requestId: string, maskPngBase64: string) => void;
32
+ onCancel: (requestId: string, message: string) => void;
33
+ }) {
34
+ const request = useMaskStore((s) => s.request);
35
+ const close = useMaskStore((s) => s.close);
36
+
37
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
38
+ const imageRef = useRef<HTMLImageElement | null>(null);
39
+ const [strokes, setStrokes] = useState<Stroke[]>([]);
40
+ const [drawing, setDrawing] = useState(false);
41
+ const [brush, setBrush] = useState(48);
42
+ const [loaded, setLoaded] = useState(false);
43
+ const [error, setError] = useState<string | null>(null);
44
+
45
+ const rawUrl = request
46
+ ? `${API_BASE}/files/content?path=${encodeURIComponent(request.imagePath)}&raw=1`
47
+ : null;
48
+
49
+ // Reset per request: a second mask on a different image must not inherit the
50
+ // first one's strokes.
51
+ useEffect(() => {
52
+ setStrokes([]);
53
+ setLoaded(false);
54
+ setError(null);
55
+ }, [request?.requestId]);
56
+
57
+ const redraw = useCallback(() => {
58
+ const canvas = canvasRef.current;
59
+ const img = imageRef.current;
60
+ if (!canvas || !img) return;
61
+ const ctx = canvas.getContext("2d");
62
+ if (!ctx) return;
63
+
64
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
65
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
66
+
67
+ // The painted region is shown as a translucent wash so the user can still
68
+ // see what is underneath it while deciding.
69
+ ctx.save();
70
+ ctx.strokeStyle = "rgba(239, 68, 68, 0.55)";
71
+ ctx.lineCap = "round";
72
+ ctx.lineJoin = "round";
73
+ for (const stroke of strokes) {
74
+ ctx.lineWidth = stroke.width;
75
+ ctx.beginPath();
76
+ stroke.points.forEach((p, i) => (i === 0 ? ctx.moveTo(p.x, p.y) : ctx.lineTo(p.x, p.y)));
77
+ // A single tap is a dot, not a zero-length line.
78
+ if (stroke.points.length === 1) ctx.lineTo(stroke.points[0].x + 0.01, stroke.points[0].y);
79
+ ctx.stroke();
80
+ }
81
+ ctx.restore();
82
+ }, [strokes]);
83
+
84
+ useEffect(redraw, [redraw, loaded]);
85
+
86
+ const pointFrom = (e: React.PointerEvent<HTMLCanvasElement>) => {
87
+ const canvas = canvasRef.current!;
88
+ const rect = canvas.getBoundingClientRect();
89
+ return {
90
+ x: ((e.clientX - rect.left) / rect.width) * canvas.width,
91
+ y: ((e.clientY - rect.top) / rect.height) * canvas.height,
92
+ };
93
+ };
94
+
95
+ if (!request) return null;
96
+
97
+ const submit = () => {
98
+ const canvas = canvasRef.current;
99
+ const img = imageRef.current;
100
+ if (!canvas || !img) return;
101
+ if (strokes.length === 0) {
102
+ setError("Paint over the area you want changed first.");
103
+ return;
104
+ }
105
+
106
+ // Export at the source image's real dimensions, not the display size.
107
+ const out = document.createElement("canvas");
108
+ out.width = img.naturalWidth;
109
+ out.height = img.naturalHeight;
110
+ const ctx = out.getContext("2d");
111
+ if (!ctx) {
112
+ setError("This browser could not produce the mask.");
113
+ return;
114
+ }
115
+ const scale = out.width / canvas.width;
116
+
117
+ ctx.fillStyle = "#ffffff";
118
+ ctx.fillRect(0, 0, out.width, out.height);
119
+ // Painted area becomes a hole: alpha 0 is what marks it editable.
120
+ ctx.globalCompositeOperation = "destination-out";
121
+ ctx.strokeStyle = "rgba(0,0,0,1)";
122
+ ctx.lineCap = "round";
123
+ ctx.lineJoin = "round";
124
+ for (const stroke of strokes) {
125
+ ctx.lineWidth = stroke.width * scale;
126
+ ctx.beginPath();
127
+ stroke.points.forEach((p, i) =>
128
+ i === 0 ? ctx.moveTo(p.x * scale, p.y * scale) : ctx.lineTo(p.x * scale, p.y * scale)
129
+ );
130
+ if (stroke.points.length === 1) {
131
+ ctx.lineTo(stroke.points[0].x * scale + 0.01, stroke.points[0].y * scale);
132
+ }
133
+ ctx.stroke();
134
+ }
135
+
136
+ const dataUrl = out.toDataURL("image/png");
137
+ onSubmit(request.requestId, dataUrl.slice(dataUrl.indexOf(",") + 1));
138
+ close();
139
+ };
140
+
141
+ const cancel = () => {
142
+ onCancel(request.requestId, "The user closed the mask editor");
143
+ close();
144
+ };
145
+
146
+ return (
147
+ <div className="fixed inset-0 z-50 flex flex-col bg-black/80 backdrop-blur-sm">
148
+ <div className="flex items-center justify-between gap-3 p-3 text-sm text-white">
149
+ <div className="min-w-0">
150
+ <div className="font-medium">Mark the area to change</div>
151
+ <div className="truncate text-white/70">
152
+ {request.instruction ?? request.imagePath}
153
+ </div>
154
+ </div>
155
+ <button
156
+ type="button"
157
+ onClick={cancel}
158
+ aria-label="Cancel"
159
+ className="rounded p-2 hover:bg-white/10"
160
+ >
161
+ <X className="h-5 w-5" />
162
+ </button>
163
+ </div>
164
+
165
+ <div className="flex min-h-0 flex-1 items-center justify-center p-3">
166
+ {rawUrl && (
167
+ <img
168
+ ref={imageRef}
169
+ src={rawUrl}
170
+ alt=""
171
+ className="hidden"
172
+ onLoad={(e) => {
173
+ const img = e.currentTarget;
174
+ const canvas = canvasRef.current;
175
+ if (canvas) {
176
+ // Cap the working canvas so a 4K source stays responsive to draw on.
177
+ const maxEdge = 1400;
178
+ const scale = Math.min(1, maxEdge / Math.max(img.naturalWidth, img.naturalHeight));
179
+ canvas.width = Math.round(img.naturalWidth * scale);
180
+ canvas.height = Math.round(img.naturalHeight * scale);
181
+ }
182
+ setLoaded(true);
183
+ }}
184
+ onError={() => setError(`Could not load ${request.imagePath}`)}
185
+ />
186
+ )}
187
+ <canvas
188
+ ref={canvasRef}
189
+ className="max-h-full max-w-full touch-none rounded shadow-lg"
190
+ onPointerDown={(e) => {
191
+ e.currentTarget.setPointerCapture(e.pointerId);
192
+ setDrawing(true);
193
+ setStrokes((prev) => [...prev, { points: [pointFrom(e)], width: brush }]);
194
+ }}
195
+ onPointerMove={(e) => {
196
+ if (!drawing) return;
197
+ const p = pointFrom(e);
198
+ setStrokes((prev) => {
199
+ const next = [...prev];
200
+ const last = next[next.length - 1];
201
+ if (last) next[next.length - 1] = { ...last, points: [...last.points, p] };
202
+ return next;
203
+ });
204
+ }}
205
+ onPointerUp={() => setDrawing(false)}
206
+ onPointerCancel={() => setDrawing(false)}
207
+ />
208
+ </div>
209
+
210
+ {error && <div className="px-3 pb-1 text-center text-sm text-red-300">{error}</div>}
211
+
212
+ <div className="flex items-center gap-3 p-3">
213
+ <label className="flex flex-1 items-center gap-2 text-xs text-white/80">
214
+ Brush
215
+ <input
216
+ type="range"
217
+ min={8}
218
+ max={160}
219
+ value={brush}
220
+ onChange={(e) => setBrush(Number(e.target.value))}
221
+ className="flex-1"
222
+ />
223
+ </label>
224
+ <button
225
+ type="button"
226
+ onClick={() => setStrokes((prev) => prev.slice(0, -1))}
227
+ disabled={strokes.length === 0}
228
+ className="rounded p-2 text-white hover:bg-white/10 disabled:opacity-40"
229
+ aria-label="Undo"
230
+ >
231
+ <RotateCcw className="h-5 w-5" />
232
+ </button>
233
+ <button
234
+ type="button"
235
+ onClick={() => setStrokes([])}
236
+ disabled={strokes.length === 0}
237
+ className="rounded p-2 text-white hover:bg-white/10 disabled:opacity-40"
238
+ aria-label="Clear"
239
+ >
240
+ <Eraser className="h-5 w-5" />
241
+ </button>
242
+ <button
243
+ type="button"
244
+ onClick={submit}
245
+ disabled={!loaded}
246
+ className="rounded bg-white px-4 py-2 text-sm font-medium text-black disabled:opacity-50"
247
+ >
248
+ Use this area
249
+ </button>
250
+ </div>
251
+ </div>
252
+ );
253
+ }
@@ -3,6 +3,7 @@ import { WSClient } from "../lib/ws-client.js";
3
3
  import { useConnectionStore } from "../stores/connection-store.js";
4
4
  import { useChatStore, activeChat, type ChatKey } from "../stores/chat-store.js";
5
5
  import { useProviderStore } from "../stores/provider-store.js";
6
+ import { useMaskStore } from "../stores/mask-store.js";
6
7
  import { getWsUrl } from "../lib/backend.js";
7
8
  import type {
8
9
  ServerMessage,
@@ -190,6 +191,17 @@ export function handleServerMessage(msg: ServerMessage) {
190
191
  requestBrowserLocation(msg);
191
192
  break;
192
193
 
194
+ case "mask_request":
195
+ // Opens the editor; the answer travels back from the component, because
196
+ // only the user can say which part of the picture they meant.
197
+ useMaskStore.getState().open({
198
+ requestId: msg.requestId,
199
+ imagePath: msg.imagePath,
200
+ ...(msg.instruction ? { instruction: msg.instruction } : {}),
201
+ ...(msg.turnId ? { turnId: msg.turnId } : {}),
202
+ });
203
+ break;
204
+
193
205
  case "result":
194
206
  state.finishAssistantMessage(key);
195
207
  resyncIfNeeded(msg.sessionId);
package/src/index.ts CHANGED
@@ -38,6 +38,8 @@ export {
38
38
  type MessageAttachment,
39
39
  } from "./stores/chat-store.js";
40
40
  export { useFileStore } from "./stores/file-store.js";
41
+ export { useMaskStore, type MaskRequest } from "./stores/mask-store.js";
42
+ export { MaskEditor } from "./components/images/mask-editor.js";
41
43
  export { useUIStore, type ActiveView } from "./stores/ui-store.js";
42
44
  export { useGraphStore, type GraphMode } from "./stores/graph-store.js";
43
45
  export { useConnectionStore } from "./stores/connection-store.js";
@@ -0,0 +1,28 @@
1
+ import { create } from "zustand";
2
+
3
+ /**
4
+ * The pending mask request, if any.
5
+ *
6
+ * Deliberately a small standalone store rather than chat-buffer state: a mask
7
+ * request is modal and at most one is open at a time, and nothing about it
8
+ * needs to survive in a transcript — the answer travels back over the socket
9
+ * and the resulting file is what persists.
10
+ */
11
+ export interface MaskRequest {
12
+ requestId: string;
13
+ imagePath: string;
14
+ instruction?: string;
15
+ turnId?: string;
16
+ }
17
+
18
+ interface MaskState {
19
+ request: MaskRequest | null;
20
+ open: (request: MaskRequest) => void;
21
+ close: () => void;
22
+ }
23
+
24
+ export const useMaskStore = create<MaskState>((set) => ({
25
+ request: null,
26
+ open: (request) => set({ request }),
27
+ close: () => set({ request: null }),
28
+ }));