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.
- package/README.md +2 -2
- package/dist/autumnnote.css +1641 -1
- package/dist/autumnnote.es.js +11545 -6224
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.umd.js +11235 -73
- package/dist/autumnnote.umd.js.map +1 -1
- package/package.json +14 -7
- package/src/js/Context.js +9 -8
- package/src/js/editing/Style.js +45 -48
- package/src/js/editing/Typing.js +28 -3
- package/src/js/index.js +1 -1
- package/src/js/module/Clipboard.js +17 -20
- package/src/js/module/CodeTooltip.js +9 -6
- package/src/js/module/FindReplace.js +21 -7
- package/src/js/module/ImageCropOverlay.js +541 -0
- package/src/js/module/ImageResizer.js +1 -1
- package/src/js/module/ImageTooltip.js +18 -1
- package/src/js/module/ShortcutsDialog.js +1 -1
- package/src/js/module/TableTooltip.js +56 -25
- package/src/js/module/Toolbar.js +108 -97
- package/src/styles/autumnnote.scss +49 -0
- package/types/index.d.ts +56 -9
|
@@ -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
|
-
|
|
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;
|