@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.
- package/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- 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
|
+
}
|