@oar-sh/oar 0.9.1 → 0.9.3

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.
Files changed (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. package/server/services/fixtures/grok-stub.sh +0 -86
@@ -0,0 +1,617 @@
1
+ // Marker-annotation editor: the DOM/controller half of the annotation feature.
2
+ //
3
+ // Everything that can be reasoned about without a DOM lives in annotate-core.mjs
4
+ // (stroke model, undo stack, coordinate mapping, drawing, export); this module
5
+ // only owns the modal, the gestures and the two buttons that end a session.
6
+ //
7
+ // Gestures are Pointer Events only — one code path for mouse, pen and touch —
8
+ // with the plan's core rule that a single pointer ALWAYS draws and a second
9
+ // pointer switches to pinch (see onStagePointerDown). The file preview's
10
+ // `imgZoom` singleton is bound to that modal, so its math is ported here rather
11
+ // than shared; the editor keeps its own {scale, panX, panY, minScale}.
12
+
13
+ import {
14
+ ANNOTATE_COLORS,
15
+ ANNOTATE_WIDTH_IDS,
16
+ MARKER_ALPHA,
17
+ strokeWidthFor,
18
+ createStrokeStack,
19
+ pushStroke,
20
+ pushReset,
21
+ undo,
22
+ canUndo,
23
+ visibleStrokes,
24
+ clientPointToImagePoint,
25
+ renderStrokes,
26
+ composeAnnotatedImage,
27
+ } from './annotate-core.mjs';
28
+ import { browserDownscaleDeps } from './image-downscale.mjs';
29
+ import { showTransientRelayNotice } from './store.js';
30
+
31
+ const MAX_SCALE = 8;
32
+ // The fitted size is baked into the surface's CSS size (see layoutSurface), so
33
+ // the transform's own minimum — "fit" — is exactly 1.
34
+ const FIT_SCALE = 1;
35
+ const SCALE_EPS = 0.001;
36
+ const WHEEL_FACTOR = 1.15;
37
+ const DOUBLE_TAP_ZOOM = 3;
38
+ const DOUBLE_TAP_MS = 300;
39
+ const DOUBLE_TAP_SLOP_PX = 30;
40
+ // Sub-pixel moves only bloat the stroke path without changing a pixel of output.
41
+ const MIN_POINT_DELTA_PX = 1.5;
42
+
43
+ const DEFAULT_COLOR_ID = ANNOTATE_COLORS[0]?.id || 'yellow';
44
+ const DEFAULT_WIDTH_ID = ANNOTATE_WIDTH_IDS[1] || 'medium';
45
+
46
+ let session = null; // the single open session, or null
47
+ let opening = false; // guards a second open() while the blob is loading
48
+ let listenersBound = false;
49
+ let activeColorId = DEFAULT_COLOR_ID;
50
+ let activeWidthId = DEFAULT_WIDTH_ID;
51
+
52
+ const view = { scale: FIT_SCALE, panX: 0, panY: 0, minScale: FIT_SCALE };
53
+
54
+ const pointers = new Map(); // pointerId -> { x, y }
55
+ let drawing = null; // { pointerId, stroke }
56
+ let panning = null; // { pointerId, x, y } — desktop middle/right drag
57
+ let pinch = null; // { dist0, scale0, panX0, panY0, cx, cy }
58
+ let suppressDrawUntilIdle = false;
59
+ let lastTap = { t: 0, x: 0, y: 0, wasDot: false };
60
+ let redrawHandle = 0;
61
+
62
+ function el(id) {
63
+ return document.getElementById(id);
64
+ }
65
+
66
+ function colorHexFor(id) {
67
+ return ANNOTATE_COLORS.find((entry) => entry?.id === id)?.hex || ANNOTATE_COLORS[0]?.hex || '#ffdd00';
68
+ }
69
+
70
+ /* ---------------------------------------------------------------- layout */
71
+
72
+ /**
73
+ * Sizes the surface so the image fits the stage at scale 1 and the canvas
74
+ * overlays it exactly. The canvas backing store stays at natural resolution and
75
+ * is CSS-scaled, which is what makes strokes land on real pixels — no two-stage
76
+ * "crispen" like the file preview needs.
77
+ */
78
+ function layoutSurface() {
79
+ if (!session) return;
80
+ const stage = el('annotate-stage');
81
+ const surface = el('annotate-surface');
82
+ const img = el('annotate-image');
83
+ const canvas = el('annotate-canvas');
84
+ if (!stage || !surface || !img || !canvas) return;
85
+ const natW = session.width;
86
+ const natH = session.height;
87
+ const stageW = stage.clientWidth;
88
+ const stageH = stage.clientHeight;
89
+ if (!natW || !natH || !stageW || !stageH) return;
90
+
91
+ const fit = Math.min(1, stageW / natW, stageH / natH);
92
+ const baseW = Math.max(1, Math.round(natW * fit));
93
+ const baseH = Math.max(1, Math.round(natH * fit));
94
+ session.baseW = baseW;
95
+ session.baseH = baseH;
96
+ surface.style.width = `${baseW}px`;
97
+ surface.style.height = `${baseH}px`;
98
+ img.style.width = `${baseW}px`;
99
+ img.style.height = `${baseH}px`;
100
+ canvas.style.width = `${baseW}px`;
101
+ canvas.style.height = `${baseH}px`;
102
+ if (canvas.width !== natW || canvas.height !== natH) {
103
+ canvas.width = natW;
104
+ canvas.height = natH;
105
+ }
106
+ clampView();
107
+ applyView();
108
+ scheduleRedraw();
109
+ }
110
+
111
+ function applyView() {
112
+ const surface = el('annotate-surface');
113
+ if (!surface) return;
114
+ surface.style.transform = `translate(${view.panX}px, ${view.panY}px) scale(${view.scale})`;
115
+ }
116
+
117
+ function clampView() {
118
+ view.scale = Math.max(view.minScale, Math.min(MAX_SCALE, view.scale));
119
+ const stage = el('annotate-stage');
120
+ if (stage && session?.baseW && session?.baseH) {
121
+ const maxPanX = Math.max(0, (session.baseW * view.scale - stage.clientWidth) / 2);
122
+ const maxPanY = Math.max(0, (session.baseH * view.scale - stage.clientHeight) / 2);
123
+ view.panX = Math.max(-maxPanX, Math.min(maxPanX, view.panX));
124
+ view.panY = Math.max(-maxPanY, Math.min(maxPanY, view.panY));
125
+ }
126
+ if (view.scale <= view.minScale + SCALE_EPS) {
127
+ view.scale = view.minScale;
128
+ view.panX = 0;
129
+ view.panY = 0;
130
+ }
131
+ }
132
+
133
+ function pointFromStageCenter(clientX, clientY) {
134
+ const rect = el('annotate-stage')?.getBoundingClientRect();
135
+ if (!rect) return { cx: 0, cy: 0 };
136
+ return { cx: clientX - rect.left - rect.width / 2, cy: clientY - rect.top - rect.height / 2 };
137
+ }
138
+
139
+ function zoomAtPoint(factor, cx, cy) {
140
+ const previous = view.scale;
141
+ view.scale = previous * factor;
142
+ if (view.scale !== previous) {
143
+ view.panX = cx - (cx - view.panX) * (view.scale / previous);
144
+ view.panY = cy - (cy - view.panY) * (view.scale / previous);
145
+ }
146
+ clampView();
147
+ applyView();
148
+ }
149
+
150
+ function toggleZoomAt(clientX, clientY) {
151
+ const { cx, cy } = pointFromStageCenter(clientX, clientY);
152
+ if (view.scale > view.minScale + SCALE_EPS || view.panX !== 0 || view.panY !== 0) {
153
+ view.scale = view.minScale;
154
+ view.panX = 0;
155
+ view.panY = 0;
156
+ applyView();
157
+ return;
158
+ }
159
+ zoomAtPoint(DOUBLE_TAP_ZOOM, cx, cy);
160
+ }
161
+
162
+ /* --------------------------------------------------------------- drawing */
163
+
164
+ function scheduleRedraw() {
165
+ if (!session || redrawHandle) return;
166
+ redrawHandle = requestAnimationFrame(() => {
167
+ redrawHandle = 0;
168
+ redrawNow();
169
+ });
170
+ }
171
+
172
+ function redrawNow() {
173
+ if (!session) return;
174
+ const canvas = el('annotate-canvas');
175
+ const ctx = canvas?.getContext?.('2d');
176
+ if (!ctx) return;
177
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
178
+ const strokes = visibleStrokes(session.stack);
179
+ renderStrokes(ctx, drawing?.stroke ? strokes.concat([drawing.stroke]) : strokes, { alpha: MARKER_ALPHA });
180
+ }
181
+
182
+ function toImagePoint(clientX, clientY) {
183
+ if (!session) return null;
184
+ const canvas = el('annotate-canvas');
185
+ if (!canvas) return null;
186
+ // The canvas rect already reflects the live pan/zoom transform, so the
187
+ // mapping needs no separate scale bookkeeping.
188
+ const rect = canvas.getBoundingClientRect();
189
+ if (!rect.width || !rect.height) return null;
190
+ return clientPointToImagePoint(clientX, clientY, rect, session.width, session.height);
191
+ }
192
+
193
+ function beginStroke(event) {
194
+ const point = toImagePoint(event.clientX, event.clientY);
195
+ if (!point) return;
196
+ drawing = {
197
+ pointerId: event.pointerId,
198
+ stroke: {
199
+ color: colorHexFor(activeColorId),
200
+ widthPx: strokeWidthFor(activeWidthId, Math.max(session.width, session.height)),
201
+ points: [point],
202
+ },
203
+ };
204
+ scheduleRedraw();
205
+ }
206
+
207
+ function cancelActiveStroke() {
208
+ if (!drawing) return;
209
+ drawing = null;
210
+ scheduleRedraw();
211
+ }
212
+
213
+ /* -------------------------------------------------------------- gestures */
214
+
215
+ function beginPinch() {
216
+ const stage = el('annotate-stage');
217
+ const [a, b] = [...pointers.values()];
218
+ if (!stage || !a || !b) return;
219
+ const rect = stage.getBoundingClientRect();
220
+ pinch = {
221
+ dist0: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)),
222
+ scale0: view.scale,
223
+ panX0: view.panX,
224
+ panY0: view.panY,
225
+ cx: (a.x + b.x) / 2 - rect.left - rect.width / 2,
226
+ cy: (a.y + b.y) / 2 - rect.top - rect.height / 2,
227
+ };
228
+ }
229
+
230
+ function updatePinch() {
231
+ if (!pinch) return;
232
+ const [a, b] = [...pointers.values()];
233
+ if (!a || !b) return;
234
+ const dist = Math.hypot(a.x - b.x, a.y - b.y);
235
+ const next = Math.max(view.minScale, Math.min(MAX_SCALE, pinch.scale0 * (dist / pinch.dist0)));
236
+ view.scale = next;
237
+ view.panX = pinch.cx - (pinch.cx - pinch.panX0) * (next / pinch.scale0);
238
+ view.panY = pinch.cy - (pinch.cy - pinch.panY0) * (next / pinch.scale0);
239
+ clampView();
240
+ applyView();
241
+ }
242
+
243
+ function onStagePointerDown(event) {
244
+ if (!session) return;
245
+ event.preventDefault();
246
+ // Frozen while exporting: a stroke committed mid-export would render on the
247
+ // overlay but be missing from the file being saved.
248
+ if (session.exporting) return;
249
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
250
+ try { el('annotate-stage')?.setPointerCapture?.(event.pointerId); } catch {}
251
+
252
+ if (pointers.size >= 2) {
253
+ // A second pointer landing mid-stroke always rescues an accidental touch:
254
+ // the in-progress stroke is dropped (never committed) and the gesture
255
+ // becomes a pinch. This is what makes "one pointer always draws" safe.
256
+ cancelActiveStroke();
257
+ panning = null;
258
+ beginPinch();
259
+ return;
260
+ }
261
+
262
+ if (event.pointerType === 'mouse' && event.button !== 0) {
263
+ panning = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
264
+ return;
265
+ }
266
+
267
+ if (suppressDrawUntilIdle) return;
268
+
269
+ const now = Date.now();
270
+ const isDoubleTap = now - lastTap.t < DOUBLE_TAP_MS
271
+ && Math.hypot(event.clientX - lastTap.x, event.clientY - lastTap.y) <= DOUBLE_TAP_SLOP_PX;
272
+ if (isDoubleTap) {
273
+ // The first tap of a double tap already committed a one-point dot, so it is
274
+ // undone here — zooming must not leave a stray mark behind. Only when the
275
+ // stack top really is that dot: a toolbar action can land in between.
276
+ const top = session.stack.entries[session.stack.entries.length - 1];
277
+ if (lastTap.wasDot && top?.kind === 'stroke' && top.stroke?.points?.length === 1) {
278
+ undo(session.stack);
279
+ scheduleRedraw();
280
+ syncToolbarState();
281
+ }
282
+ lastTap = { t: 0, x: 0, y: 0, wasDot: false };
283
+ toggleZoomAt(event.clientX, event.clientY);
284
+ return;
285
+ }
286
+
287
+ lastTap = { t: now, x: event.clientX, y: event.clientY, wasDot: false };
288
+ beginStroke(event);
289
+ }
290
+
291
+ function onStagePointerMove(event) {
292
+ if (!session || !pointers.has(event.pointerId)) return;
293
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
294
+
295
+ if (pinch && pointers.size >= 2) {
296
+ event.preventDefault();
297
+ updatePinch();
298
+ return;
299
+ }
300
+ if (panning && panning.pointerId === event.pointerId) {
301
+ event.preventDefault();
302
+ view.panX += event.clientX - panning.x;
303
+ view.panY += event.clientY - panning.y;
304
+ panning.x = event.clientX;
305
+ panning.y = event.clientY;
306
+ clampView();
307
+ applyView();
308
+ return;
309
+ }
310
+ if (drawing && drawing.pointerId === event.pointerId) {
311
+ event.preventDefault();
312
+ const point = toImagePoint(event.clientX, event.clientY);
313
+ if (!point) return;
314
+ const points = drawing.stroke.points;
315
+ const last = points[points.length - 1];
316
+ if (last && Math.hypot(point.x - last.x, point.y - last.y) < MIN_POINT_DELTA_PX) return;
317
+ points.push(point);
318
+ // A drag is not a tap: without this, two quick strokes in the same spot
319
+ // read as a double tap and the second becomes a surprise zoom.
320
+ lastTap.t = 0;
321
+ scheduleRedraw();
322
+ }
323
+ }
324
+
325
+ function onStagePointerUp(event) {
326
+ pointers.delete(event.pointerId);
327
+ try { el('annotate-stage')?.releasePointerCapture?.(event.pointerId); } catch {}
328
+ if (!session) return;
329
+
330
+ if (pinch && pointers.size < 2) {
331
+ pinch = null;
332
+ // A finger left over from a pinch must not start a stroke on its own.
333
+ suppressDrawUntilIdle = pointers.size > 0;
334
+ }
335
+ if (panning?.pointerId === event.pointerId) panning = null;
336
+
337
+ if (drawing?.pointerId === event.pointerId) {
338
+ const stroke = drawing.stroke;
339
+ drawing = null;
340
+ if (event.type === 'pointercancel') {
341
+ scheduleRedraw();
342
+ } else if (stroke.points.length >= 1) {
343
+ // A tap is a legitimate one-point dot.
344
+ pushStroke(session.stack, stroke);
345
+ lastTap.wasDot = stroke.points.length === 1;
346
+ scheduleRedraw();
347
+ syncToolbarState();
348
+ }
349
+ }
350
+ if (pointers.size === 0) suppressDrawUntilIdle = false;
351
+ }
352
+
353
+ function onStageWheel(event) {
354
+ if (!session) return;
355
+ event.preventDefault();
356
+ const { cx, cy } = pointFromStageCenter(event.clientX, event.clientY);
357
+ zoomAtPoint(event.deltaY < 0 ? WHEEL_FACTOR : 1 / WHEEL_FACTOR, cx, cy);
358
+ }
359
+
360
+ function onStageContextMenu(event) {
361
+ // Right-drag pans, so the menu would fire mid-gesture.
362
+ if (session) event.preventDefault();
363
+ }
364
+
365
+ function onKeyDown(event) {
366
+ if (event.key !== 'Escape' || !session) return;
367
+ event.preventDefault();
368
+ closeAnnotateEditor();
369
+ }
370
+
371
+ function onWindowResize() {
372
+ layoutSurface();
373
+ }
374
+
375
+ /* --------------------------------------------------------------- toolbar */
376
+
377
+ function syncToolSelection() {
378
+ for (const btn of document.querySelectorAll('.annotate-color')) {
379
+ btn.classList.toggle('active', btn.dataset.color === activeColorId);
380
+ }
381
+ for (const btn of document.querySelectorAll('.annotate-width')) {
382
+ btn.classList.toggle('active', btn.dataset.width === activeWidthId);
383
+ }
384
+ }
385
+
386
+ function syncToolbarState() {
387
+ const strokeCount = session ? visibleStrokes(session.stack).length : 0;
388
+ const undoBtn = el('annotate-undo-btn');
389
+ const resetBtn = el('annotate-reset-btn');
390
+ const acceptBtn = el('annotate-accept-btn');
391
+ const exporting = !!session?.exporting;
392
+ if (undoBtn) undoBtn.disabled = !session || !canUndo(session.stack) || exporting;
393
+ if (resetBtn) resetBtn.disabled = strokeCount === 0 || exporting;
394
+ if (acceptBtn) acceptBtn.disabled = strokeCount === 0 || !session || exporting;
395
+ }
396
+
397
+ function selectColor(id) {
398
+ if (!ANNOTATE_COLORS.some((entry) => entry?.id === id)) return;
399
+ activeColorId = id;
400
+ syncToolSelection();
401
+ }
402
+
403
+ function selectWidth(id) {
404
+ if (!ANNOTATE_WIDTH_IDS.includes(id)) return;
405
+ activeWidthId = id;
406
+ syncToolSelection();
407
+ }
408
+
409
+ function onUndoClick() {
410
+ if (!session) return;
411
+ undo(session.stack);
412
+ scheduleRedraw();
413
+ syncToolbarState();
414
+ }
415
+
416
+ function onResetClick() {
417
+ if (!session) return;
418
+ // Reset is itself undoable, so a fat-fingered clear is never fatal.
419
+ pushReset(session.stack);
420
+ scheduleRedraw();
421
+ syncToolbarState();
422
+ }
423
+
424
+ async function acceptAnnotations() {
425
+ if (!session || session.exporting) return;
426
+ const current = session;
427
+ const strokes = visibleStrokes(current.stack);
428
+ if (!strokes.length) return;
429
+ current.exporting = true;
430
+ syncToolbarState();
431
+ try {
432
+ const file = await composeAnnotatedImage(
433
+ { bitmap: current.bitmap, strokes, name: current.name },
434
+ browserDownscaleDeps(),
435
+ );
436
+ // The session can be closed (Escape) while the export awaited; the discard
437
+ // then already happened and this result is simply dropped.
438
+ if (session !== current) return;
439
+ if (!file) throw new Error('the image could not be exported');
440
+ const accepted = current.onAccept ? await current.onAccept(file) : false;
441
+ if (session !== current) return;
442
+ if (accepted) {
443
+ finishClose();
444
+ return;
445
+ }
446
+ // A falsy result means the caller rejected the file and has already shown
447
+ // its own notice (over-cap, upload refused, …), so nothing is added here.
448
+ } catch (error) {
449
+ if (session !== current) return;
450
+ showTransientRelayNotice(`Could not save annotations: ${String(error?.message || error || 'unknown error')}`);
451
+ }
452
+ if (session === current) {
453
+ current.exporting = false;
454
+ syncToolbarState();
455
+ }
456
+ }
457
+
458
+ function bindListeners() {
459
+ if (listenersBound) return true;
460
+ const stage = el('annotate-stage');
461
+ if (!stage) return false;
462
+ stage.addEventListener('pointerdown', onStagePointerDown, { passive: false });
463
+ stage.addEventListener('pointermove', onStagePointerMove, { passive: false });
464
+ stage.addEventListener('pointerup', onStagePointerUp);
465
+ stage.addEventListener('pointercancel', onStagePointerUp);
466
+ stage.addEventListener('wheel', onStageWheel, { passive: false });
467
+ stage.addEventListener('contextmenu', onStageContextMenu);
468
+ el('annotate-close-btn')?.addEventListener('click', () => closeAnnotateEditor());
469
+ el('annotate-undo-btn')?.addEventListener('click', onUndoClick);
470
+ el('annotate-reset-btn')?.addEventListener('click', onResetClick);
471
+ el('annotate-accept-btn')?.addEventListener('click', () => { void acceptAnnotations(); });
472
+ for (const btn of document.querySelectorAll('.annotate-color')) {
473
+ btn.addEventListener('click', () => selectColor(btn.dataset.color));
474
+ }
475
+ for (const btn of document.querySelectorAll('.annotate-width')) {
476
+ btn.addEventListener('click', () => selectWidth(btn.dataset.width));
477
+ }
478
+ listenersBound = true;
479
+ return true;
480
+ }
481
+
482
+ // `imageOrientation: 'from-image'` keeps rotated phone screenshots upright, but
483
+ // the option bag is rejected outright by older engines.
484
+ async function decodeUpright(blob) {
485
+ try {
486
+ return await createImageBitmap(blob, { imageOrientation: 'from-image' });
487
+ } catch {
488
+ return await createImageBitmap(blob);
489
+ }
490
+ }
491
+
492
+ function finishClose() {
493
+ document.removeEventListener('keydown', onKeyDown);
494
+ window.removeEventListener('resize', onWindowResize);
495
+ if (redrawHandle) {
496
+ cancelAnimationFrame(redrawHandle);
497
+ redrawHandle = 0;
498
+ }
499
+ const canvas = el('annotate-canvas');
500
+ const ctx = canvas?.getContext?.('2d');
501
+ if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
502
+ const img = el('annotate-image');
503
+ if (img) img.removeAttribute('src');
504
+ if (session) {
505
+ try { session.bitmap?.close?.(); } catch {}
506
+ if (session.objectUrl) URL.revokeObjectURL(session.objectUrl);
507
+ }
508
+ session = null;
509
+ pointers.clear();
510
+ drawing = null;
511
+ panning = null;
512
+ pinch = null;
513
+ suppressDrawUntilIdle = false;
514
+ lastTap = { t: 0, x: 0, y: 0, wasDot: false };
515
+ view.scale = FIT_SCALE;
516
+ view.panX = 0;
517
+ view.panY = 0;
518
+ const modal = el('image-annotate-modal');
519
+ modal?.classList.remove('visible');
520
+ modal?.setAttribute('aria-hidden', 'true');
521
+ syncToolbarState();
522
+ }
523
+
524
+ /**
525
+ * Opens the editor on one image. Exactly one session exists at a time — a call
526
+ * while another is open (or still loading) is ignored.
527
+ *
528
+ * `getBlob()` supplies the source bytes; `onAccept(file)` receives the exported
529
+ * PNG and decides the outcome: a truthy result closes the editor, a falsy one
530
+ * leaves it open (the caller has already explained why). Failures to load or
531
+ * decode surface as a transient notice and the editor never opens.
532
+ *
533
+ * Resolves to true when the editor is showing.
534
+ */
535
+ export async function openAnnotateEditor({ name, getBlob, onAccept } = {}) {
536
+ if (session || opening) return false;
537
+ if (typeof getBlob !== 'function') return false;
538
+ opening = true;
539
+
540
+ let bitmap = null;
541
+ let objectUrl = '';
542
+ try {
543
+ const blob = await getBlob();
544
+ if (!blob) throw new Error('the image could not be loaded');
545
+ bitmap = await decodeUpright(blob);
546
+ if (!bitmap?.width || !bitmap?.height) throw new Error('the image could not be decoded');
547
+ objectUrl = URL.createObjectURL(blob);
548
+ } catch (error) {
549
+ opening = false;
550
+ try { bitmap?.close?.(); } catch {}
551
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
552
+ showTransientRelayNotice(`Could not open the annotation editor: ${String(error?.message || error || 'unknown error')}`);
553
+ return false;
554
+ }
555
+
556
+ const modal = el('image-annotate-modal');
557
+ const img = el('annotate-image');
558
+ if (!modal || !img || !bindListeners()) {
559
+ opening = false;
560
+ try { bitmap.close?.(); } catch {}
561
+ URL.revokeObjectURL(objectUrl);
562
+ showTransientRelayNotice('Could not open the annotation editor.');
563
+ return false;
564
+ }
565
+
566
+ session = {
567
+ name: String(name || 'image.png'),
568
+ onAccept: typeof onAccept === 'function' ? onAccept : null,
569
+ bitmap,
570
+ objectUrl,
571
+ width: bitmap.width,
572
+ height: bitmap.height,
573
+ stack: createStrokeStack(),
574
+ baseW: 0,
575
+ baseH: 0,
576
+ exporting: false,
577
+ };
578
+ opening = false;
579
+
580
+ activeColorId = DEFAULT_COLOR_ID;
581
+ activeWidthId = DEFAULT_WIDTH_ID;
582
+ view.scale = FIT_SCALE;
583
+ view.panX = 0;
584
+ view.panY = 0;
585
+ view.minScale = FIT_SCALE;
586
+ pointers.clear();
587
+ drawing = null;
588
+ panning = null;
589
+ pinch = null;
590
+ suppressDrawUntilIdle = false;
591
+ lastTap = { t: 0, x: 0, y: 0, wasDot: false };
592
+
593
+ const title = el('annotate-title');
594
+ if (title) title.textContent = session.name;
595
+ img.src = objectUrl;
596
+ modal.classList.add('visible');
597
+ modal.setAttribute('aria-hidden', 'false');
598
+ document.addEventListener('keydown', onKeyDown);
599
+ window.addEventListener('resize', onWindowResize);
600
+ syncToolSelection();
601
+ syncToolbarState();
602
+ applyView();
603
+ // The stage has no measurable size until the modal is visible.
604
+ requestAnimationFrame(() => layoutSurface());
605
+ return true;
606
+ }
607
+
608
+ /** Discards the session; confirms first when there is something to lose. */
609
+ export function closeAnnotateEditor() {
610
+ if (!session) return;
611
+ if (visibleStrokes(session.stack).length && !window.confirm('Discard annotations?')) return;
612
+ finishClose();
613
+ }
614
+
615
+ export function isAnnotateEditorOpen() {
616
+ return !!session;
617
+ }