blip-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,643 @@
1
+ // Blip - Injectable annotation overlay
2
+ // Add this script to ANY page to enable drawing annotations on top of it.
3
+ // Canvas covers the FULL document so you can scroll and annotate anywhere.
4
+
5
+ (function () {
6
+ if (window.__blip) return;
7
+ window.__blip = true;
8
+
9
+ // Detect the annotation server origin from this script's src URL
10
+ // This is needed because proxy pages set <base> to the original site
11
+ const _dicScript = document.currentScript;
12
+ const _dicOrigin = _dicScript ? new URL(_dicScript.src).origin : '';
13
+
14
+ let active = false;
15
+ let tool = 'pen';
16
+ let color = '#ff3333';
17
+ let lineWidth = 3;
18
+ let isDrawing = false;
19
+ let startX, startY;
20
+
21
+ // Store annotations as objects with document-level coordinates
22
+ let strokes = [];
23
+ let redoStack = [];
24
+
25
+ // --- Create overlay elements ---
26
+
27
+ const overlay = document.createElement('div');
28
+ overlay.id = 'dic-overlay';
29
+ overlay.innerHTML = `
30
+ <canvas id="dic-canvas"></canvas>
31
+ <div id="dic-toolbar">
32
+ <div class="dic-group">
33
+ <button class="dic-btn dic-active" data-tool="pen">Pen</button>
34
+ <button class="dic-btn" data-tool="arrow">Arrow</button>
35
+ <button class="dic-btn" data-tool="circle">Circle</button>
36
+ <button class="dic-btn" data-tool="rect">Rect</button>
37
+ <button class="dic-btn" data-tool="highlight">Highlight</button>
38
+ <button class="dic-btn" data-tool="text">Text</button>
39
+ </div>
40
+ <div class="dic-sep"></div>
41
+ <div class="dic-group">
42
+ <button class="dic-color dic-active" data-color="#ff3333" style="background:#ff3333"></button>
43
+ <button class="dic-color" data-color="#33aaff" style="background:#33aaff"></button>
44
+ <button class="dic-color" data-color="#33ff88" style="background:#33ff88"></button>
45
+ <button class="dic-color" data-color="#ffdd33" style="background:#ffdd33"></button>
46
+ <button class="dic-color" data-color="#ffffff" style="background:#ffffff"></button>
47
+ </div>
48
+ <div class="dic-sep"></div>
49
+ <div class="dic-group">
50
+ <button class="dic-btn dic-size" data-size="2"><span style="width:4px;height:4px"></span></button>
51
+ <button class="dic-btn dic-size dic-active" data-size="4"><span style="width:8px;height:8px"></span></button>
52
+ <button class="dic-btn dic-size" data-size="8"><span style="width:12px;height:12px"></span></button>
53
+ </div>
54
+ <div class="dic-sep"></div>
55
+ <div class="dic-group">
56
+ <button class="dic-btn" id="dic-undo">Undo</button>
57
+ <button class="dic-btn" id="dic-redo">Redo</button>
58
+ <button class="dic-btn" id="dic-clear">Clear</button>
59
+ </div>
60
+ <button class="dic-btn dic-send" id="dic-send">Send to Claude</button>
61
+ <button class="dic-btn dic-cancel" id="dic-cancel">Cancel</button>
62
+ <span id="dic-status"></span>
63
+ </div>
64
+ <input id="dic-text-input" type="text" placeholder="Type and press Enter..." />
65
+ `;
66
+
67
+ const style = document.createElement('style');
68
+ style.textContent = `
69
+ #dic-overlay {
70
+ display: none;
71
+ }
72
+ #dic-overlay.active {
73
+ display: block;
74
+ }
75
+ #dic-canvas {
76
+ position: absolute;
77
+ top: 0;
78
+ left: 0;
79
+ cursor: crosshair;
80
+ z-index: 999998;
81
+ pointer-events: auto;
82
+ }
83
+ #dic-toolbar {
84
+ position: fixed;
85
+ top: 0; left: 0; right: 0;
86
+ display: flex;
87
+ align-items: center;
88
+ gap: 6px;
89
+ padding: 8px 12px;
90
+ background: rgba(15, 15, 25, 0.95);
91
+ backdrop-filter: blur(10px);
92
+ border-bottom: 2px solid #7c5cbf;
93
+ z-index: 1000000;
94
+ flex-wrap: wrap;
95
+ pointer-events: auto;
96
+ }
97
+ .dic-group { display: flex; align-items: center; gap: 4px; }
98
+ .dic-sep { width: 1px; height: 24px; background: #444; margin: 0 4px; }
99
+ .dic-btn {
100
+ background: #1e1e38;
101
+ border: 1px solid #333;
102
+ color: #ccc;
103
+ padding: 5px 10px;
104
+ border-radius: 5px;
105
+ cursor: pointer;
106
+ font-size: 12px;
107
+ font-family: -apple-system, system-ui, sans-serif;
108
+ }
109
+ .dic-btn:hover { background: #2a2a50; border-color: #555; }
110
+ .dic-btn.dic-active { background: #3a2a6a; border-color: #7c5cbf; color: #fff; }
111
+ .dic-color {
112
+ width: 22px; height: 22px;
113
+ border-radius: 50%;
114
+ border: 2px solid #333;
115
+ cursor: pointer;
116
+ padding: 0;
117
+ }
118
+ .dic-color.dic-active { border-color: #fff; box-shadow: 0 0 6px rgba(255,255,255,0.3); }
119
+ .dic-send {
120
+ background: #7c5cbf !important;
121
+ border-color: #9b7dd4 !important;
122
+ color: #fff !important;
123
+ font-weight: 600 !important;
124
+ margin-left: auto !important;
125
+ padding: 6px 16px !important;
126
+ }
127
+ .dic-send:hover { background: #9b7dd4 !important; }
128
+ .dic-cancel {
129
+ background: #333 !important;
130
+ color: #aaa !important;
131
+ }
132
+ .dic-size { width: 28px; height: 28px; padding: 0; display: flex; align-items: center; justify-content: center; }
133
+ .dic-size span { display: block; border-radius: 50%; background: currentColor; }
134
+ #dic-status { font-size: 12px; color: #4ade80; margin-left: 8px; }
135
+ #dic-fab {
136
+ position: fixed;
137
+ bottom: 24px;
138
+ right: 24px;
139
+ width: 56px;
140
+ height: 56px;
141
+ border-radius: 50%;
142
+ background: #7c5cbf;
143
+ border: none;
144
+ color: white;
145
+ font-size: 24px;
146
+ cursor: pointer;
147
+ z-index: 999997;
148
+ box-shadow: 0 4px 20px rgba(124, 92, 191, 0.5);
149
+ display: flex;
150
+ align-items: center;
151
+ justify-content: center;
152
+ transition: all 0.2s;
153
+ font-family: -apple-system, system-ui, sans-serif;
154
+ }
155
+ #dic-fab:hover {
156
+ transform: scale(1.1);
157
+ box-shadow: 0 6px 30px rgba(124, 92, 191, 0.7);
158
+ }
159
+ #dic-fab.hidden { display: none; }
160
+ #dic-text-input {
161
+ display: none;
162
+ position: fixed;
163
+ z-index: 1000001;
164
+ background: rgba(0,0,0,0.85);
165
+ color: #fff;
166
+ border: 2px solid #7c5cbf;
167
+ border-radius: 4px;
168
+ padding: 4px 8px;
169
+ font-size: 16px;
170
+ font-family: -apple-system, system-ui, sans-serif;
171
+ outline: none;
172
+ min-width: 150px;
173
+ pointer-events: auto;
174
+ }
175
+ `;
176
+
177
+ const fab = document.createElement('button');
178
+ fab.id = 'dic-fab';
179
+ fab.title = 'Annotate this page';
180
+ fab.textContent = '\u270F\uFE0F';
181
+
182
+ document.head.appendChild(style);
183
+ document.body.appendChild(overlay);
184
+ document.body.appendChild(fab);
185
+
186
+ const canvas = document.getElementById('dic-canvas');
187
+ const ctx = canvas.getContext('2d');
188
+ const textInput = document.getElementById('dic-text-input');
189
+
190
+ // Get full document dimensions
191
+ function getDocSize() {
192
+ return {
193
+ w: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth, window.innerWidth),
194
+ h: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight, window.innerHeight)
195
+ };
196
+ }
197
+
198
+ function resizeCanvas() {
199
+ const dpr = window.devicePixelRatio;
200
+ const doc = getDocSize();
201
+ canvas.style.width = doc.w + 'px';
202
+ canvas.style.height = doc.h + 'px';
203
+ canvas.width = doc.w * dpr;
204
+ canvas.height = doc.h * dpr;
205
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
206
+ redrawAll();
207
+ }
208
+
209
+ function activate() {
210
+ active = true;
211
+ overlay.classList.add('active');
212
+ fab.classList.add('hidden');
213
+ resizeCanvas();
214
+ }
215
+
216
+ function deactivate() {
217
+ active = false;
218
+ overlay.classList.remove('active');
219
+ fab.classList.remove('hidden');
220
+ strokes = [];
221
+ redoStack = [];
222
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
223
+ }
224
+
225
+ fab.addEventListener('click', activate);
226
+ document.getElementById('dic-cancel').addEventListener('click', deactivate);
227
+
228
+ // --- Redraw all strokes ---
229
+ // Strokes are in document coordinates, canvas is also document-sized,
230
+ // so no offset needed for drawing -- they map 1:1.
231
+
232
+ function redrawAll() {
233
+ const doc = getDocSize();
234
+ ctx.clearRect(0, 0, doc.w, doc.h);
235
+ for (const s of strokes) {
236
+ drawStrokeOn(ctx, s, 0, 0);
237
+ }
238
+ }
239
+
240
+ // --- Undo / Redo / Clear ---
241
+
242
+ function undo() {
243
+ if (strokes.length === 0) return;
244
+ redoStack.push(strokes.pop());
245
+ redrawAll();
246
+ }
247
+
248
+ function redo() {
249
+ if (redoStack.length === 0) return;
250
+ strokes.push(redoStack.pop());
251
+ redrawAll();
252
+ }
253
+
254
+ document.getElementById('dic-undo').addEventListener('click', undo);
255
+ document.getElementById('dic-redo').addEventListener('click', redo);
256
+ document.getElementById('dic-clear').addEventListener('click', () => {
257
+ strokes = [];
258
+ redoStack = [];
259
+ redrawAll();
260
+ });
261
+
262
+ // --- Drawing ---
263
+
264
+ // Mouse/touch position to document coordinates
265
+ function toDoc(e) {
266
+ return { x: e.clientX + window.scrollX, y: e.clientY + window.scrollY };
267
+ }
268
+
269
+ let currentStroke = null;
270
+
271
+ canvas.addEventListener('mousedown', (e) => {
272
+ if (!active) return;
273
+ const doc = toDoc(e);
274
+
275
+ if (tool === 'text') {
276
+ textInput.style.display = 'block';
277
+ textInput.style.left = e.clientX + 'px';
278
+ textInput.style.top = e.clientY + 'px';
279
+ textInput.style.color = color;
280
+ textInput.value = '';
281
+ textInput.dataset.docX = doc.x;
282
+ textInput.dataset.docY = doc.y;
283
+ textInput.focus();
284
+ return;
285
+ }
286
+
287
+ isDrawing = true;
288
+ startX = doc.x;
289
+ startY = doc.y;
290
+
291
+ if (tool === 'pen') {
292
+ currentStroke = { type: 'pen', color, lineWidth, points: [{ x: doc.x, y: doc.y }] };
293
+ }
294
+ });
295
+
296
+ canvas.addEventListener('mousemove', (e) => {
297
+ if (!isDrawing || !active) return;
298
+ const doc = toDoc(e);
299
+
300
+ if (tool === 'pen' && currentStroke) {
301
+ currentStroke.points.push({ x: doc.x, y: doc.y });
302
+ redrawAll();
303
+ drawStrokeOn(ctx, currentStroke, 0, 0);
304
+ } else {
305
+ const preview = makeShape(doc);
306
+ if (preview) {
307
+ redrawAll();
308
+ drawStrokeOn(ctx, preview, 0, 0);
309
+ }
310
+ }
311
+ });
312
+
313
+ function makeShape(doc) {
314
+ if (tool === 'arrow') return { type: 'arrow', color, lineWidth, x1: startX, y1: startY, x2: doc.x, y2: doc.y };
315
+ if (tool === 'circle') return { type: 'circle', color, lineWidth, x1: startX, y1: startY, x2: doc.x, y2: doc.y };
316
+ if (tool === 'rect') return { type: 'rect', color, lineWidth, x1: startX, y1: startY, x2: doc.x, y2: doc.y };
317
+ if (tool === 'highlight') return { type: 'highlight', color, lineWidth, x1: startX, y1: startY, x2: doc.x, y2: doc.y };
318
+ return null;
319
+ }
320
+
321
+ canvas.addEventListener('mouseup', (e) => {
322
+ if (!isDrawing) return;
323
+ isDrawing = false;
324
+ const doc = toDoc(e);
325
+
326
+ if (tool === 'pen' && currentStroke) {
327
+ strokes.push(currentStroke);
328
+ currentStroke = null;
329
+ } else {
330
+ const shape = makeShape(doc);
331
+ if (shape) strokes.push(shape);
332
+ }
333
+ redoStack = [];
334
+ redrawAll();
335
+ });
336
+
337
+ canvas.addEventListener('mouseleave', (e) => {
338
+ if (!isDrawing) return;
339
+ isDrawing = false;
340
+ const doc = toDoc(e);
341
+ if (tool === 'pen' && currentStroke) {
342
+ strokes.push(currentStroke);
343
+ currentStroke = null;
344
+ } else {
345
+ const shape = makeShape(doc);
346
+ if (shape) strokes.push(shape);
347
+ }
348
+ redoStack = [];
349
+ redrawAll();
350
+ });
351
+
352
+ // --- Text input handler ---
353
+
354
+ textInput.addEventListener('keydown', (e) => {
355
+ if (e.key === 'Enter') {
356
+ e.preventDefault();
357
+ const text = textInput.value.trim();
358
+ if (text) {
359
+ strokes.push({
360
+ type: 'text', color, lineWidth,
361
+ x: parseFloat(textInput.dataset.docX),
362
+ y: parseFloat(textInput.dataset.docY),
363
+ text
364
+ });
365
+ redoStack = [];
366
+ redrawAll();
367
+ }
368
+ textInput.style.display = 'none';
369
+ }
370
+ if (e.key === 'Escape') {
371
+ textInput.style.display = 'none';
372
+ }
373
+ e.stopPropagation();
374
+ });
375
+
376
+ // --- Toolbar ---
377
+
378
+ overlay.querySelectorAll('[data-tool]').forEach(btn => {
379
+ btn.addEventListener('click', () => {
380
+ overlay.querySelectorAll('[data-tool]').forEach(b => b.classList.remove('dic-active'));
381
+ btn.classList.add('dic-active');
382
+ tool = btn.dataset.tool;
383
+ });
384
+ });
385
+
386
+ overlay.querySelectorAll('[data-color]').forEach(btn => {
387
+ btn.addEventListener('click', () => {
388
+ overlay.querySelectorAll('[data-color]').forEach(b => b.classList.remove('dic-active'));
389
+ btn.classList.add('dic-active');
390
+ color = btn.dataset.color;
391
+ });
392
+ });
393
+
394
+ overlay.querySelectorAll('[data-size]').forEach(btn => {
395
+ btn.addEventListener('click', () => {
396
+ overlay.querySelectorAll('[data-size]').forEach(b => b.classList.remove('dic-active'));
397
+ btn.classList.add('dic-active');
398
+ lineWidth = parseInt(btn.dataset.size);
399
+ });
400
+ });
401
+
402
+ // --- Send: capture full page screenshot + overlay annotations ---
403
+
404
+ // Load html2canvas for page capture
405
+ function loadHtml2Canvas() {
406
+ return new Promise((resolve, reject) => {
407
+ if (window.html2canvas) return resolve(window.html2canvas);
408
+ const script = document.createElement('script');
409
+ script.src = _dicOrigin + '/html2canvas.min.js';
410
+ script.onload = () => resolve(window.html2canvas);
411
+ script.onerror = () => reject(new Error('Failed to load html2canvas'));
412
+ document.head.appendChild(script);
413
+ });
414
+ }
415
+
416
+ // Fix vh units before html2canvas capture to prevent stretching.
417
+ // html2canvas recalculates 100vh relative to the full document height,
418
+ // which causes elements like .hero { min-height: 100vh } to stretch.
419
+ function freezeVhUnits() {
420
+ const viewportH = window.innerHeight;
421
+ const style = document.createElement('style');
422
+ style.id = 'dic-vh-fix';
423
+ style.textContent = `*, *::before, *::after { --dic-real-vh: ${viewportH}px !important; }`;
424
+ // Replace all vh usages in computed styles by setting explicit pixel heights
425
+ const vhElements = [];
426
+ document.querySelectorAll('*').forEach(el => {
427
+ const cs = getComputedStyle(el);
428
+ // Check inline/stylesheet for vh usage
429
+ const raw = el.getAttribute('style') || '';
430
+ const sheets = [...document.styleSheets];
431
+ let usesVh = raw.includes('vh');
432
+ if (!usesVh) {
433
+ // Check if min-height or height is viewport-relative by comparing to viewport
434
+ const mh = parseFloat(cs.minHeight);
435
+ const h = parseFloat(cs.height);
436
+ if (mh === viewportH || Math.abs(mh - viewportH) < 2) usesVh = true;
437
+ if (h === viewportH || Math.abs(h - viewportH) < 2) usesVh = true;
438
+ }
439
+ if (usesVh) {
440
+ vhElements.push({
441
+ el,
442
+ origMinHeight: el.style.minHeight,
443
+ origHeight: el.style.height,
444
+ });
445
+ if (parseFloat(cs.minHeight) >= viewportH - 2) {
446
+ el.style.minHeight = viewportH + 'px';
447
+ }
448
+ if (parseFloat(cs.height) >= viewportH - 2 && cs.height !== 'auto') {
449
+ el.style.height = viewportH + 'px';
450
+ }
451
+ }
452
+ });
453
+ return function restore() {
454
+ vhElements.forEach(({ el, origMinHeight, origHeight }) => {
455
+ el.style.minHeight = origMinHeight;
456
+ el.style.height = origHeight;
457
+ });
458
+ };
459
+ }
460
+
461
+ document.getElementById('dic-send').addEventListener('click', async () => {
462
+ const status = document.getElementById('dic-status');
463
+ status.textContent = 'Capturing page...';
464
+
465
+ // Use CSS pixel dimensions for html2canvas (it works in CSS pixels)
466
+ const cssW = document.documentElement.scrollWidth;
467
+ const cssH = document.documentElement.scrollHeight;
468
+
469
+ try {
470
+ // Step 1: Hide overlay UI so it doesn't appear in the screenshot
471
+ overlay.style.display = 'none';
472
+ canvas.style.display = 'none';
473
+
474
+ // Step 2: Freeze vh units to prevent stretching, then capture
475
+ const restoreVh = freezeVhUnits();
476
+ let pageBg;
477
+ try {
478
+ const h2c = await loadHtml2Canvas();
479
+ pageBg = await h2c(document.documentElement, {
480
+ width: cssW,
481
+ height: cssH,
482
+ windowWidth: cssW,
483
+ windowHeight: window.innerHeight,
484
+ scrollX: 0,
485
+ scrollY: 0,
486
+ scale: 1,
487
+ useCORS: true,
488
+ allowTaint: true,
489
+ logging: false,
490
+ });
491
+ } finally {
492
+ restoreVh();
493
+ overlay.style.display = '';
494
+ canvas.style.display = '';
495
+ }
496
+
497
+ status.textContent = 'Compositing annotations...';
498
+
499
+ // Step 3: Composite page background + annotations in CSS pixels
500
+ const comp = document.createElement('canvas');
501
+ comp.width = cssW;
502
+ comp.height = cssH;
503
+ const compCtx = comp.getContext('2d');
504
+
505
+ // Draw page background first
506
+ compCtx.drawImage(pageBg, 0, 0);
507
+
508
+ // Draw annotations on top (strokes are in CSS coords)
509
+ for (const s of strokes) {
510
+ drawStrokeOn(compCtx, s, 0, 0);
511
+ }
512
+
513
+ const dataUrl = comp.toDataURL('image/png');
514
+ const base64 = dataUrl.split(',')[1];
515
+
516
+ // Also serialize stroke data so Claude can understand what was annotated
517
+ const strokeData = strokes.map(s => {
518
+ const base = { type: s.type, color: s.color };
519
+ if (s.type === 'pen') return { ...base, bounds: getPenBounds(s.points) };
520
+ if (s.type === 'text') return { ...base, x: s.x, y: s.y, text: s.text };
521
+ return { ...base, x1: s.x1, y1: s.y1, x2: s.x2, y2: s.y2 };
522
+ });
523
+
524
+ const res = await fetch(_dicOrigin + '/api/save-base64', {
525
+ method: 'POST',
526
+ headers: { 'Content-Type': 'application/json' },
527
+ body: JSON.stringify({
528
+ image: base64,
529
+ page: window.location.pathname,
530
+ scrollY: window.scrollY,
531
+ documentSize: { width: cssW, height: cssH },
532
+ viewport: { width: window.innerWidth, height: window.innerHeight },
533
+ strokeCount: strokes.length,
534
+ strokes: strokeData
535
+ })
536
+ });
537
+ const data = await res.json();
538
+ if (data.success) {
539
+ status.textContent = '\u2705 Image copied! Cmd+V in Claude to attach.';
540
+ status.style.color = '#4ade80';
541
+ setTimeout(() => { status.textContent = ''; deactivate(); }, 5000);
542
+ } else {
543
+ status.textContent = 'Error: ' + (data.error || 'unknown');
544
+ status.style.color = '#ff4444';
545
+ }
546
+ } catch (err) {
547
+ status.textContent = 'Error: ' + (err.message || 'capture failed');
548
+ status.style.color = '#ff4444';
549
+ }
550
+ });
551
+
552
+ function getPenBounds(points) {
553
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
554
+ for (const p of points) {
555
+ if (p.x < minX) minX = p.x;
556
+ if (p.y < minY) minY = p.y;
557
+ if (p.x > maxX) maxX = p.x;
558
+ if (p.y > maxY) maxY = p.y;
559
+ }
560
+ return { x1: minX, y1: minY, x2: maxX, y2: maxY };
561
+ }
562
+
563
+ // --- Draw a stroke on any canvas context ---
564
+
565
+ function drawStrokeOn(c, s, ox, oy) {
566
+ c.save();
567
+ c.strokeStyle = s.color;
568
+ c.fillStyle = s.color;
569
+ c.lineWidth = s.lineWidth;
570
+ c.lineCap = 'round';
571
+ c.lineJoin = 'round';
572
+
573
+ switch (s.type) {
574
+ case 'pen': {
575
+ if (s.points.length < 2) break;
576
+ c.beginPath();
577
+ c.moveTo(s.points[0].x + ox, s.points[0].y + oy);
578
+ for (let i = 1; i < s.points.length; i++) {
579
+ c.lineTo(s.points[i].x + ox, s.points[i].y + oy);
580
+ }
581
+ c.stroke();
582
+ break;
583
+ }
584
+ case 'arrow': {
585
+ const x1 = s.x1 + ox, y1 = s.y1 + oy, x2 = s.x2 + ox, y2 = s.y2 + oy;
586
+ const headLen = Math.max(15, s.lineWidth * 4);
587
+ const angle = Math.atan2(y2 - y1, x2 - x1);
588
+ c.beginPath(); c.moveTo(x1, y1); c.lineTo(x2, y2); c.stroke();
589
+ c.beginPath();
590
+ c.moveTo(x2, y2);
591
+ c.lineTo(x2 - headLen * Math.cos(angle - Math.PI / 6), y2 - headLen * Math.sin(angle - Math.PI / 6));
592
+ c.moveTo(x2, y2);
593
+ c.lineTo(x2 - headLen * Math.cos(angle + Math.PI / 6), y2 - headLen * Math.sin(angle + Math.PI / 6));
594
+ c.stroke();
595
+ break;
596
+ }
597
+ case 'circle': {
598
+ const cx = (s.x1 + s.x2) / 2 + ox, cy = (s.y1 + s.y2) / 2 + oy;
599
+ const rx = Math.abs(s.x2 - s.x1) / 2, ry = Math.abs(s.y2 - s.y1) / 2;
600
+ if (rx > 0 && ry > 0) { c.beginPath(); c.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2); c.stroke(); }
601
+ break;
602
+ }
603
+ case 'rect': {
604
+ c.strokeRect(s.x1 + ox, s.y1 + oy, s.x2 - s.x1, s.y2 - s.y1);
605
+ break;
606
+ }
607
+ case 'highlight': {
608
+ c.fillStyle = s.color + '40';
609
+ c.fillRect(s.x1 + ox, s.y1 + oy, s.x2 - s.x1, s.y2 - s.y1);
610
+ break;
611
+ }
612
+ case 'text': {
613
+ c.font = `${Math.max(16, s.lineWidth * 6)}px -apple-system, system-ui, sans-serif`;
614
+ c.fillText(s.text, s.x + ox, s.y + oy);
615
+ break;
616
+ }
617
+ }
618
+ c.restore();
619
+ }
620
+
621
+ // --- Keyboard ---
622
+
623
+ document.addEventListener('keydown', (e) => {
624
+ if (!active) return;
625
+ if (textInput.style.display !== 'none') return;
626
+ if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
627
+ e.preventDefault();
628
+ if (e.shiftKey) redo();
629
+ else undo();
630
+ }
631
+ if (e.key === 'Escape') deactivate();
632
+ });
633
+
634
+ // Resize canvas if page content changes size
635
+ const resizeObserver = new ResizeObserver(() => {
636
+ if (active) resizeCanvas();
637
+ });
638
+ resizeObserver.observe(document.body);
639
+
640
+ window.addEventListener('resize', () => {
641
+ if (active) resizeCanvas();
642
+ });
643
+ })();