@merittdev/horus-lens 0.1.1 → 0.2.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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  LENS_OVERLAY_HOST_ID,
3
3
  LensClient
4
- } from "./chunk-UB7YYHI4.js";
4
+ } from "./chunk-U6GBOUCE.js";
5
5
 
6
6
  // ../browser/dist/index.js
7
7
  var DEFAULT_PARAM_NAME = "lens";
@@ -124,177 +124,933 @@ function el(tag, props = {}, children = []) {
124
124
  function clear(node) {
125
125
  while (node.firstChild) node.removeChild(node.firstChild);
126
126
  }
127
- var Annotator = class {
127
+ var THIN_MIN_DIST = 3;
128
+ var MAX_POINTS = 2e3;
129
+ var HIGHLIGHTER_WIDTH = 14;
130
+ var STEP_RADIUS = 12;
131
+ function distance(a, b) {
132
+ return Math.hypot(a.x - b.x, a.y - b.y);
133
+ }
134
+ function shouldKeepPoint(last, candidate, minDist = THIN_MIN_DIST) {
135
+ return distance(last, candidate) >= minDist;
136
+ }
137
+ function perpendicularDistance(p, a, b) {
138
+ const dx = b.x - a.x;
139
+ const dy = b.y - a.y;
140
+ const len2 = dx * dx + dy * dy;
141
+ if (len2 === 0) return distance(p, a);
142
+ let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2;
143
+ t = Math.max(0, Math.min(1, t));
144
+ return distance(p, { x: a.x + t * dx, y: a.y + t * dy });
145
+ }
146
+ function simplifyPoints(points, tolerance = 1) {
147
+ if (points.length <= 2 || tolerance <= 0) return points.map((p) => ({ ...p }));
148
+ const first = points[0];
149
+ const last = points[points.length - 1];
150
+ let maxDist = 0;
151
+ let index = 0;
152
+ for (let i = 1; i < points.length - 1; i++) {
153
+ const d = perpendicularDistance(points[i], first, last);
154
+ if (d > maxDist) {
155
+ maxDist = d;
156
+ index = i;
157
+ }
158
+ }
159
+ if (maxDist > tolerance) {
160
+ const left = simplifyPoints(points.slice(0, index + 1), tolerance);
161
+ const right = simplifyPoints(points.slice(index), tolerance);
162
+ return left.slice(0, -1).concat(right);
163
+ }
164
+ return [{ ...first }, { ...last }];
165
+ }
166
+ function arrowheadPolygon(x1, y1, x2, y2, size = 14) {
167
+ const angle = Math.atan2(y2 - y1, x2 - x1);
168
+ const spread = Math.PI / 7;
169
+ return [
170
+ { x: x2, y: y2 },
171
+ { x: x2 - size * Math.cos(angle - spread), y: y2 - size * Math.sin(angle - spread) },
172
+ { x: x2 - size * Math.cos(angle + spread), y: y2 - size * Math.sin(angle + spread) }
173
+ ];
174
+ }
175
+ function boundsOfAnnotation(a) {
176
+ switch (a.kind) {
177
+ case "rect":
178
+ case "ellipse":
179
+ case "blur": {
180
+ const x = Math.min(a.x, a.x + a.width);
181
+ const y = Math.min(a.y, a.y + a.height);
182
+ return { x, y, width: Math.abs(a.width), height: Math.abs(a.height) };
183
+ }
184
+ case "arrow":
185
+ case "line": {
186
+ const x = Math.min(a.x1, a.x2);
187
+ const y = Math.min(a.y1, a.y2);
188
+ return { x, y, width: Math.abs(a.x2 - a.x1), height: Math.abs(a.y2 - a.y1) };
189
+ }
190
+ case "pencil":
191
+ case "highlighter": {
192
+ if (a.points.length === 0) return null;
193
+ let minX = Infinity;
194
+ let minY = Infinity;
195
+ let maxX = -Infinity;
196
+ let maxY = -Infinity;
197
+ for (const p of a.points) {
198
+ minX = Math.min(minX, p.x);
199
+ minY = Math.min(minY, p.y);
200
+ maxX = Math.max(maxX, p.x);
201
+ maxY = Math.max(maxY, p.y);
202
+ }
203
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
204
+ }
205
+ case "text": {
206
+ const fontSize = a.fontSize ?? 16;
207
+ const width = Math.max(1, a.text.length * fontSize * 0.6);
208
+ return { x: a.x, y: a.y, width, height: fontSize * 1.2 };
209
+ }
210
+ case "step": {
211
+ return {
212
+ x: a.x - STEP_RADIUS,
213
+ y: a.y - STEP_RADIUS,
214
+ width: STEP_RADIUS * 2,
215
+ height: STEP_RADIUS * 2
216
+ };
217
+ }
218
+ }
219
+ }
220
+ function unionBounds(list) {
221
+ if (list.length === 0) return null;
222
+ let minX = Infinity;
223
+ let minY = Infinity;
224
+ let maxX = -Infinity;
225
+ let maxY = -Infinity;
226
+ for (const b of list) {
227
+ minX = Math.min(minX, b.x);
228
+ minY = Math.min(minY, b.y);
229
+ maxX = Math.max(maxX, b.x + b.width);
230
+ maxY = Math.max(maxY, b.y + b.height);
231
+ }
232
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
233
+ }
234
+ function padAndClampBounds(b, padding, maxWidth, maxHeight) {
235
+ const x = Math.max(0, b.x - padding);
236
+ const y = Math.max(0, b.y - padding);
237
+ const right = Math.min(maxWidth, b.x + b.width + padding);
238
+ const bottom = Math.min(maxHeight, b.y + b.height + padding);
239
+ return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
240
+ }
241
+ var svg = (inner) => `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
242
+ var iconEye = svg(
243
+ '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>'
244
+ );
245
+ var iconCrosshair = svg(
246
+ '<circle cx="12" cy="12" r="8"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>'
247
+ );
248
+ var iconClose = svg('<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>');
249
+ var iconMark = svg(
250
+ '<rect x="3" y="3" width="18" height="18" rx="2" stroke-dasharray="4 3"/>'
251
+ );
252
+ var iconCopy = svg(
253
+ '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>'
254
+ );
255
+ var iconCheck = svg('<polyline points="20 6 9 17 4 12"/>');
256
+ var iconSend = svg('<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>');
257
+ var iconRecord = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="6" fill="#ef4444"/></svg>`;
258
+ var iconStop = `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
259
+ var iconRectTool = svg('<rect x="4" y="6" width="16" height="12" rx="1"/>');
260
+ var iconEllipseTool = svg('<ellipse cx="12" cy="12" rx="8" ry="6"/>');
261
+ var iconArrowTool = svg('<line x1="5" y1="19" x2="18" y2="6"/><polyline points="18 12 18 6 12 6"/>');
262
+ var iconLineTool = svg('<line x1="5" y1="19" x2="19" y2="5"/>');
263
+ var iconPencil = svg('<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>');
264
+ var iconHighlighter = svg(
265
+ '<path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a1 1 0 0 1-1.4 0l-5.6-5.6a1 1 0 0 1 0-1.4L14 4"/>'
266
+ );
267
+ var iconText = svg(
268
+ '<polyline points="4 7 4 4 20 4 20 7"/><line x1="12" y1="4" x2="12" y2="20"/><line x1="9" y1="20" x2="15" y2="20"/>'
269
+ );
270
+ var iconStepTool = svg('<circle cx="12" cy="12" r="9"/><path d="M11.2 9 13 8v8"/>');
271
+ var iconBlur = svg('<path d="M12 3s6 6.4 6 10a6 6 0 0 1-12 0c0-3.6 6-10 6-10Z"/>');
272
+ var iconUndo = svg('<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 0 10h-1"/>');
273
+ var iconTrash = svg(
274
+ '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>'
275
+ );
276
+ var iconAnnotate = iconPencil;
277
+ var SVG_NS = "http://www.w3.org/2000/svg";
278
+ var FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
279
+ var MIN_DRAG = 6;
280
+ var TOOLS = [
281
+ { tool: "rect", icon: iconRectTool, label: "Rectangle" },
282
+ { tool: "ellipse", icon: iconEllipseTool, label: "Ellipse" },
283
+ { tool: "arrow", icon: iconArrowTool, label: "Arrow" },
284
+ { tool: "line", icon: iconLineTool, label: "Line" },
285
+ { tool: "pencil", icon: iconPencil, label: "Pencil" },
286
+ { tool: "highlighter", icon: iconHighlighter, label: "Highlighter" },
287
+ { tool: "text", icon: iconText, label: "Text" },
288
+ { tool: "step", icon: iconStepTool, label: "Step number" },
289
+ { tool: "blur", icon: iconBlur, label: "Blur / redact" }
290
+ ];
291
+ var COLORS = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#111827"];
292
+ var WIDTHS = [
293
+ { label: "S", value: 2 },
294
+ { label: "M", value: 3.5 },
295
+ { label: "L", value: 6 }
296
+ ];
297
+ var AnnotationEditor = class {
128
298
  constructor(host) {
129
299
  this.host = host;
130
300
  }
131
301
  host;
132
- layer = null;
133
- chips = null;
134
- draft = null;
135
- startX = 0;
136
- startY = 0;
137
- active = false;
138
- dragging = false;
302
+ svg = null;
303
+ bg = null;
304
+ toolbar = null;
305
+ textInputEl = null;
306
+ textAnchor = null;
307
+ textColor = COLORS[0];
139
308
  annotations = [];
140
- renderedRects = [];
309
+ markNodes = [];
310
+ drag = null;
311
+ active = false;
141
312
  sessionBase = 0;
313
+ stepCounter = 0;
314
+ idSeq = 0;
142
315
  onExit = null;
316
+ tool = "rect";
317
+ color = COLORS[0];
318
+ width = 3.5;
319
+ docW = 0;
320
+ docH = 0;
321
+ resizeObserver = null;
322
+ toolBtns = /* @__PURE__ */ new Map();
323
+ colorBtns = /* @__PURE__ */ new Map();
324
+ widthBtns = /* @__PURE__ */ new Map();
325
+ undoBtn = null;
326
+ clearBtn = null;
143
327
  get isActive() {
144
328
  return this.active;
145
329
  }
146
330
  get count() {
147
331
  return this.annotations.length;
148
332
  }
333
+ /** Deep-copied document-coord annotations per the schema contract. */
149
334
  list() {
150
- return this.annotations.map((a) => ({ ...a, viewport: { ...a.viewport } }));
151
- }
152
- /**
153
- * Live rendered rect elements (fixed-positioned, viewport coords). Exposed so
154
- * the overlay can derive a document-space bounding box for screenshot crop.
155
- */
156
- rects() {
157
- return [...this.renderedRects];
335
+ return this.annotations.map((a) => {
336
+ const copy2 = { ...a };
337
+ if ("points" in copy2 && Array.isArray(copy2.points)) {
338
+ copy2.points = copy2.points.map((p) => ({ ...p }));
339
+ }
340
+ if (copy2.kind === "rect" && copy2.viewport) copy2.viewport = { ...copy2.viewport };
341
+ return copy2;
342
+ });
158
343
  }
344
+ /** Remove every mark. Tears the layer down when not actively drawing. */
159
345
  clear() {
346
+ for (const node of this.markNodes) node.remove();
347
+ this.markNodes = [];
160
348
  this.annotations = [];
161
- for (const rect of this.renderedRects) rect.remove();
162
- this.renderedRects = [];
349
+ this.stepCounter = 0;
350
+ if (!this.active) this.teardownLayer();
351
+ this.syncActionButtons();
163
352
  }
164
353
  start(onExit) {
165
354
  if (this.active) return;
355
+ this.ensureLayer();
356
+ const svg2 = this.svg;
166
357
  this.active = true;
167
358
  this.onExit = onExit;
168
359
  this.sessionBase = this.annotations.length;
169
- this.layer = el("div", { class: "lens-annotate" });
170
- this.layer.addEventListener("pointerdown", this.onDown);
171
- this.chips = el("div", { class: "lens-annotate-chips" }, [
172
- el("span", { class: "lens-annotate-chips__label", text: "Drag to mark an area" }),
360
+ this.stepCounter = 0;
361
+ this.sizeLayer();
362
+ this.updateTransform();
363
+ svg2.style.pointerEvents = "auto";
364
+ svg2.style.touchAction = "none";
365
+ svg2.style.cursor = "crosshair";
366
+ svg2.addEventListener("pointerdown", this.onPointerDown);
367
+ this.buildToolbar();
368
+ window.addEventListener("keydown", this.onKey, true);
369
+ }
370
+ /** Done — keep this session's marks, exit draw mode. */
371
+ exit() {
372
+ if (!this.active) return;
373
+ this.active = false;
374
+ this.commitTextInput();
375
+ if (this.drag) {
376
+ this.drag.node.remove();
377
+ this.detachDragListeners();
378
+ this.drag = null;
379
+ }
380
+ const svg2 = this.svg;
381
+ if (svg2) {
382
+ svg2.removeEventListener("pointerdown", this.onPointerDown);
383
+ svg2.style.pointerEvents = "none";
384
+ svg2.style.touchAction = "";
385
+ svg2.style.cursor = "";
386
+ }
387
+ window.removeEventListener("keydown", this.onKey, true);
388
+ this.removeToolbar();
389
+ const cb = this.onExit;
390
+ this.onExit = null;
391
+ cb?.();
392
+ }
393
+ /** Cancel — discard marks drawn during this session, then exit. */
394
+ cancel() {
395
+ this.cancelTextInput();
396
+ const removed = this.markNodes.splice(this.sessionBase);
397
+ for (const node of removed) node.remove();
398
+ this.annotations.splice(this.sessionBase);
399
+ this.exit();
400
+ }
401
+ undo() {
402
+ const node = this.markNodes.pop();
403
+ if (!node) return;
404
+ node.remove();
405
+ this.annotations.pop();
406
+ this.syncActionButtons();
407
+ }
408
+ // --------------------------------------------------------------------------
409
+ // Composite / crop
410
+ // --------------------------------------------------------------------------
411
+ /**
412
+ * Complete standalone SVG of every NON-blur mark, plus blur regions returned
413
+ * separately. `null` when there are no marks.
414
+ */
415
+ getComposite() {
416
+ if (this.annotations.length === 0 || !this.svg) return null;
417
+ this.sizeLayer();
418
+ const docWidth = this.docW;
419
+ const docHeight = this.docH;
420
+ const clone = this.svg.cloneNode(true);
421
+ clone.removeAttribute("style");
422
+ clone.removeAttribute("class");
423
+ clone.querySelectorAll("[data-lens-chrome],[data-lens-draft],[data-lens-blur]").forEach((n) => n.remove());
424
+ clone.querySelector("defs")?.remove();
425
+ clone.setAttribute("xmlns", SVG_NS);
426
+ clone.setAttribute("viewBox", `0 0 ${docWidth} ${docHeight}`);
427
+ clone.setAttribute("width", String(docWidth));
428
+ clone.setAttribute("height", String(docHeight));
429
+ const svgMarkup = new XMLSerializer().serializeToString(clone);
430
+ const blurRegions = this.annotations.filter((a) => a.kind === "blur").map((a) => ({ x: a.x, y: a.y, width: a.width, height: a.height }));
431
+ return { svgMarkup, docWidth, docHeight, blurRegions };
432
+ }
433
+ /** Union bbox of all marks + 24px padding, clamped to the document. */
434
+ getCropBounds() {
435
+ if (this.annotations.length === 0) return void 0;
436
+ const boxes = [];
437
+ for (let i = 0; i < this.annotations.length; i++) {
438
+ const a = this.annotations[i];
439
+ const b = a.kind === "text" ? this.textBounds(i) : boundsOfAnnotation(a);
440
+ if (b) boxes.push(b);
441
+ }
442
+ const u = unionBounds(boxes);
443
+ if (!u) return void 0;
444
+ this.sizeLayer();
445
+ return padAndClampBounds(u, 24, this.docW, this.docH);
446
+ }
447
+ /** Prefer a live getBBox() for text; fall back to the geometric estimate. */
448
+ textBounds(index) {
449
+ const node = this.markNodes[index];
450
+ const annotation = this.annotations[index];
451
+ if (node && typeof node.getBBox === "function") {
452
+ try {
453
+ const box = node.getBBox();
454
+ if (box.width > 0 && box.height > 0) {
455
+ return { x: box.x, y: box.y, width: box.width, height: box.height };
456
+ }
457
+ } catch {
458
+ }
459
+ }
460
+ return boundsOfAnnotation(annotation);
461
+ }
462
+ // --------------------------------------------------------------------------
463
+ // Layer lifecycle
464
+ // --------------------------------------------------------------------------
465
+ ensureLayer() {
466
+ if (this.svg) return;
467
+ const svg2 = document.createElementNS(SVG_NS, "svg");
468
+ svg2.setAttribute("class", "lens-annotate-svg");
469
+ svg2.setAttribute("xmlns", SVG_NS);
470
+ const defs = document.createElementNS(SVG_NS, "defs");
471
+ defs.setAttribute("data-lens-chrome", "1");
472
+ const pattern = document.createElementNS(SVG_NS, "pattern");
473
+ pattern.setAttribute("id", "lens-blur-hatch");
474
+ pattern.setAttribute("width", "8");
475
+ pattern.setAttribute("height", "8");
476
+ pattern.setAttribute("patternUnits", "userSpaceOnUse");
477
+ pattern.setAttribute("patternTransform", "rotate(45)");
478
+ const patternBase = document.createElementNS(SVG_NS, "rect");
479
+ patternBase.setAttribute("width", "8");
480
+ patternBase.setAttribute("height", "8");
481
+ patternBase.setAttribute("fill", "rgba(17,24,39,0.12)");
482
+ const patternLine = document.createElementNS(SVG_NS, "line");
483
+ patternLine.setAttribute("x1", "0");
484
+ patternLine.setAttribute("y1", "0");
485
+ patternLine.setAttribute("x2", "0");
486
+ patternLine.setAttribute("y2", "8");
487
+ patternLine.setAttribute("stroke", "rgba(17,24,39,0.55)");
488
+ patternLine.setAttribute("stroke-width", "4");
489
+ pattern.append(patternBase, patternLine);
490
+ defs.append(pattern);
491
+ svg2.append(defs);
492
+ const bg = document.createElementNS(SVG_NS, "rect");
493
+ bg.setAttribute("data-lens-chrome", "1");
494
+ bg.setAttribute("x", "0");
495
+ bg.setAttribute("y", "0");
496
+ bg.setAttribute("fill", "transparent");
497
+ bg.style.pointerEvents = "all";
498
+ svg2.append(bg);
499
+ this.svg = svg2;
500
+ this.bg = bg;
501
+ this.host.append(svg2);
502
+ window.addEventListener("scroll", this.onScroll, { passive: true, capture: true });
503
+ window.addEventListener("resize", this.onResize);
504
+ if (typeof ResizeObserver !== "undefined") {
505
+ this.resizeObserver = new ResizeObserver(this.onResize);
506
+ this.resizeObserver.observe(document.documentElement);
507
+ }
508
+ this.sizeLayer();
509
+ this.updateTransform();
510
+ }
511
+ teardownLayer() {
512
+ window.removeEventListener("scroll", this.onScroll, true);
513
+ window.removeEventListener("resize", this.onResize);
514
+ this.resizeObserver?.disconnect();
515
+ this.resizeObserver = null;
516
+ this.svg?.remove();
517
+ this.svg = null;
518
+ this.bg = null;
519
+ }
520
+ docSize() {
521
+ const d = document.documentElement;
522
+ const b = document.body;
523
+ const width = Math.max(d.scrollWidth, b?.scrollWidth ?? 0, d.clientWidth);
524
+ const height = Math.max(d.scrollHeight, b?.scrollHeight ?? 0, d.clientHeight);
525
+ return { width, height };
526
+ }
527
+ sizeLayer() {
528
+ if (!this.svg) return;
529
+ const { width, height } = this.docSize();
530
+ this.docW = width;
531
+ this.docH = height;
532
+ this.svg.setAttribute("width", String(width));
533
+ this.svg.setAttribute("height", String(height));
534
+ this.svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
535
+ this.svg.style.width = `${width}px`;
536
+ this.svg.style.height = `${height}px`;
537
+ if (this.bg) {
538
+ this.bg.setAttribute("width", String(width));
539
+ this.bg.setAttribute("height", String(height));
540
+ }
541
+ }
542
+ updateTransform() {
543
+ if (!this.svg) return;
544
+ const scrollX = window.scrollX || window.pageXOffset || 0;
545
+ const scrollY = window.scrollY || window.pageYOffset || 0;
546
+ this.svg.style.left = `${-scrollX}px`;
547
+ this.svg.style.top = `${-scrollY}px`;
548
+ }
549
+ onScroll = () => this.updateTransform();
550
+ onResize = () => {
551
+ this.sizeLayer();
552
+ this.updateTransform();
553
+ };
554
+ // --------------------------------------------------------------------------
555
+ // Toolbar
556
+ // --------------------------------------------------------------------------
557
+ buildToolbar() {
558
+ this.toolBtns.clear();
559
+ this.colorBtns.clear();
560
+ this.widthBtns.clear();
561
+ const toolGroup = el("div", { class: "lens-tool-group" });
562
+ for (const { tool, icon, label } of TOOLS) {
563
+ const btn = el("button", {
564
+ class: "lens-tool-btn",
565
+ ariaLabel: label,
566
+ title: label,
567
+ html: icon,
568
+ attrs: { type: "button", "aria-pressed": String(tool === this.tool) },
569
+ on: { click: () => this.selectTool(tool) }
570
+ });
571
+ this.toolBtns.set(tool, btn);
572
+ toolGroup.append(btn);
573
+ }
574
+ const colorGroup = el("div", { class: "lens-tool-group lens-swatches" });
575
+ for (const c of COLORS) {
576
+ const btn = el("button", {
577
+ class: "lens-swatch",
578
+ ariaLabel: `Color ${c}`,
579
+ title: c,
580
+ attrs: { type: "button", "aria-pressed": String(c === this.color) },
581
+ style: { background: c },
582
+ on: { click: () => this.selectColor(c) }
583
+ });
584
+ this.colorBtns.set(c, btn);
585
+ colorGroup.append(btn);
586
+ }
587
+ const widthGroup = el("div", { class: "lens-tool-group" });
588
+ for (const { label, value } of WIDTHS) {
589
+ const btn = el("button", {
590
+ class: "lens-width-btn",
591
+ text: label,
592
+ ariaLabel: `Stroke width ${label}`,
593
+ title: `Stroke ${label} (${value}px)`,
594
+ attrs: { type: "button", "aria-pressed": String(value === this.width) },
595
+ on: { click: () => this.selectWidth(value) }
596
+ });
597
+ this.widthBtns.set(value, btn);
598
+ widthGroup.append(btn);
599
+ }
600
+ this.undoBtn = el("button", {
601
+ class: "lens-tool-btn",
602
+ ariaLabel: "Undo",
603
+ title: "Undo",
604
+ html: iconUndo,
605
+ attrs: { type: "button" },
606
+ on: { click: () => this.undo() }
607
+ });
608
+ this.clearBtn = el("button", {
609
+ class: "lens-tool-btn",
610
+ ariaLabel: "Clear all",
611
+ title: "Clear all",
612
+ html: iconTrash,
613
+ attrs: { type: "button" },
614
+ on: { click: () => this.clear() }
615
+ });
616
+ const editGroup = el("div", { class: "lens-tool-group" }, [this.undoBtn, this.clearBtn]);
617
+ const doneGroup = el("div", { class: "lens-tool-group" }, [
173
618
  el("button", {
174
619
  class: "lens-pick-chip__btn",
175
620
  text: "Cancel",
621
+ attrs: { type: "button" },
176
622
  on: { click: () => this.cancel() }
177
623
  }),
178
624
  el("button", {
179
625
  class: "lens-pick-chip__btn lens-pick-chip__btn--primary",
180
626
  text: "Done",
627
+ attrs: { type: "button" },
181
628
  on: { click: () => this.exit() }
182
629
  })
183
630
  ]);
184
- this.host.append(this.layer, this.chips);
185
- window.addEventListener("keydown", this.onKey, true);
631
+ this.toolbar = el(
632
+ "div",
633
+ { class: "lens-annotate-toolbar", attrs: { role: "toolbar", "aria-label": "Annotation tools" } },
634
+ [
635
+ toolGroup,
636
+ el("span", { class: "lens-tool-sep" }),
637
+ colorGroup,
638
+ el("span", { class: "lens-tool-sep" }),
639
+ widthGroup,
640
+ el("span", { class: "lens-tool-sep" }),
641
+ editGroup,
642
+ el("span", { class: "lens-tool-sep" }),
643
+ doneGroup
644
+ ]
645
+ );
646
+ this.host.append(this.toolbar);
647
+ this.syncActionButtons();
648
+ }
649
+ removeToolbar() {
650
+ this.toolbar?.remove();
651
+ this.toolbar = null;
652
+ this.toolBtns.clear();
653
+ this.colorBtns.clear();
654
+ this.widthBtns.clear();
655
+ this.undoBtn = null;
656
+ this.clearBtn = null;
657
+ }
658
+ selectTool(tool) {
659
+ this.commitTextInput();
660
+ this.tool = tool;
661
+ for (const [t, btn] of this.toolBtns) {
662
+ const on = t === tool;
663
+ btn.classList.toggle("is-active", on);
664
+ btn.setAttribute("aria-pressed", String(on));
665
+ }
666
+ if (this.svg) this.svg.style.cursor = tool === "text" ? "text" : "crosshair";
667
+ }
668
+ selectColor(color) {
669
+ this.color = color;
670
+ for (const [c, btn] of this.colorBtns) {
671
+ const on = c === color;
672
+ btn.classList.toggle("is-active", on);
673
+ btn.setAttribute("aria-pressed", String(on));
674
+ }
186
675
  }
187
- /** Discard rectangles drawn during this session, then exit. */
188
- cancel() {
189
- const removed = this.renderedRects.splice(this.sessionBase);
190
- for (const rect of removed) rect.remove();
191
- this.annotations.splice(this.sessionBase);
192
- this.exit();
676
+ selectWidth(value) {
677
+ this.width = value;
678
+ for (const [v, btn] of this.widthBtns) {
679
+ const on = v === value;
680
+ btn.classList.toggle("is-active", on);
681
+ btn.setAttribute("aria-pressed", String(on));
682
+ }
193
683
  }
194
- exit() {
684
+ syncActionButtons() {
685
+ const empty = this.markNodes.length === 0;
686
+ if (this.undoBtn) this.undoBtn.disabled = empty;
687
+ if (this.clearBtn) this.clearBtn.disabled = empty;
688
+ }
689
+ // --------------------------------------------------------------------------
690
+ // Pointer / drawing
691
+ // --------------------------------------------------------------------------
692
+ toDoc(e) {
693
+ return {
694
+ x: e.clientX + (window.scrollX || window.pageXOffset || 0),
695
+ y: e.clientY + (window.scrollY || window.pageYOffset || 0)
696
+ };
697
+ }
698
+ onPointerDown = (e) => {
195
699
  if (!this.active) return;
196
- this.active = false;
197
- this.dragging = false;
198
- window.removeEventListener("keydown", this.onKey, true);
199
- window.removeEventListener("pointermove", this.onMove, true);
200
- window.removeEventListener("pointerup", this.onUp, true);
201
- if (this.draft) {
202
- this.draft.remove();
203
- this.draft = null;
700
+ if (e.button !== void 0 && e.button !== 0) return;
701
+ e.preventDefault();
702
+ const p = this.toDoc(e);
703
+ const tool = this.tool;
704
+ if (tool === "text") {
705
+ this.openTextInput(e.clientX, e.clientY, p);
706
+ return;
204
707
  }
205
- if (this.layer) {
206
- this.layer.removeEventListener("pointerdown", this.onDown);
207
- this.layer.remove();
208
- this.layer = null;
708
+ if (tool === "step") {
709
+ this.placeStep(p);
710
+ return;
209
711
  }
210
- if (this.chips) {
211
- this.chips.remove();
212
- this.chips = null;
712
+ const color = this.color;
713
+ const width = this.width;
714
+ let drag;
715
+ if (tool === "pencil" || tool === "highlighter") {
716
+ const poly = this.svgEl("polyline", {
717
+ "data-lens-draft": "1",
718
+ fill: "none",
719
+ stroke: color,
720
+ "stroke-width": tool === "highlighter" ? HIGHLIGHTER_WIDTH : width,
721
+ "stroke-linecap": "round",
722
+ "stroke-linejoin": "round",
723
+ ...tool === "highlighter" ? { "stroke-opacity": "0.35" } : {},
724
+ points: `${p.x},${p.y}`
725
+ });
726
+ this.svg.append(poly);
727
+ drag = { tool, color, width, start: p, node: poly, points: [p], last: p };
728
+ } else if (tool === "arrow") {
729
+ const g = this.svgEl("g", { "data-lens-draft": "1" });
730
+ const line = this.svgEl("line", {
731
+ stroke: color,
732
+ "stroke-width": width,
733
+ "stroke-linecap": "round"
734
+ });
735
+ const head = this.svgEl("polygon", { fill: color });
736
+ g.append(line, head);
737
+ this.svg.append(g);
738
+ drag = { tool, color, width, start: p, node: g, line, head };
739
+ this.updateArrow(drag, p);
740
+ } else {
741
+ const node = this.createShapeNode(tool, color, width);
742
+ this.svg.append(node);
743
+ drag = { tool, color, width, start: p, node };
744
+ this.updateShape(drag, p);
213
745
  }
214
- const exit = this.onExit;
215
- this.onExit = null;
216
- exit?.();
217
- }
218
- onDown = (e) => {
219
- e.preventDefault();
220
- this.dragging = true;
221
- this.startX = e.clientX;
222
- this.startY = e.clientY;
223
- this.draft = el("div", { class: "lens-rect" });
224
- this.host.append(this.draft);
225
- this.position(this.draft, e.clientX, e.clientY);
226
- window.addEventListener("pointermove", this.onMove, true);
227
- window.addEventListener("pointerup", this.onUp, true);
746
+ this.drag = drag;
747
+ window.addEventListener("pointermove", this.onPointerMove, true);
748
+ window.addEventListener("pointerup", this.onPointerUp, true);
228
749
  };
229
- onMove = (e) => {
230
- if (this.dragging && this.draft) this.position(this.draft, e.clientX, e.clientY);
750
+ onPointerMove = (e) => {
751
+ const d = this.drag;
752
+ if (!d) return;
753
+ const p = this.toDoc(e);
754
+ if (d.tool === "pencil" || d.tool === "highlighter") {
755
+ const pts = d.points;
756
+ if (d.last && shouldKeepPoint(d.last, p) && pts.length < MAX_POINTS) {
757
+ pts.push(p);
758
+ d.last = p;
759
+ d.node.setAttribute("points", this.pointsAttr(pts));
760
+ }
761
+ } else if (d.tool === "arrow") {
762
+ this.updateArrow(d, p);
763
+ } else {
764
+ this.updateShape(d, p);
765
+ }
231
766
  };
232
- onUp = (e) => {
233
- window.removeEventListener("pointermove", this.onMove, true);
234
- window.removeEventListener("pointerup", this.onUp, true);
235
- this.dragging = false;
236
- const x = Math.min(this.startX, e.clientX);
237
- const y = Math.min(this.startY, e.clientY);
238
- const width = Math.abs(e.clientX - this.startX);
239
- const height = Math.abs(e.clientY - this.startY);
240
- if (this.draft) {
241
- this.draft.remove();
242
- this.draft = null;
767
+ onPointerUp = (e) => {
768
+ const d = this.drag;
769
+ this.detachDragListeners();
770
+ this.drag = null;
771
+ if (!d) return;
772
+ const p = this.toDoc(e);
773
+ let annotation = null;
774
+ if (d.tool === "pencil" || d.tool === "highlighter") {
775
+ let pts = d.points;
776
+ if (pts.length >= 2) pts = simplifyPoints(pts, 1);
777
+ if (pts.length < 2) {
778
+ d.node.remove();
779
+ return;
780
+ }
781
+ d.node.setAttribute("points", this.pointsAttr(pts));
782
+ annotation = d.tool === "pencil" ? { kind: "pencil", points: pts, color: d.color, strokeWidth: d.width, id: this.nextId() } : {
783
+ kind: "highlighter",
784
+ points: pts,
785
+ color: d.color,
786
+ strokeWidth: HIGHLIGHTER_WIDTH,
787
+ id: this.nextId()
788
+ };
789
+ } else if (d.tool === "arrow" || d.tool === "line") {
790
+ if (distance(d.start, p) < MIN_DRAG) {
791
+ d.node.remove();
792
+ return;
793
+ }
794
+ if (d.tool === "arrow") {
795
+ this.updateArrow(d, p);
796
+ annotation = {
797
+ kind: "arrow",
798
+ x1: d.start.x,
799
+ y1: d.start.y,
800
+ x2: p.x,
801
+ y2: p.y,
802
+ color: d.color,
803
+ strokeWidth: d.width,
804
+ id: this.nextId()
805
+ };
806
+ } else {
807
+ this.updateShape(d, p);
808
+ annotation = {
809
+ kind: "line",
810
+ x1: d.start.x,
811
+ y1: d.start.y,
812
+ x2: p.x,
813
+ y2: p.y,
814
+ color: d.color,
815
+ strokeWidth: d.width,
816
+ id: this.nextId()
817
+ };
818
+ }
819
+ } else {
820
+ const x = Math.min(d.start.x, p.x);
821
+ const y = Math.min(d.start.y, p.y);
822
+ const width = Math.abs(p.x - d.start.x);
823
+ const height = Math.abs(p.y - d.start.y);
824
+ if (width < MIN_DRAG || height < MIN_DRAG) {
825
+ d.node.remove();
826
+ return;
827
+ }
828
+ this.updateShape(d, p);
829
+ if (d.tool === "rect") {
830
+ annotation = {
831
+ kind: "rect",
832
+ x,
833
+ y,
834
+ width,
835
+ height,
836
+ color: d.color,
837
+ strokeWidth: d.width,
838
+ id: this.nextId()
839
+ };
840
+ } else if (d.tool === "ellipse") {
841
+ annotation = {
842
+ kind: "ellipse",
843
+ x,
844
+ y,
845
+ width,
846
+ height,
847
+ color: d.color,
848
+ strokeWidth: d.width,
849
+ id: this.nextId()
850
+ };
851
+ } else {
852
+ annotation = { kind: "blur", x, y, width, height, id: this.nextId() };
853
+ }
854
+ }
855
+ d.node.removeAttribute("data-lens-draft");
856
+ this.commitMark(annotation, d.node);
857
+ };
858
+ detachDragListeners() {
859
+ window.removeEventListener("pointermove", this.onPointerMove, true);
860
+ window.removeEventListener("pointerup", this.onPointerUp, true);
861
+ }
862
+ commitMark(annotation, node) {
863
+ this.annotations.push(annotation);
864
+ this.markNodes.push(node);
865
+ this.syncActionButtons();
866
+ }
867
+ createShapeNode(tool, color, width) {
868
+ if (tool === "ellipse") {
869
+ return this.svgEl("ellipse", {
870
+ "data-lens-draft": "1",
871
+ fill: "none",
872
+ stroke: color,
873
+ "stroke-width": width
874
+ });
875
+ }
876
+ if (tool === "line") {
877
+ return this.svgEl("line", {
878
+ "data-lens-draft": "1",
879
+ stroke: color,
880
+ "stroke-width": width,
881
+ "stroke-linecap": "round"
882
+ });
883
+ }
884
+ if (tool === "blur") {
885
+ return this.svgEl("rect", {
886
+ "data-lens-draft": "1",
887
+ "data-lens-blur": "1",
888
+ fill: "url(#lens-blur-hatch)",
889
+ stroke: "#111827",
890
+ "stroke-width": 1,
891
+ "stroke-dasharray": "4 3"
892
+ });
243
893
  }
244
- if (width < 6 || height < 6) return;
245
- const rect = el("div", { class: "lens-rect" });
246
- Object.assign(rect.style, {
247
- left: `${x}px`,
248
- top: `${y}px`,
249
- width: `${width}px`,
250
- height: `${height}px`
894
+ return this.svgEl("rect", {
895
+ "data-lens-draft": "1",
896
+ fill: "none",
897
+ stroke: color,
898
+ "stroke-width": width,
899
+ rx: 2
251
900
  });
252
- this.host.append(rect);
253
- this.renderedRects.push(rect);
254
- this.annotations.push({
255
- kind: "rect",
256
- x,
257
- y,
258
- width,
259
- height,
260
- viewport: { width: window.innerWidth, height: window.innerHeight }
901
+ }
902
+ updateShape(d, cur) {
903
+ const x = Math.min(d.start.x, cur.x);
904
+ const y = Math.min(d.start.y, cur.y);
905
+ const w = Math.abs(cur.x - d.start.x);
906
+ const h = Math.abs(cur.y - d.start.y);
907
+ const node = d.node;
908
+ if (d.tool === "ellipse") {
909
+ node.setAttribute("cx", String(x + w / 2));
910
+ node.setAttribute("cy", String(y + h / 2));
911
+ node.setAttribute("rx", String(w / 2));
912
+ node.setAttribute("ry", String(h / 2));
913
+ } else if (d.tool === "line") {
914
+ node.setAttribute("x1", String(d.start.x));
915
+ node.setAttribute("y1", String(d.start.y));
916
+ node.setAttribute("x2", String(cur.x));
917
+ node.setAttribute("y2", String(cur.y));
918
+ } else {
919
+ node.setAttribute("x", String(x));
920
+ node.setAttribute("y", String(y));
921
+ node.setAttribute("width", String(w));
922
+ node.setAttribute("height", String(h));
923
+ }
924
+ }
925
+ updateArrow(d, cur) {
926
+ if (!d.line || !d.head) return;
927
+ d.line.setAttribute("x1", String(d.start.x));
928
+ d.line.setAttribute("y1", String(d.start.y));
929
+ d.line.setAttribute("x2", String(cur.x));
930
+ d.line.setAttribute("y2", String(cur.y));
931
+ const size = Math.max(12, d.width * 3);
932
+ const poly = arrowheadPolygon(d.start.x, d.start.y, cur.x, cur.y, size);
933
+ d.head.setAttribute("points", this.pointsAttr(poly));
934
+ }
935
+ // --------------------------------------------------------------------------
936
+ // Text tool
937
+ // --------------------------------------------------------------------------
938
+ openTextInput(clientX, clientY, docPoint) {
939
+ this.commitTextInput();
940
+ const input = el("input", {
941
+ class: "lens-text-input",
942
+ attrs: { type: "text", "aria-label": "Annotation text" },
943
+ style: { left: `${clientX}px`, top: `${clientY}px`, color: this.color }
261
944
  });
262
- };
263
- position(node, curX, curY) {
264
- const x = Math.min(this.startX, curX);
265
- const y = Math.min(this.startY, curY);
266
- Object.assign(node.style, {
267
- left: `${x}px`,
268
- top: `${y}px`,
269
- width: `${Math.abs(curX - this.startX)}px`,
270
- height: `${Math.abs(curY - this.startY)}px`
945
+ input.addEventListener("blur", () => this.commitTextInput());
946
+ this.host.append(input);
947
+ this.textInputEl = input;
948
+ this.textAnchor = docPoint;
949
+ this.textColor = this.color;
950
+ input.focus();
951
+ }
952
+ commitTextInput() {
953
+ const input = this.textInputEl;
954
+ const anchor = this.textAnchor;
955
+ if (!input || !anchor) return;
956
+ const value = input.value.trim();
957
+ this.textInputEl = null;
958
+ this.textAnchor = null;
959
+ input.remove();
960
+ if (!value) return;
961
+ const node = this.svgEl("text", {
962
+ x: anchor.x,
963
+ y: anchor.y,
964
+ fill: this.textColor,
965
+ "font-size": 16,
966
+ "font-family": FONT,
967
+ "dominant-baseline": "hanging"
271
968
  });
969
+ node.textContent = value;
970
+ this.svg.append(node);
971
+ this.commitMark(
972
+ {
973
+ kind: "text",
974
+ x: anchor.x,
975
+ y: anchor.y,
976
+ text: value,
977
+ fontSize: 16,
978
+ color: this.textColor,
979
+ id: this.nextId()
980
+ },
981
+ node
982
+ );
272
983
  }
984
+ cancelTextInput() {
985
+ if (!this.textInputEl) return;
986
+ this.textInputEl.remove();
987
+ this.textInputEl = null;
988
+ this.textAnchor = null;
989
+ }
990
+ // --------------------------------------------------------------------------
991
+ // Step tool
992
+ // --------------------------------------------------------------------------
993
+ placeStep(p) {
994
+ const n = ++this.stepCounter;
995
+ const g = this.svgEl("g", {});
996
+ const circle = this.svgEl("circle", {
997
+ cx: p.x,
998
+ cy: p.y,
999
+ r: STEP_RADIUS,
1000
+ fill: this.color
1001
+ });
1002
+ const text = this.svgEl("text", {
1003
+ x: p.x,
1004
+ y: p.y,
1005
+ fill: "#ffffff",
1006
+ "font-size": 13,
1007
+ "font-weight": 700,
1008
+ "font-family": FONT,
1009
+ "text-anchor": "middle",
1010
+ "dominant-baseline": "central"
1011
+ });
1012
+ text.textContent = String(n);
1013
+ g.append(circle, text);
1014
+ this.svg.append(g);
1015
+ this.commitMark({ kind: "step", x: p.x, y: p.y, n, color: this.color, id: this.nextId() }, g);
1016
+ }
1017
+ // --------------------------------------------------------------------------
1018
+ // Keyboard
1019
+ // --------------------------------------------------------------------------
273
1020
  onKey = (e) => {
274
- if (e.key !== "Escape") return;
275
- e.preventDefault();
276
- e.stopPropagation();
277
- this.exit();
1021
+ if (this.textInputEl) {
1022
+ if (e.key === "Escape") {
1023
+ e.preventDefault();
1024
+ e.stopPropagation();
1025
+ this.cancelTextInput();
1026
+ } else if (e.key === "Enter") {
1027
+ e.preventDefault();
1028
+ e.stopPropagation();
1029
+ this.commitTextInput();
1030
+ }
1031
+ return;
1032
+ }
1033
+ if (e.key === "Escape") {
1034
+ e.preventDefault();
1035
+ e.stopPropagation();
1036
+ this.exit();
1037
+ }
278
1038
  };
1039
+ // --------------------------------------------------------------------------
1040
+ // Small helpers
1041
+ // --------------------------------------------------------------------------
1042
+ svgEl(tag, attrs) {
1043
+ const node = document.createElementNS(SVG_NS, tag);
1044
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, String(v));
1045
+ return node;
1046
+ }
1047
+ pointsAttr(points) {
1048
+ return points.map((p) => `${p.x},${p.y}`).join(" ");
1049
+ }
1050
+ nextId() {
1051
+ return `lens-${++this.idSeq}`;
1052
+ }
279
1053
  };
280
- var svg = (inner) => `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
281
- var iconEye = svg(
282
- '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>'
283
- );
284
- var iconCrosshair = svg(
285
- '<circle cx="12" cy="12" r="8"/><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/>'
286
- );
287
- var iconClose = svg('<line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/>');
288
- var iconMark = svg(
289
- '<rect x="3" y="3" width="18" height="18" rx="2" stroke-dasharray="4 3"/>'
290
- );
291
- var iconCopy = svg(
292
- '<rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>'
293
- );
294
- var iconCheck = svg('<polyline points="20 6 9 17 4 12"/>');
295
- var iconSend = svg('<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>');
296
- var iconRecord = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"><circle cx="12" cy="12" r="6" fill="#ef4444"/></svg>`;
297
- var iconStop = `<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
298
1054
  var INCLUDE_ITEMS = [
299
1055
  { key: "replay", label: "Replay" },
300
1056
  { key: "console", label: "Console" },
@@ -451,7 +1207,7 @@ var Panel = class {
451
1207
  buildActions() {
452
1208
  const mark = el("button", {
453
1209
  class: "lens-abtn lens-abtn--ghost lens-abtn--compact",
454
- html: iconMark + "<span>Mark area</span>",
1210
+ html: iconAnnotate + "<span>Annotate</span>",
455
1211
  on: { click: () => this.cb.onMarkArea() }
456
1212
  });
457
1213
  const record = el("button", {
@@ -835,21 +1591,33 @@ var OVERLAY_CSS = (
835
1591
  pointer-events: none;
836
1592
  }
837
1593
 
838
- /* ---------------- Annotation layer ---------------- */
839
- .lens-annotate {
840
- position: fixed;
841
- inset: 0;
1594
+ /* ---------------- Annotation SVG layer ---------------- */
1595
+ /* One full-DOCUMENT SVG anchored to page content (translated by -scroll so
1596
+ marks survive scrolling). pointer-events flip to auto only while drawing. */
1597
+ .lens-annotate-svg {
1598
+ position: absolute;
1599
+ left: 0;
1600
+ top: 0;
842
1601
  z-index: calc(var(--lens-z) - 1);
843
- cursor: crosshair;
844
- background: rgba(15, 15, 20, 0.04);
845
- touch-action: none;
1602
+ pointer-events: none;
1603
+ overflow: visible;
846
1604
  }
847
- .lens-rect {
1605
+
1606
+ /* Floating text-entry input for the Text tool (viewport-positioned). */
1607
+ .lens-text-input {
848
1608
  position: fixed;
849
- border: 2px solid var(--lens-accent);
850
- background: rgba(99, 102, 241, 0.1);
851
- border-radius: 3px;
852
- pointer-events: none;
1609
+ z-index: var(--lens-z);
1610
+ min-width: 120px;
1611
+ padding: 2px 6px;
1612
+ border: 1px solid var(--lens-accent);
1613
+ border-radius: 4px;
1614
+ background: rgba(255, 255, 255, 0.96);
1615
+ color: #111827;
1616
+ font-family: var(--lens-font);
1617
+ font-size: 15px;
1618
+ line-height: 1.3;
1619
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
1620
+ outline: none;
853
1621
  }
854
1622
 
855
1623
  /* ---------------- Panel ---------------- */
@@ -1140,6 +1908,114 @@ var OVERLAY_CSS = (
1140
1908
  }
1141
1909
  .lens-annotate-chips { z-index: var(--lens-z); }
1142
1910
 
1911
+ /* ---------------- Annotation toolbar ---------------- */
1912
+ /* Same floating top-center anchor as the chips row; wraps on narrow screens. */
1913
+ .lens-annotate-toolbar {
1914
+ position: fixed;
1915
+ top: calc(16px + env(safe-area-inset-top));
1916
+ left: 50%;
1917
+ transform: translateX(-50%);
1918
+ z-index: var(--lens-z);
1919
+ display: inline-flex;
1920
+ flex-wrap: wrap;
1921
+ align-items: center;
1922
+ justify-content: center;
1923
+ gap: 6px;
1924
+ max-width: calc(100vw - 24px);
1925
+ padding: 6px 8px;
1926
+ border-radius: 14px;
1927
+ background: rgba(20, 20, 23, 0.92);
1928
+ -webkit-backdrop-filter: blur(12px) saturate(180%);
1929
+ backdrop-filter: blur(12px) saturate(180%);
1930
+ border: 1px solid rgba(255, 255, 255, 0.12);
1931
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
1932
+ color: #f4f4f5;
1933
+ }
1934
+ .lens-tool-group {
1935
+ display: inline-flex;
1936
+ align-items: center;
1937
+ gap: 4px;
1938
+ flex-wrap: wrap;
1939
+ }
1940
+ .lens-tool-sep {
1941
+ width: 1px;
1942
+ align-self: stretch;
1943
+ min-height: 22px;
1944
+ background: rgba(255, 255, 255, 0.16);
1945
+ margin: 0 2px;
1946
+ }
1947
+ .lens-tool-btn {
1948
+ display: inline-flex;
1949
+ align-items: center;
1950
+ justify-content: center;
1951
+ width: 30px;
1952
+ height: 30px;
1953
+ padding: 0;
1954
+ border: 1px solid transparent;
1955
+ border-radius: 8px;
1956
+ background: transparent;
1957
+ color: #d4d4d8;
1958
+ cursor: pointer;
1959
+ transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
1960
+ }
1961
+ .lens-tool-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
1962
+ .lens-tool-btn:focus-visible { outline: 2px solid var(--lens-accent); outline-offset: 1px; }
1963
+ .lens-tool-btn.is-active {
1964
+ background: var(--lens-accent);
1965
+ border-color: var(--lens-accent);
1966
+ color: #fff;
1967
+ }
1968
+ .lens-tool-btn:disabled { opacity: 0.4; cursor: default; }
1969
+ .lens-tool-btn:disabled:hover { background: transparent; color: #d4d4d8; }
1970
+
1971
+ .lens-swatch {
1972
+ width: 22px;
1973
+ height: 22px;
1974
+ padding: 0;
1975
+ border-radius: 999px;
1976
+ border: 2px solid transparent;
1977
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25) inset;
1978
+ cursor: pointer;
1979
+ transition: transform 120ms ease, border-color 120ms ease;
1980
+ }
1981
+ .lens-swatch:hover { transform: scale(1.1); }
1982
+ .lens-swatch:focus-visible { outline: 2px solid #fff; outline-offset: 1px; }
1983
+ .lens-swatch.is-active {
1984
+ border-color: #fff;
1985
+ box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.4);
1986
+ }
1987
+
1988
+ .lens-width-btn {
1989
+ display: inline-flex;
1990
+ align-items: center;
1991
+ justify-content: center;
1992
+ min-width: 26px;
1993
+ height: 30px;
1994
+ padding: 0 6px;
1995
+ border: 1px solid transparent;
1996
+ border-radius: 8px;
1997
+ background: transparent;
1998
+ color: #d4d4d8;
1999
+ font-family: var(--lens-font);
2000
+ font-size: 12px;
2001
+ font-weight: 700;
2002
+ cursor: pointer;
2003
+ transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
2004
+ }
2005
+ .lens-width-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
2006
+ .lens-width-btn:focus-visible { outline: 2px solid var(--lens-accent); outline-offset: 1px; }
2007
+ .lens-width-btn.is-active {
2008
+ background: var(--lens-accent);
2009
+ border-color: var(--lens-accent);
2010
+ color: #fff;
2011
+ }
2012
+
2013
+ /* Coarse pointers: bump the tool targets toward 40px. */
2014
+ @media (pointer: coarse) {
2015
+ .lens-tool-btn, .lens-width-btn { width: 38px; height: 38px; }
2016
+ .lens-swatch { width: 30px; height: 30px; }
2017
+ }
2018
+
1143
2019
  /* Stacking inside the shadow layer: the panel, pill and floating chips always
1144
2020
  sit above the annotation layer/rects and picker highlight, so marked areas
1145
2021
  never paint over interactive UI. */
@@ -1265,7 +2141,7 @@ var Overlay = class {
1265
2141
  this.layer = el("div", { class: "lens-layer" });
1266
2142
  this.shadow.append(this.layer);
1267
2143
  this.picker = new ElementPicker(this.layer, this.host);
1268
- this.annotator = new Annotator(this.layer);
2144
+ this.annotator = new AnnotationEditor(this.layer);
1269
2145
  this.layer.append(this.buildPill());
1270
2146
  document.documentElement.append(this.host);
1271
2147
  window.addEventListener("keydown", this.onGlobalKey, true);
@@ -1433,13 +2309,15 @@ var Overlay = class {
1433
2309
  }
1434
2310
  async submit(comment, include) {
1435
2311
  const annotations = this.annotator.list();
1436
- const screenshotCrop = this.computeAnnotationCrop();
2312
+ const composite = this.annotator.getComposite() ?? void 0;
2313
+ const screenshotCrop = this.annotator.getCropBounds();
1437
2314
  const result = await this.deps.client.submitReport({
1438
2315
  comment: comment || void 0,
1439
2316
  annotations,
1440
2317
  target: this.target ?? void 0,
1441
2318
  excludeFromScreenshot: this.host,
1442
2319
  screenshotCrop,
2320
+ annotationComposite: composite,
1443
2321
  include
1444
2322
  });
1445
2323
  this.target = null;
@@ -1448,35 +2326,6 @@ var Overlay = class {
1448
2326
  this.deps.client.discardRecording();
1449
2327
  return result;
1450
2328
  }
1451
- /**
1452
- * Union the marked rects into a single bounding box in DOCUMENT coordinates,
1453
- * or `undefined` if nothing is marked (→ full-page screenshot). Each rect is
1454
- * fixed-positioned, so getBoundingClientRect() gives viewport coords; add the
1455
- * page scroll offset to convert to document space.
1456
- */
1457
- computeAnnotationCrop() {
1458
- const rects = this.annotator.rects();
1459
- if (rects.length === 0) return void 0;
1460
- const scrollX = window.scrollX || window.pageXOffset || 0;
1461
- const scrollY = window.scrollY || window.pageYOffset || 0;
1462
- let minX = Infinity;
1463
- let minY = Infinity;
1464
- let maxX = -Infinity;
1465
- let maxY = -Infinity;
1466
- for (const rect of rects) {
1467
- const r = rect.getBoundingClientRect();
1468
- const left = r.left + scrollX;
1469
- const top = r.top + scrollY;
1470
- minX = Math.min(minX, left);
1471
- minY = Math.min(minY, top);
1472
- maxX = Math.max(maxX, left + r.width);
1473
- maxY = Math.max(maxY, top + r.height);
1474
- }
1475
- const width = maxX - minX;
1476
- const height = maxY - minY;
1477
- if (!(width > 0) || !(height > 0)) return void 0;
1478
- return { x: minX, y: minY, width, height };
1479
- }
1480
2329
  dismiss() {
1481
2330
  this.deps.onDismiss();
1482
2331
  this.destroy();
@@ -1679,4 +2528,4 @@ export {
1679
2528
  boot,
1680
2529
  autoBoot
1681
2530
  };
1682
- //# sourceMappingURL=chunk-CRV65EEY.js.map
2531
+ //# sourceMappingURL=chunk-SDMTZ5Y6.js.map