autumnnote 1.0.4 → 1.0.6

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,541 @@
1
+ // ImageCropOverlay.js - Inline crop overlay for images inside the editor
2
+ //
3
+ // Architecture:
4
+ // - A dark scrim fills the entire viewport (fixed, z=10100) with a transparent
5
+ // cutout showing the current crop region (clip-path technique).
6
+ // - A "crop box" div overlaps the cutout; four corner + four edge handles let
7
+ // the user drag to resize it.
8
+ // - On Confirm: draws the crop region onto an off-screen canvas and replaces
9
+ // img.src with a data-URL. Cross-origin images are caught and a warning is
10
+ // shown instead of crashing.
11
+ // - All pointer listeners are document-level and are torn down after each
12
+ // interaction to avoid leaks.
13
+
14
+ import { on } from '../core/dom.js';
15
+
16
+ // Minimum crop box dimension in CSS pixels
17
+ const MIN_SIZE = 20;
18
+
19
+ // Accent colour for handles (synced with $an-primary)
20
+ const ACCENT = '#3b82f6';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /** Clamp `value` between `lo` and `hi`. */
27
+ const clamp = (value, lo, hi) => Math.min(Math.max(value, lo), hi);
28
+
29
+ /**
30
+ * Attempt to draw `img` onto a canvas even if it was loaded without CORS.
31
+ * Returns a canvas element on success, or null if the image is cross-origin
32
+ * tainted and cannot be read.
33
+ *
34
+ * Strategy:
35
+ * 1. Try directly — works for same-origin and data/blob URLs.
36
+ * 2. Re-load with crossOrigin="anonymous" and retry (needs server ACAO header).
37
+ *
38
+ * @param {HTMLImageElement} img
39
+ * @param {DOMRect} naturalRect - crop region in *natural* image pixels
40
+ * @param {number} renderW - desired output width
41
+ * @param {number} renderH - desired output height
42
+ * @returns {Promise<HTMLCanvasElement|null>}
43
+ */
44
+ function drawCropToCanvas(img, naturalRect, renderW, renderH) {
45
+ return new Promise((resolve) => {
46
+ const tryDraw = (source) => {
47
+ const canvas = document.createElement('canvas');
48
+ canvas.width = Math.round(renderW);
49
+ canvas.height = Math.round(renderH);
50
+ const ctx = canvas.getContext('2d');
51
+ try {
52
+ ctx.drawImage(
53
+ source,
54
+ naturalRect.x, naturalRect.y, naturalRect.width, naturalRect.height,
55
+ 0, 0, canvas.width, canvas.height,
56
+ );
57
+ // Access a pixel — this throws if canvas is tainted
58
+ ctx.getImageData(0, 0, 1, 1);
59
+ resolve(canvas);
60
+ } catch (_) {
61
+ resolve(null);
62
+ }
63
+ };
64
+
65
+ // Fast path — data: / blob: / same-origin
66
+ if (
67
+ img.src.startsWith('data:') ||
68
+ img.src.startsWith('blob:') ||
69
+ img.src.startsWith(location.origin)
70
+ ) {
71
+ tryDraw(img);
72
+ return;
73
+ }
74
+
75
+ // Cross-origin: try a fresh image with crossOrigin="anonymous"
76
+ const tmp = new Image();
77
+ tmp.crossOrigin = 'anonymous';
78
+ tmp.onload = () => tryDraw(tmp);
79
+ tmp.onerror = () => resolve(null);
80
+ // Append cache-buster to avoid serving a cached non-CORS response
81
+ tmp.src = img.src + (img.src.includes('?') ? '&' : '?') + '_an=' + Date.now();
82
+ });
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // ImageCropOverlay
87
+ // ---------------------------------------------------------------------------
88
+
89
+ export class ImageCropOverlay {
90
+ /** @param {import('../Context.js').Context} context */
91
+ constructor(context) {
92
+ this.context = context;
93
+ /** @type {HTMLImageElement|null} */
94
+ this._img = null;
95
+ /** Crop box position in viewport px: { x, y, w, h } */
96
+ this._box = null;
97
+ /** Natural-size bounding rect of the image in viewport px */
98
+ this._imgRect = null;
99
+
100
+ this._scrim = null;
101
+ this._cropBox = null;
102
+ this._handles = {};
103
+ this._toolbar = null;
104
+ this._infoEl = null;
105
+
106
+ this._dragDisposers = null;
107
+ this._disposers = [];
108
+ }
109
+
110
+ initialize() {
111
+ return this;
112
+ }
113
+
114
+ destroy() {
115
+ this._disposers.forEach((d) => d());
116
+ this._disposers = [];
117
+ this._close(false);
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Public API
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Open the crop overlay for the given image.
126
+ * @param {HTMLImageElement} img
127
+ */
128
+ open(img) {
129
+ if (this._scrim) this._close(false); // guard: only one at a time
130
+
131
+ this._img = img;
132
+ const rect = img.getBoundingClientRect();
133
+ this._imgRect = rect;
134
+
135
+ // Default crop box = full image, inset 12 px on each side (feels visual)
136
+ const inset = Math.min(12, rect.width * 0.1, rect.height * 0.1);
137
+ this._box = {
138
+ x: rect.left + inset,
139
+ y: rect.top + inset,
140
+ w: rect.width - inset * 2,
141
+ h: rect.height - inset * 2,
142
+ };
143
+
144
+ this._buildDOM();
145
+ this._updateDOM();
146
+ this._bindEsc();
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // DOM construction
151
+ // ---------------------------------------------------------------------------
152
+
153
+ _buildDOM() {
154
+ /* ---- scrim ---- */
155
+ const scrim = document.createElement('div');
156
+ scrim.className = 'an-crop-scrim';
157
+ // Prevent scroll during crop
158
+ scrim.style.cssText = `
159
+ position:fixed; inset:0; z-index:10100;
160
+ cursor:crosshair;
161
+ background: rgba(0,0,0,0.55);
162
+ `;
163
+ // Click on scrim (outside crop box) → cancel
164
+ this._disposers.push(
165
+ on(scrim, 'mousedown', (e) => {
166
+ if (e.target === scrim) this._close(false);
167
+ }),
168
+ );
169
+
170
+ /* ---- crop box ---- */
171
+ const cropBox = document.createElement('div');
172
+ cropBox.className = 'an-crop-box';
173
+ cropBox.style.cssText = `
174
+ position:fixed; z-index:10101;
175
+ box-sizing:border-box;
176
+ border:2px solid ${ACCENT};
177
+ cursor:move;
178
+ outline: none;
179
+ `;
180
+
181
+ /* ---- rule-of-thirds grid lines ---- */
182
+ const grid = document.createElement('div');
183
+ grid.className = 'an-crop-grid';
184
+ grid.style.cssText = `
185
+ position:absolute; inset:0; pointer-events:none;
186
+ opacity:0.35;
187
+ `;
188
+ // 2 vertical + 2 horizontal lines
189
+ ['33.33%','66.66%'].forEach((pos) => {
190
+ const vl = document.createElement('div');
191
+ vl.style.cssText = `position:absolute;top:0;bottom:0;left:${pos};width:1px;background:#fff;`;
192
+ const hl = document.createElement('div');
193
+ hl.style.cssText = `position:absolute;left:0;right:0;top:${pos};height:1px;background:#fff;`;
194
+ grid.appendChild(vl);
195
+ grid.appendChild(hl);
196
+ });
197
+ cropBox.appendChild(grid);
198
+
199
+ /* ---- resize handles ---- */
200
+ const HANDLE_DEFS = [
201
+ { id: 'nw', cur: 'nw-resize', top: '-5px', left: '-5px' },
202
+ { id: 'n', cur: 'n-resize', top: '-5px', left: 'calc(50% - 5px)' },
203
+ { id: 'ne', cur: 'ne-resize', top: '-5px', right: '-5px' },
204
+ { id: 'e', cur: 'e-resize', top: 'calc(50% - 5px)', right: '-5px' },
205
+ { id: 'se', cur: 'se-resize', bottom: '-5px', right: '-5px' },
206
+ { id: 's', cur: 's-resize', bottom: '-5px', left: 'calc(50% - 5px)' },
207
+ { id: 'sw', cur: 'sw-resize', bottom: '-5px', left: '-5px' },
208
+ { id: 'w', cur: 'w-resize', top: 'calc(50% - 5px)', left: '-5px' },
209
+ ];
210
+
211
+ HANDLE_DEFS.forEach(({ id, cur, ...pos }) => {
212
+ const h = document.createElement('div');
213
+ h.className = `an-crop-handle an-crop-handle-${id}`;
214
+ h.style.cssText = [
215
+ 'position:absolute',
216
+ 'width:10px', 'height:10px',
217
+ `background:${ACCENT}`,
218
+ 'border:2px solid #fff',
219
+ 'border-radius:2px',
220
+ 'box-sizing:border-box',
221
+ `cursor:${cur}`,
222
+ ...Object.entries(pos).map(([k, v]) => `${k}:${v}`),
223
+ ].join(';');
224
+ this._disposers.push(
225
+ on(h, 'mousedown', (e) => {
226
+ e.preventDefault();
227
+ e.stopPropagation();
228
+ this._startHandleDrag(e, id);
229
+ }),
230
+ );
231
+ this._handles[id] = h;
232
+ cropBox.appendChild(h);
233
+ });
234
+
235
+ /* ---- crop move drag ---- */
236
+ this._disposers.push(
237
+ on(cropBox, 'mousedown', (e) => {
238
+ // Only directly on the box surface (not on a handle)
239
+ if (e.target !== cropBox && e.target !== grid && !(e.target.tagName === 'DIV' && !e.target.className.includes('handle'))) {
240
+ // Let handle mousedown handle it
241
+ return;
242
+ }
243
+ if (e.target.className && e.target.className.includes('an-crop-handle')) return;
244
+ e.preventDefault();
245
+ e.stopPropagation();
246
+ this._startBoxMove(e);
247
+ }),
248
+ );
249
+
250
+ /* ---- info label ---- */
251
+ const infoEl = document.createElement('div');
252
+ infoEl.className = 'an-crop-info';
253
+ infoEl.style.cssText = `
254
+ position:absolute; bottom:-28px; left:0;
255
+ font:bold 11px/20px system-ui,sans-serif;
256
+ color:#fff; white-space:nowrap;
257
+ pointer-events:none;
258
+ text-shadow: 0 1px 3px rgba(0,0,0,.6);
259
+ `;
260
+ cropBox.appendChild(infoEl);
261
+ this._infoEl = infoEl;
262
+
263
+ /* ---- floating toolbar ---- */
264
+ const toolbar = document.createElement('div');
265
+ toolbar.className = 'an-crop-toolbar';
266
+ toolbar.style.cssText = `
267
+ position:fixed; z-index:10102;
268
+ background:#1e1e2e; border-radius:6px;
269
+ padding:5px 8px; display:flex; gap:6px;
270
+ align-items:center;
271
+ box-shadow:0 4px 16px rgba(0,0,0,.4);
272
+ font:13px system-ui,sans-serif;
273
+ `;
274
+
275
+ const confirmBtn = this._makeToolbarBtn('✓ Crop', '#22c55e', () => this._confirm());
276
+ const cancelBtn = this._makeToolbarBtn('✕ Cancel', '#94a3b8', () => this._close(false));
277
+
278
+ // Aspect ratio lock checkbox
279
+ const arLabel = document.createElement('label');
280
+ arLabel.style.cssText = 'color:#94a3b8;font-size:11px;cursor:pointer;display:flex;align-items:center;gap:4px;';
281
+ const arCheck = document.createElement('input');
282
+ arCheck.type = 'checkbox';
283
+ arCheck.style.accentColor = ACCENT;
284
+ arLabel.appendChild(arCheck);
285
+ arLabel.appendChild(document.createTextNode('Lock ratio'));
286
+ this._arCheck = arCheck;
287
+
288
+ toolbar.appendChild(confirmBtn);
289
+ toolbar.appendChild(cancelBtn);
290
+ toolbar.appendChild(arLabel);
291
+ this._toolbar = toolbar;
292
+
293
+ // Prevent toolbar clicks from closing the overlay
294
+ this._disposers.push(
295
+ on(toolbar, 'mousedown', (e) => e.stopPropagation()),
296
+ );
297
+
298
+ document.body.appendChild(scrim);
299
+ document.body.appendChild(cropBox);
300
+ document.body.appendChild(toolbar);
301
+
302
+ this._scrim = scrim;
303
+ this._cropBox = cropBox;
304
+ }
305
+
306
+ _makeToolbarBtn(text, color, handler) {
307
+ const btn = document.createElement('button');
308
+ btn.type = 'button';
309
+ btn.textContent = text;
310
+ btn.style.cssText = `
311
+ background:none; border:1px solid ${color}; border-radius:4px;
312
+ color:${color}; padding:3px 10px; cursor:pointer; font-size:12px;
313
+ font-family:inherit;
314
+ transition:background 0.12s;
315
+ `;
316
+ btn.addEventListener('mouseover', () => { btn.style.background = color + '22'; });
317
+ btn.addEventListener('mouseout', () => { btn.style.background = 'none'; });
318
+ this._disposers.push(on(btn, 'click', (e) => { e.stopPropagation(); handler(); }));
319
+ return btn;
320
+ }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // DOM sync
324
+ // ---------------------------------------------------------------------------
325
+
326
+ _updateDOM() {
327
+ const { x, y, w, h } = this._box;
328
+
329
+ this._cropBox.style.left = `${x}px`;
330
+ this._cropBox.style.top = `${y}px`;
331
+ this._cropBox.style.width = `${w}px`;
332
+ this._cropBox.style.height = `${h}px`;
333
+
334
+ // Scrim cutout via clip-path polygon (punches a transparent hole)
335
+ const vw = window.innerWidth;
336
+ const vh = window.innerHeight;
337
+ // Outer rect → inner rectangle (counterclockwise) = hole
338
+ this._scrim.style.clipPath = [
339
+ `polygon(`,
340
+ `0 0, ${vw}px 0, ${vw}px ${vh}px, 0 ${vh}px, 0 0,`,
341
+ `${x}px ${y}px, ${x}px ${y + h}px, ${x + w}px ${y + h}px, ${x + w}px ${y}px, ${x}px ${y}px`,
342
+ `)`,
343
+ ].join('');
344
+
345
+ // Info label: natural pixels
346
+ if (this._img) {
347
+ const nw = this._img.naturalWidth || 0;
348
+ const nh = this._img.naturalHeight || 0;
349
+ const dispW = this._imgRect.width || 1;
350
+ const dispH = this._imgRect.height || 1;
351
+ const scale = { x: nw / dispW, y: nh / dispH };
352
+ const cx = clamp(x - this._imgRect.left, 0, dispW);
353
+ const cy = clamp(y - this._imgRect.top, 0, dispH);
354
+ const cw = clamp(w, 0, dispW - cx);
355
+ const ch = clamp(h, 0, dispH - cy);
356
+ this._infoEl.textContent = `${Math.round(cw * scale.x)} × ${Math.round(ch * scale.y)} px`;
357
+ }
358
+
359
+ // Position toolbar below crop box (or above if near bottom)
360
+ const margin = 8;
361
+ let tbTop = y + h + margin;
362
+ if (tbTop + 40 > window.innerHeight - margin) tbTop = y - 40 - margin;
363
+ this._toolbar.style.left = `${x}px`;
364
+ this._toolbar.style.top = `${tbTop}px`;
365
+ }
366
+
367
+ // ---------------------------------------------------------------------------
368
+ // Drag: move box
369
+ // ---------------------------------------------------------------------------
370
+
371
+ _startBoxMove(e) {
372
+ const startX = e.clientX - this._box.x;
373
+ const startY = e.clientY - this._box.y;
374
+ const r = this._imgRect;
375
+
376
+ const onMove = (me) => {
377
+ this._box.x = clamp(me.clientX - startX, r.left, r.right - this._box.w);
378
+ this._box.y = clamp(me.clientY - startY, r.top, r.bottom - this._box.h);
379
+ this._updateDOM();
380
+ };
381
+
382
+ this._attachDocDrag(onMove);
383
+ }
384
+
385
+ // ---------------------------------------------------------------------------
386
+ // Drag: resize handle
387
+ // ---------------------------------------------------------------------------
388
+
389
+ _startHandleDrag(e, id) {
390
+ const startX = e.clientX;
391
+ const startY = e.clientY;
392
+ const orig = { ...this._box };
393
+ const r = this._imgRect;
394
+ const lockAR = this._arCheck && this._arCheck.checked;
395
+ const aspect = orig.w / (orig.h || 1);
396
+
397
+ const onMove = (me) => {
398
+ const dx = me.clientX - startX;
399
+ const dy = me.clientY - startY;
400
+ let { x, y, w, h } = orig;
401
+
402
+ // Apply deltas per handle position
403
+ if (id.includes('e')) w = Math.max(MIN_SIZE, orig.w + dx);
404
+ if (id.includes('s')) h = Math.max(MIN_SIZE, orig.h + dy);
405
+ if (id.includes('w')) { x = Math.min(orig.x + orig.w - MIN_SIZE, orig.x + dx); w = orig.x + orig.w - x; }
406
+ if (id.includes('n')) { y = Math.min(orig.y + orig.h - MIN_SIZE, orig.y + dy); h = orig.y + orig.h - y; }
407
+
408
+ // Aspect-ratio lock (corner handles)
409
+ if (lockAR && id.length === 2) {
410
+ if (Math.abs(dx) >= Math.abs(dy)) {
411
+ h = w / aspect;
412
+ if (id.includes('n')) y = orig.y + orig.h - h;
413
+ } else {
414
+ w = h * aspect;
415
+ if (id.includes('w')) x = orig.x + orig.w - w;
416
+ }
417
+ }
418
+
419
+ // Clamp to image bounds
420
+ x = clamp(x, r.left, r.right - MIN_SIZE);
421
+ y = clamp(y, r.top, r.bottom - MIN_SIZE);
422
+ w = clamp(w, MIN_SIZE, r.right - x);
423
+ h = clamp(h, MIN_SIZE, r.bottom - y);
424
+
425
+ this._box = { x, y, w, h };
426
+ this._updateDOM();
427
+ };
428
+
429
+ this._attachDocDrag(onMove);
430
+ }
431
+
432
+ /**
433
+ * Attach mousemove + mouseup to document for the duration of a drag.
434
+ * @param {(e: MouseEvent) => void} onMove
435
+ */
436
+ _attachDocDrag(onMove) {
437
+ const cleanup = () => {
438
+ document.removeEventListener('mousemove', onMove);
439
+ document.removeEventListener('mouseup', cleanup);
440
+ document.body.style.userSelect = '';
441
+ document.body.style.cursor = '';
442
+ };
443
+ document.body.style.userSelect = 'none';
444
+ document.addEventListener('mousemove', onMove);
445
+ document.addEventListener('mouseup', cleanup, { once: true });
446
+ }
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Keyboard
450
+ // ---------------------------------------------------------------------------
451
+
452
+ _bindEsc() {
453
+ const handler = (e) => {
454
+ if (e.key === 'Escape') { e.preventDefault(); this._close(false); }
455
+ if (e.key === 'Enter') { e.preventDefault(); this._confirm(); }
456
+ };
457
+ document.addEventListener('keydown', handler);
458
+ this._disposers.push(() => document.removeEventListener('keydown', handler));
459
+ }
460
+
461
+ // ---------------------------------------------------------------------------
462
+ // Confirm / Close
463
+ // ---------------------------------------------------------------------------
464
+
465
+ async _confirm() {
466
+ const img = this._img;
467
+ if (!img) { this._close(false); return; }
468
+
469
+ const r = this._imgRect;
470
+ const { x, y, w, h } = this._box;
471
+
472
+ // Convert display-px crop region → natural-px
473
+ const scaleX = (img.naturalWidth || img.width || 1) / (r.width || 1);
474
+ const scaleY = (img.naturalHeight || img.height || 1) / (r.height || 1);
475
+
476
+ const natX = Math.round(clamp(x - r.left, 0, r.width) * scaleX);
477
+ const natY = Math.round(clamp(y - r.top, 0, r.height) * scaleY);
478
+ const natW = Math.round(clamp(w, 0, r.width - (x - r.left)) * scaleX);
479
+ const natH = Math.round(clamp(h, 0, r.height - (y - r.top)) * scaleY);
480
+
481
+ if (natW <= 0 || natH <= 0) { this._close(false); return; }
482
+
483
+ // Output dimensions = display-pixel crop size (preserve visual size)
484
+ const outW = w;
485
+ const outH = h;
486
+
487
+ const naturalRect = { x: natX, y: natY, width: natW, height: natH };
488
+ const canvas = await drawCropToCanvas(img, naturalRect, outW, outH);
489
+
490
+ if (!canvas) {
491
+ // Cross-origin failure — inform user and abort
492
+ alert(
493
+ 'Cannot crop this image: the image server does not allow cross-origin access.\n' +
494
+ 'Upload the image directly to use the crop tool.',
495
+ );
496
+ this._close(false);
497
+ return;
498
+ }
499
+
500
+ // Determine output format: preserve JPEG, fall back to PNG
501
+ const fmt = img.src.match(/^data:image\/(jpe?g)/i) ? 'image/jpeg' : 'image/png';
502
+ const quality = fmt === 'image/jpeg' ? 0.92 : undefined;
503
+ const newSrc = canvas.toDataURL(fmt, quality);
504
+
505
+ // Apply the crop
506
+ this._close(false);
507
+
508
+ img.src = newSrc;
509
+ // Strip explicit width/height — the image should show at its new natural size
510
+ img.style.width = '';
511
+ img.style.height = '';
512
+ img.removeAttribute('width');
513
+ img.removeAttribute('height');
514
+
515
+ this.context.invoke('editor.afterCommand');
516
+ this.context.invoke('imageResizer.updateOverlay');
517
+ }
518
+
519
+ /**
520
+ * Remove all overlay DOM elements and reset state.
521
+ * @param {boolean} _committed - reserved for future use
522
+ */
523
+ _close(_committed) {
524
+ this._disposers.forEach((d) => d());
525
+ this._disposers = [];
526
+
527
+ [this._scrim, this._cropBox, this._toolbar].forEach((el) => {
528
+ if (el && el.parentNode) el.parentNode.removeChild(el);
529
+ });
530
+
531
+ this._scrim = null;
532
+ this._cropBox = null;
533
+ this._toolbar = null;
534
+ this._infoEl = null;
535
+ this._handles = {};
536
+ this._img = null;
537
+ this._box = null;
538
+ this._imgRect = null;
539
+ this._arCheck = null;
540
+ }
541
+ }
@@ -55,7 +55,7 @@ export class ImageResizer {
55
55
  }),
56
56
  on(document, 'click', (e) => this._onDocClick(e)),
57
57
  on(window, 'scroll', () => this._updateOverlayPosition(), { passive: true }),
58
- on(window, 'resize', onWindowResize),
58
+ on(window, 'resize', onWindowResize, { passive: true }),
59
59
  on(editable, 'scroll', () => this._updateOverlayPosition(), { passive: true }),
60
60
  );
61
61
 
@@ -13,6 +13,7 @@ const ICONS = {
13
13
  caption: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="11" rx="2"/><line x1="6" y1="18" x2="18" y2="18"/><line x1="9" y1="21" x2="15" y2="21"/></svg>`,
14
14
  rotateLeft: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><polyline points="3 3 3 8 8 8"/></svg>`,
15
15
  rotateRight: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><polyline points="21 3 21 8 16 8"/></svg>`,
16
+ crop: `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 2 6 18 22 18"/><polyline points="2 6 18 6 18 22"/></svg>`,
16
17
  };
17
18
 
18
19
  const SHOW_DELAY = 100;
@@ -110,6 +111,11 @@ export class ImageTooltip {
110
111
 
111
112
  el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
112
113
 
114
+ this._cropBtn = this._makeBtn(ICONS.crop, 'Crop Image', () => this._crop());
115
+ el.appendChild(this._cropBtn);
116
+
117
+ el.appendChild(createElement('div', { class: 'an-link-tooltip-sep' }));
118
+
113
119
  this._captionBtn = this._makeBtn(ICONS.caption, 'Add / Edit Caption', () => this._toggleCaption());
114
120
  el.appendChild(this._captionBtn);
115
121
 
@@ -167,7 +173,9 @@ export class ImageTooltip {
167
173
  _scheduleHide() {
168
174
  clearTimeout(this._showTimer);
169
175
  this._showTimer = null;
170
- if (this._hideTimer) return;
176
+ // Always reset the hide timer so rapid mouseout→mouseover sequences
177
+ // don't leave a stale timer that hides the tooltip prematurely.
178
+ clearTimeout(this._hideTimer);
171
179
  this._hideTimer = setTimeout(() => this._hide(), HIDE_DELAY);
172
180
  }
173
181
 
@@ -287,6 +295,15 @@ export class ImageTooltip {
287
295
  this.context.invoke('editor.afterCommand');
288
296
  }
289
297
 
298
+ _crop() {
299
+ const img = this._activeImg;
300
+ if (!img) return;
301
+ // Hide the tooltip while the crop overlay is active
302
+ this._hide();
303
+ // Delegate to imageCropOverlay module
304
+ this.context.invoke('imageCropOverlay.open', img);
305
+ }
306
+
290
307
  _toggleCaption() {
291
308
  const img = this._activeImg;
292
309
  if (!img) return;
@@ -46,7 +46,7 @@ const SHORTCUTS = [
46
46
  {
47
47
  category: 'Editor',
48
48
  items: [
49
- { keys: 'Shift + ?', action: 'Show this keyboard shortcuts dialog' },
49
+ { keys: 'Ctrl + Shift + /', action: 'Show this keyboard shortcuts dialog' },
50
50
  ],
51
51
  },
52
52
  ];