autumnnote 2.1.0 → 2.3.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.
- package/README.md +74 -6
- package/dist/autumnnote.cjs +27 -22
- package/dist/autumnnote.es.js +474 -591
- package/dist/autumnnote.es.js.map +1 -1
- package/dist/autumnnote.min.js +27 -22
- package/dist/autumnnote.umd.js +27 -22
- package/dist/autumnnote.umd.js.map +1 -1
- package/dist/icon-data-V0Xqv-wX.js +255 -0
- package/dist/icon-data-V0Xqv-wX.js.map +1 -0
- package/package.json +12 -3
- package/types/index.d.ts +8 -0
- package/src/js/Context.js +0 -854
- package/src/js/core/detectLang.js +0 -98
- package/src/js/core/dom.js +0 -372
- package/src/js/core/env.js +0 -38
- package/src/js/core/key.js +0 -66
- package/src/js/core/lists.js +0 -121
- package/src/js/core/markdown.js +0 -695
- package/src/js/core/range.js +0 -194
- package/src/js/core/sanitise.js +0 -304
- package/src/js/editing/History.js +0 -266
- package/src/js/editing/Style.js +0 -812
- package/src/js/editing/Table.js +0 -105
- package/src/js/editing/Typing.js +0 -397
- package/src/js/index.js +0 -193
- package/src/js/index.umd.js +0 -17
- package/src/js/module/AutoSaveRestore.js +0 -125
- package/src/js/module/BaseDialog.js +0 -133
- package/src/js/module/BaseMediaTooltip.js +0 -142
- package/src/js/module/BaseResizer.js +0 -322
- package/src/js/module/BubbleToolbar.js +0 -483
- package/src/js/module/Buttons.js +0 -399
- package/src/js/module/Clipboard.js +0 -579
- package/src/js/module/CodeTooltip.js +0 -493
- package/src/js/module/Codeview.js +0 -125
- package/src/js/module/ContextMenu.js +0 -621
- package/src/js/module/Editor.js +0 -747
- package/src/js/module/EmojiDialog.js +0 -254
- package/src/js/module/FindReplace.js +0 -512
- package/src/js/module/Fullscreen.js +0 -80
- package/src/js/module/IconDialog.js +0 -618
- package/src/js/module/ImageCropOverlay.js +0 -586
- package/src/js/module/ImageDialog.js +0 -193
- package/src/js/module/ImageResizer.js +0 -42
- package/src/js/module/ImageTooltip.js +0 -285
- package/src/js/module/LinkDialog.js +0 -145
- package/src/js/module/LinkTooltip.js +0 -250
- package/src/js/module/MarkdownShortcuts.js +0 -250
- package/src/js/module/Mention.js +0 -365
- package/src/js/module/Placeholder.js +0 -51
- package/src/js/module/ShortcutsDialog.js +0 -111
- package/src/js/module/SlashMenu.js +0 -376
- package/src/js/module/Statusbar.js +0 -246
- package/src/js/module/TableTooltip.js +0 -1392
- package/src/js/module/Toolbar.js +0 -855
- package/src/js/module/VideoDialog.js +0 -193
- package/src/js/module/VideoResizer.js +0 -66
- package/src/js/module/VideoTooltip.js +0 -248
- package/src/js/module/emoji-data.js +0 -496
- package/src/js/module/table-grid.js +0 -102
- package/src/js/module/table-icons.js +0 -33
- package/src/js/renderer.js +0 -120
- package/src/js/settings.js +0 -214
- package/src/styles/_variables.scss +0 -48
- package/src/styles/autumnnote.scss +0 -2877
|
@@ -1,586 +0,0 @@
|
|
|
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
|
-
if (!ctx) { resolve(null); return; } // GPU memory limit — let _confirm() handle null
|
|
52
|
-
try {
|
|
53
|
-
ctx.drawImage(
|
|
54
|
-
source,
|
|
55
|
-
naturalRect.x, naturalRect.y, naturalRect.width, naturalRect.height,
|
|
56
|
-
0, 0, canvas.width, canvas.height,
|
|
57
|
-
);
|
|
58
|
-
// Access a pixel — this throws if canvas is tainted
|
|
59
|
-
ctx.getImageData(0, 0, 1, 1);
|
|
60
|
-
resolve(canvas);
|
|
61
|
-
} catch (_) {
|
|
62
|
-
void _; // canvas tainted — resolve with null
|
|
63
|
-
resolve(null);
|
|
64
|
-
}
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
// Fast path — data: / blob: / same-origin
|
|
68
|
-
if (
|
|
69
|
-
img.src.startsWith('data:') ||
|
|
70
|
-
img.src.startsWith('blob:') ||
|
|
71
|
-
img.src.startsWith(globalThis.location.origin)
|
|
72
|
-
) {
|
|
73
|
-
tryDraw(img);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Cross-origin: try a fresh image with crossOrigin="anonymous"
|
|
78
|
-
const tmp = new Image();
|
|
79
|
-
tmp.crossOrigin = 'anonymous';
|
|
80
|
-
tmp.onload = () => tryDraw(tmp);
|
|
81
|
-
tmp.onerror = () => resolve(null);
|
|
82
|
-
// Append cache-buster to avoid serving a cached non-CORS response
|
|
83
|
-
tmp.src = img.src + (img.src.includes('?') ? '&' : '?') + '_an=' + Date.now();
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
|
-
// ImageCropOverlay
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
|
|
91
|
-
export class ImageCropOverlay {
|
|
92
|
-
/** @param {import('../Context.js').Context} context */
|
|
93
|
-
constructor(context) {
|
|
94
|
-
this.context = context;
|
|
95
|
-
/** @type {HTMLImageElement|null} */
|
|
96
|
-
this._img = null;
|
|
97
|
-
/** Crop box position in viewport px: { x, y, w, h } */
|
|
98
|
-
this._box = null;
|
|
99
|
-
/** Natural-size bounding rect of the image in viewport px */
|
|
100
|
-
this._imgRect = null;
|
|
101
|
-
|
|
102
|
-
this._scrim = null;
|
|
103
|
-
this._cropBox = null;
|
|
104
|
-
this._handles = {};
|
|
105
|
-
this._toolbar = null;
|
|
106
|
-
this._infoEl = null;
|
|
107
|
-
|
|
108
|
-
this._dragDisposers = null;
|
|
109
|
-
this._disposers = [];
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
initialize() {
|
|
113
|
-
return this;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
destroy() {
|
|
117
|
-
this._disposers.forEach((d) => d());
|
|
118
|
-
this._disposers = [];
|
|
119
|
-
this._close(false);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// ---------------------------------------------------------------------------
|
|
123
|
-
// Public API
|
|
124
|
-
// ---------------------------------------------------------------------------
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Open the crop overlay for the given image.
|
|
128
|
-
* @param {HTMLImageElement} img
|
|
129
|
-
*/
|
|
130
|
-
open(img) {
|
|
131
|
-
if (this._scrim) this._close(false); // guard: only one at a time
|
|
132
|
-
|
|
133
|
-
this._img = img;
|
|
134
|
-
const rect = img.getBoundingClientRect();
|
|
135
|
-
this._imgRect = rect;
|
|
136
|
-
|
|
137
|
-
// Default crop box = full image, inset 12 px on each side (feels visual)
|
|
138
|
-
const inset = Math.min(12, rect.width * 0.1, rect.height * 0.1);
|
|
139
|
-
this._box = {
|
|
140
|
-
x: rect.left + inset,
|
|
141
|
-
y: rect.top + inset,
|
|
142
|
-
w: rect.width - inset * 2,
|
|
143
|
-
h: rect.height - inset * 2,
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
this._buildDOM();
|
|
147
|
-
this._updateDOM();
|
|
148
|
-
this._bindEsc();
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ---------------------------------------------------------------------------
|
|
152
|
-
// DOM construction
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
|
|
155
|
-
_buildDOM() {
|
|
156
|
-
/* ---- scrim ---- */
|
|
157
|
-
const scrim = document.createElement('div');
|
|
158
|
-
scrim.className = 'an-crop-scrim';
|
|
159
|
-
// Prevent scroll during crop
|
|
160
|
-
scrim.style.cssText = `
|
|
161
|
-
position:fixed; inset:0; z-index:10100;
|
|
162
|
-
cursor:crosshair;
|
|
163
|
-
background: rgba(0,0,0,0.55);
|
|
164
|
-
`;
|
|
165
|
-
// Click on scrim (outside crop box) → cancel
|
|
166
|
-
this._disposers.push(
|
|
167
|
-
on(scrim, 'mousedown', (e) => {
|
|
168
|
-
if (e.target === scrim) this._close(false);
|
|
169
|
-
}),
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
/* ---- crop box ---- */
|
|
173
|
-
const cropBox = document.createElement('div');
|
|
174
|
-
cropBox.className = 'an-crop-box';
|
|
175
|
-
cropBox.style.cssText = `
|
|
176
|
-
position:fixed; z-index:10101;
|
|
177
|
-
box-sizing:border-box;
|
|
178
|
-
border:2px solid ${ACCENT};
|
|
179
|
-
cursor:move;
|
|
180
|
-
outline: none;
|
|
181
|
-
`;
|
|
182
|
-
|
|
183
|
-
/* ---- rule-of-thirds grid lines ---- */
|
|
184
|
-
const grid = document.createElement('div');
|
|
185
|
-
grid.className = 'an-crop-grid';
|
|
186
|
-
grid.style.cssText = `
|
|
187
|
-
position:absolute; inset:0; pointer-events:none;
|
|
188
|
-
opacity:0.35;
|
|
189
|
-
`;
|
|
190
|
-
// 2 vertical + 2 horizontal lines
|
|
191
|
-
['33.33%','66.66%'].forEach((pos) => {
|
|
192
|
-
const vl = document.createElement('div');
|
|
193
|
-
vl.style.cssText = `position:absolute;top:0;bottom:0;left:${pos};width:1px;background:#fff;`;
|
|
194
|
-
const hl = document.createElement('div');
|
|
195
|
-
hl.style.cssText = `position:absolute;left:0;right:0;top:${pos};height:1px;background:#fff;`;
|
|
196
|
-
grid.appendChild(vl);
|
|
197
|
-
grid.appendChild(hl);
|
|
198
|
-
});
|
|
199
|
-
cropBox.appendChild(grid);
|
|
200
|
-
|
|
201
|
-
/* ---- resize handles ---- */
|
|
202
|
-
const HANDLE_DEFS = [
|
|
203
|
-
{ id: 'nw', cur: 'nw-resize', top: '-5px', left: '-5px' },
|
|
204
|
-
{ id: 'n', cur: 'n-resize', top: '-5px', left: 'calc(50% - 5px)' },
|
|
205
|
-
{ id: 'ne', cur: 'ne-resize', top: '-5px', right: '-5px' },
|
|
206
|
-
{ id: 'e', cur: 'e-resize', top: 'calc(50% - 5px)', right: '-5px' },
|
|
207
|
-
{ id: 'se', cur: 'se-resize', bottom: '-5px', right: '-5px' },
|
|
208
|
-
{ id: 's', cur: 's-resize', bottom: '-5px', left: 'calc(50% - 5px)' },
|
|
209
|
-
{ id: 'sw', cur: 'sw-resize', bottom: '-5px', left: '-5px' },
|
|
210
|
-
{ id: 'w', cur: 'w-resize', top: 'calc(50% - 5px)', left: '-5px' },
|
|
211
|
-
];
|
|
212
|
-
|
|
213
|
-
HANDLE_DEFS.forEach(({ id, cur, ...pos }) => {
|
|
214
|
-
const h = document.createElement('div');
|
|
215
|
-
h.className = `an-crop-handle an-crop-handle-${id}`;
|
|
216
|
-
h.style.cssText = [
|
|
217
|
-
'position:absolute',
|
|
218
|
-
'width:10px', 'height:10px',
|
|
219
|
-
`background:${ACCENT}`,
|
|
220
|
-
'border:2px solid #fff',
|
|
221
|
-
'border-radius:2px',
|
|
222
|
-
'box-sizing:border-box',
|
|
223
|
-
`cursor:${cur}`,
|
|
224
|
-
...Object.entries(pos).map(([k, v]) => `${k}:${v}`),
|
|
225
|
-
].join(';');
|
|
226
|
-
this._disposers.push(
|
|
227
|
-
on(h, 'mousedown', (e) => {
|
|
228
|
-
e.preventDefault();
|
|
229
|
-
e.stopPropagation();
|
|
230
|
-
this._startHandleDrag(e, id);
|
|
231
|
-
}),
|
|
232
|
-
on(h, 'touchstart', (e) => {
|
|
233
|
-
e.preventDefault();
|
|
234
|
-
e.stopPropagation();
|
|
235
|
-
const te = /** @type {TouchEvent} */ (e); this._startHandleDrag({ clientX: te.touches[0].clientX, clientY: te.touches[0].clientY }, id);
|
|
236
|
-
}, { passive: false }),
|
|
237
|
-
);
|
|
238
|
-
this._handles[id] = h;
|
|
239
|
-
cropBox.appendChild(h);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
/* ---- crop move drag ---- */
|
|
243
|
-
this._disposers.push(
|
|
244
|
-
on(cropBox, 'mousedown', (e) => {
|
|
245
|
-
if (/** @type {Element} */ (e.target).classList.contains('an-crop-handle')) return;
|
|
246
|
-
if (e.target !== cropBox && e.target !== grid) return;
|
|
247
|
-
e.preventDefault();
|
|
248
|
-
e.stopPropagation();
|
|
249
|
-
this._startBoxMove(e);
|
|
250
|
-
}),
|
|
251
|
-
on(cropBox, 'touchstart', (e) => {
|
|
252
|
-
if (/** @type {Element} */ (e.target).classList.contains('an-crop-handle')) return;
|
|
253
|
-
if (e.target !== cropBox && e.target !== grid) return;
|
|
254
|
-
e.preventDefault();
|
|
255
|
-
e.stopPropagation();
|
|
256
|
-
const te2 = /** @type {TouchEvent} */ (e); this._startBoxMove({ clientX: te2.touches[0].clientX, clientY: te2.touches[0].clientY });
|
|
257
|
-
}, { passive: false }),
|
|
258
|
-
);
|
|
259
|
-
|
|
260
|
-
/* ---- info label ---- */
|
|
261
|
-
const infoEl = document.createElement('div');
|
|
262
|
-
infoEl.className = 'an-crop-info';
|
|
263
|
-
infoEl.style.cssText = `
|
|
264
|
-
position:absolute; bottom:-28px; left:0;
|
|
265
|
-
font:bold 11px/20px system-ui,sans-serif;
|
|
266
|
-
color:#fff; white-space:nowrap;
|
|
267
|
-
pointer-events:none;
|
|
268
|
-
text-shadow: 0 1px 3px rgba(0,0,0,.6);
|
|
269
|
-
`;
|
|
270
|
-
cropBox.appendChild(infoEl);
|
|
271
|
-
this._infoEl = infoEl;
|
|
272
|
-
|
|
273
|
-
/* ---- floating toolbar ---- */
|
|
274
|
-
const toolbar = document.createElement('div');
|
|
275
|
-
toolbar.className = 'an-crop-toolbar';
|
|
276
|
-
toolbar.style.cssText = `
|
|
277
|
-
position:fixed; z-index:10102;
|
|
278
|
-
background:#1e1e2e; border-radius:6px;
|
|
279
|
-
padding:5px 8px; display:flex; gap:6px;
|
|
280
|
-
align-items:center;
|
|
281
|
-
box-shadow:0 4px 16px rgba(0,0,0,.4);
|
|
282
|
-
font:13px system-ui,sans-serif;
|
|
283
|
-
`;
|
|
284
|
-
|
|
285
|
-
const confirmBtn = this._makeToolbarBtn('✓ Crop', '#22c55e', () => this._confirm());
|
|
286
|
-
const cancelBtn = this._makeToolbarBtn('✕ Cancel', '#94a3b8', () => this._close(false));
|
|
287
|
-
|
|
288
|
-
// Aspect ratio lock checkbox
|
|
289
|
-
const arLabel = document.createElement('label');
|
|
290
|
-
arLabel.style.cssText = 'color:#94a3b8;font-size:11px;cursor:pointer;display:flex;align-items:center;gap:4px;';
|
|
291
|
-
const arCheck = document.createElement('input');
|
|
292
|
-
arCheck.type = 'checkbox';
|
|
293
|
-
arCheck.style.accentColor = ACCENT;
|
|
294
|
-
arLabel.appendChild(arCheck);
|
|
295
|
-
arLabel.appendChild(document.createTextNode('Lock ratio'));
|
|
296
|
-
this._arCheck = arCheck;
|
|
297
|
-
|
|
298
|
-
toolbar.appendChild(confirmBtn);
|
|
299
|
-
toolbar.appendChild(cancelBtn);
|
|
300
|
-
toolbar.appendChild(arLabel);
|
|
301
|
-
this._toolbar = toolbar;
|
|
302
|
-
|
|
303
|
-
// Prevent toolbar clicks from closing the overlay
|
|
304
|
-
this._disposers.push(
|
|
305
|
-
on(toolbar, 'mousedown', (e) => e.stopPropagation()),
|
|
306
|
-
);
|
|
307
|
-
|
|
308
|
-
document.body.appendChild(scrim);
|
|
309
|
-
document.body.appendChild(cropBox);
|
|
310
|
-
document.body.appendChild(toolbar);
|
|
311
|
-
|
|
312
|
-
this._scrim = scrim;
|
|
313
|
-
this._cropBox = cropBox;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
_makeToolbarBtn(text, color, handler) {
|
|
317
|
-
const btn = document.createElement('button');
|
|
318
|
-
btn.type = 'button';
|
|
319
|
-
btn.textContent = text;
|
|
320
|
-
btn.style.cssText = `
|
|
321
|
-
background:none; border:1px solid ${color}; border-radius:4px;
|
|
322
|
-
color:${color}; padding:3px 10px; cursor:pointer; font-size:12px;
|
|
323
|
-
font-family:inherit;
|
|
324
|
-
transition:background 0.12s;
|
|
325
|
-
`;
|
|
326
|
-
btn.addEventListener('mouseover', () => { btn.style.background = color + '22'; });
|
|
327
|
-
btn.addEventListener('mouseout', () => { btn.style.background = 'none'; });
|
|
328
|
-
this._disposers.push(on(btn, 'click', (e) => { e.stopPropagation(); handler(); }));
|
|
329
|
-
return btn;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ---------------------------------------------------------------------------
|
|
333
|
-
// DOM sync
|
|
334
|
-
// ---------------------------------------------------------------------------
|
|
335
|
-
|
|
336
|
-
_updateDOM() {
|
|
337
|
-
const { x, y, w, h } = this._box;
|
|
338
|
-
|
|
339
|
-
this._cropBox.style.left = `${x}px`;
|
|
340
|
-
this._cropBox.style.top = `${y}px`;
|
|
341
|
-
this._cropBox.style.width = `${w}px`;
|
|
342
|
-
this._cropBox.style.height = `${h}px`;
|
|
343
|
-
|
|
344
|
-
// Scrim cutout via clip-path polygon (punches a transparent hole)
|
|
345
|
-
const vw = globalThis.innerWidth;
|
|
346
|
-
const vh = globalThis.innerHeight;
|
|
347
|
-
// Outer rect → inner rectangle (counterclockwise) = hole
|
|
348
|
-
this._scrim.style.clipPath = [
|
|
349
|
-
`polygon(`,
|
|
350
|
-
`0 0, ${vw}px 0, ${vw}px ${vh}px, 0 ${vh}px, 0 0,`,
|
|
351
|
-
`${x}px ${y}px, ${x}px ${y + h}px, ${x + w}px ${y + h}px, ${x + w}px ${y}px, ${x}px ${y}px`,
|
|
352
|
-
`)`,
|
|
353
|
-
].join('');
|
|
354
|
-
|
|
355
|
-
// Info label: natural pixels
|
|
356
|
-
if (this._img) {
|
|
357
|
-
const nw = this._img.naturalWidth || 0;
|
|
358
|
-
const nh = this._img.naturalHeight || 0;
|
|
359
|
-
const dispW = this._imgRect.width || 1;
|
|
360
|
-
const dispH = this._imgRect.height || 1;
|
|
361
|
-
const scale = { x: nw / dispW, y: nh / dispH };
|
|
362
|
-
const cx = clamp(x - this._imgRect.left, 0, dispW);
|
|
363
|
-
const cy = clamp(y - this._imgRect.top, 0, dispH);
|
|
364
|
-
const cw = clamp(w, 0, dispW - cx);
|
|
365
|
-
const ch = clamp(h, 0, dispH - cy);
|
|
366
|
-
this._infoEl.textContent = `${Math.round(cw * scale.x)} × ${Math.round(ch * scale.y)} px`;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// Position toolbar below crop box (or above if near bottom)
|
|
370
|
-
const margin = 8;
|
|
371
|
-
let tbTop = y + h + margin;
|
|
372
|
-
if (tbTop + 40 > globalThis.innerHeight - margin) tbTop = y - 40 - margin;
|
|
373
|
-
this._toolbar.style.left = `${x}px`;
|
|
374
|
-
this._toolbar.style.top = `${tbTop}px`;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ---------------------------------------------------------------------------
|
|
378
|
-
// Drag: move box
|
|
379
|
-
// ---------------------------------------------------------------------------
|
|
380
|
-
|
|
381
|
-
_startBoxMove(e) {
|
|
382
|
-
const startX = e.clientX - this._box.x;
|
|
383
|
-
const startY = e.clientY - this._box.y;
|
|
384
|
-
const r = this._imgRect;
|
|
385
|
-
|
|
386
|
-
const onMove = (me) => {
|
|
387
|
-
this._box.x = clamp(me.clientX - startX, r.left, r.right - this._box.w);
|
|
388
|
-
this._box.y = clamp(me.clientY - startY, r.top, r.bottom - this._box.h);
|
|
389
|
-
this._updateDOM();
|
|
390
|
-
};
|
|
391
|
-
|
|
392
|
-
this._attachDocDrag(onMove);
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// ---------------------------------------------------------------------------
|
|
396
|
-
// Drag: resize handle
|
|
397
|
-
// ---------------------------------------------------------------------------
|
|
398
|
-
|
|
399
|
-
_startHandleDrag(e, id) {
|
|
400
|
-
const startX = e.clientX;
|
|
401
|
-
const startY = e.clientY;
|
|
402
|
-
const orig = { ...this._box };
|
|
403
|
-
const r = this._imgRect;
|
|
404
|
-
const lockAR = this._arCheck?.checked;
|
|
405
|
-
const aspect = orig.w / (orig.h || 1);
|
|
406
|
-
|
|
407
|
-
const onMove = (me) => {
|
|
408
|
-
const dx = me.clientX - startX;
|
|
409
|
-
const dy = me.clientY - startY;
|
|
410
|
-
let { x, y, w, h } = orig;
|
|
411
|
-
|
|
412
|
-
// Apply deltas per handle position
|
|
413
|
-
if (id.includes('e')) w = Math.max(MIN_SIZE, orig.w + dx);
|
|
414
|
-
if (id.includes('s')) h = Math.max(MIN_SIZE, orig.h + dy);
|
|
415
|
-
if (id.includes('w')) { x = Math.min(orig.x + orig.w - MIN_SIZE, orig.x + dx); w = orig.x + orig.w - x; }
|
|
416
|
-
if (id.includes('n')) { y = Math.min(orig.y + orig.h - MIN_SIZE, orig.y + dy); h = orig.y + orig.h - y; }
|
|
417
|
-
|
|
418
|
-
// Aspect-ratio lock (corner handles)
|
|
419
|
-
if (lockAR && id.length === 2) {
|
|
420
|
-
if (Math.abs(dx) >= Math.abs(dy)) {
|
|
421
|
-
h = w / aspect;
|
|
422
|
-
if (id.includes('n')) y = orig.y + orig.h - h;
|
|
423
|
-
} else {
|
|
424
|
-
w = h * aspect;
|
|
425
|
-
if (id.includes('w')) x = orig.x + orig.w - w;
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Clamp to image bounds
|
|
430
|
-
x = clamp(x, r.left, r.right - MIN_SIZE);
|
|
431
|
-
y = clamp(y, r.top, r.bottom - MIN_SIZE);
|
|
432
|
-
w = clamp(w, MIN_SIZE, r.right - x);
|
|
433
|
-
h = clamp(h, MIN_SIZE, r.bottom - y);
|
|
434
|
-
|
|
435
|
-
this._box = { x, y, w, h };
|
|
436
|
-
this._updateDOM();
|
|
437
|
-
};
|
|
438
|
-
|
|
439
|
-
this._attachDocDrag(onMove);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/**
|
|
443
|
-
* Attach mousemove + mouseup to document for the duration of a drag.
|
|
444
|
-
* @param {(e: MouseEvent) => void} onMove
|
|
445
|
-
*/
|
|
446
|
-
_attachDocDrag(onMove) {
|
|
447
|
-
this._dragDisposers?.();
|
|
448
|
-
const onTouchMove = (e) => {
|
|
449
|
-
e.preventDefault();
|
|
450
|
-
const te3 = /** @type {TouchEvent} */ (e); onMove(/** @type {MouseEvent} */ (/** @type {unknown} */ ({ clientX: te3.touches[0].clientX, clientY: te3.touches[0].clientY })));
|
|
451
|
-
};
|
|
452
|
-
const cleanup = () => {
|
|
453
|
-
document.removeEventListener('mousemove', onMove);
|
|
454
|
-
document.removeEventListener('mouseup', cleanup);
|
|
455
|
-
document.removeEventListener('touchmove', onTouchMove);
|
|
456
|
-
document.removeEventListener('touchend', cleanup);
|
|
457
|
-
document.body.style.userSelect = '';
|
|
458
|
-
document.body.style.cursor = '';
|
|
459
|
-
this._dragDisposers = null;
|
|
460
|
-
};
|
|
461
|
-
this._dragDisposers = cleanup;
|
|
462
|
-
document.body.style.userSelect = 'none';
|
|
463
|
-
document.addEventListener('mousemove', onMove);
|
|
464
|
-
document.addEventListener('mouseup', cleanup, { once: true });
|
|
465
|
-
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
466
|
-
document.addEventListener('touchend', cleanup, { once: true });
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// ---------------------------------------------------------------------------
|
|
470
|
-
// Keyboard
|
|
471
|
-
// ---------------------------------------------------------------------------
|
|
472
|
-
|
|
473
|
-
_bindEsc() {
|
|
474
|
-
const handler = (e) => {
|
|
475
|
-
if (e.key === 'Escape') { e.preventDefault(); this._close(false); }
|
|
476
|
-
if (e.key === 'Enter') { e.preventDefault(); this._confirm(); }
|
|
477
|
-
};
|
|
478
|
-
document.addEventListener('keydown', handler);
|
|
479
|
-
this._disposers.push(() => document.removeEventListener('keydown', handler));
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
// ---------------------------------------------------------------------------
|
|
483
|
-
// Confirm / Close
|
|
484
|
-
// ---------------------------------------------------------------------------
|
|
485
|
-
|
|
486
|
-
async _confirm() {
|
|
487
|
-
const img = this._img;
|
|
488
|
-
if (!img) { this._close(false); return; }
|
|
489
|
-
|
|
490
|
-
const r = this._imgRect;
|
|
491
|
-
const { x, y, w, h } = this._box;
|
|
492
|
-
|
|
493
|
-
// Convert display-px crop region → natural-px
|
|
494
|
-
const scaleX = (img.naturalWidth || img.width || 1) / (r.width || 1);
|
|
495
|
-
const scaleY = (img.naturalHeight || img.height || 1) / (r.height || 1);
|
|
496
|
-
|
|
497
|
-
const natX = Math.round(clamp(x - r.left, 0, r.width) * scaleX);
|
|
498
|
-
const natY = Math.round(clamp(y - r.top, 0, r.height) * scaleY);
|
|
499
|
-
const natW = Math.round(clamp(w, 0, r.width - (x - r.left)) * scaleX);
|
|
500
|
-
const natH = Math.round(clamp(h, 0, r.height - (y - r.top)) * scaleY);
|
|
501
|
-
|
|
502
|
-
if (natW <= 0 || natH <= 0) { this._close(false); return; }
|
|
503
|
-
|
|
504
|
-
// Output dimensions = display-pixel crop size (preserve visual size)
|
|
505
|
-
const outW = w;
|
|
506
|
-
const outH = h;
|
|
507
|
-
|
|
508
|
-
const naturalRect = /** @type {DOMRect} */ (/** @type {unknown} */ ({ x: natX, y: natY, width: natW, height: natH }));
|
|
509
|
-
const canvas = await drawCropToCanvas(img, naturalRect, outW, outH);
|
|
510
|
-
|
|
511
|
-
if (!canvas) {
|
|
512
|
-
// Cross-origin failure — inform user and abort
|
|
513
|
-
this._showCropError(
|
|
514
|
-
'Cannot crop this image: the image server does not allow cross-origin access. ' +
|
|
515
|
-
'Upload the image directly to use the crop tool.',
|
|
516
|
-
);
|
|
517
|
-
this._close(false);
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Determine output format: preserve JPEG, fall back to PNG
|
|
522
|
-
const fmt = /^data:image\/(jpe?g)/i.exec(img.src) ? 'image/jpeg' : 'image/png';
|
|
523
|
-
const quality = fmt === 'image/jpeg' ? 0.92 : undefined;
|
|
524
|
-
const newSrc = canvas.toDataURL(fmt, quality);
|
|
525
|
-
|
|
526
|
-
// Apply the crop
|
|
527
|
-
this._close(false);
|
|
528
|
-
|
|
529
|
-
img.src = newSrc;
|
|
530
|
-
// Strip explicit width/height — the image should show at its new natural size
|
|
531
|
-
img.style.width = '';
|
|
532
|
-
img.style.height = '';
|
|
533
|
-
img.removeAttribute('width');
|
|
534
|
-
img.removeAttribute('height');
|
|
535
|
-
|
|
536
|
-
this.context.invoke('editor.afterCommand');
|
|
537
|
-
this.context.invoke('imageResizer.updateOverlay');
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Show a non-blocking inline error banner appended to document.body.
|
|
542
|
-
* Auto-dismisses after 4 seconds.
|
|
543
|
-
* @param {string} msg
|
|
544
|
-
*/
|
|
545
|
-
_showCropError(msg) {
|
|
546
|
-
const banner = document.createElement('div');
|
|
547
|
-
banner.setAttribute('role', 'alert');
|
|
548
|
-
banner.style.cssText = [
|
|
549
|
-
'position:fixed', 'bottom:24px', 'left:50%', 'transform:translateX(-50%)',
|
|
550
|
-
'z-index:10200', 'max-width:420px', 'width:max-content',
|
|
551
|
-
'background:#7f1d1d', 'color:#fecaca', 'border:1px solid #b91c1c',
|
|
552
|
-
'border-radius:8px', 'padding:12px 18px',
|
|
553
|
-
'font:13px/1.5 system-ui,sans-serif',
|
|
554
|
-
'box-shadow:0 4px 16px rgba(0,0,0,.4)',
|
|
555
|
-
'pointer-events:auto',
|
|
556
|
-
].join(';');
|
|
557
|
-
banner.textContent = msg;
|
|
558
|
-
document.body.appendChild(banner);
|
|
559
|
-
setTimeout(() => { banner.remove(); }, 4000);
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* Remove all overlay DOM elements and reset state.
|
|
564
|
-
* @param {boolean} _committed - reserved for future use
|
|
565
|
-
*/
|
|
566
|
-
_close(_committed) {
|
|
567
|
-
this._dragDisposers?.();
|
|
568
|
-
this._dragDisposers = null;
|
|
569
|
-
this._disposers.forEach((d) => d());
|
|
570
|
-
this._disposers = [];
|
|
571
|
-
|
|
572
|
-
[this._scrim, this._cropBox, this._toolbar].forEach((el) => {
|
|
573
|
-
el?.remove();
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
this._scrim = null;
|
|
577
|
-
this._cropBox = null;
|
|
578
|
-
this._toolbar = null;
|
|
579
|
-
this._infoEl = null;
|
|
580
|
-
this._handles = {};
|
|
581
|
-
this._img = null;
|
|
582
|
-
this._box = null;
|
|
583
|
-
this._imgRect = null;
|
|
584
|
-
this._arCheck = null;
|
|
585
|
-
}
|
|
586
|
-
}
|