@panphora/clayjs 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,401 @@
1
+ /*! quickcrop v1.0.0 | MIT | https://github.com/panphora/quickcrop
2
+ *
3
+ * const result = await quickcrop(file, { aspect: 1 });
4
+ * // result: { blob, dataURL, width, height } on confirm, null on cancel
5
+ *
6
+ * Uses your modal system (themodal, or any adapter) or brings its own.
7
+ */
8
+
9
+ const CSS = `:root{--qc-surface:#f7f2ea;--qc-surface-hover:#efe7d8;--qc-border:#cdbfa6;--qc-text:#2b241b;--qc-text-hover:#463c2e;--qc-on-dark:#efe7d8;--qc-overlay:rgba(43,36,27,.6);--qc-dim:rgba(0,0,0,.55);--qc-radius:8px;--qc-crop-line:#fff;--qc-font:ui-sans-serif,system-ui,sans-serif;}.qc-css-probe{position:absolute;}.qc-backdrop{position:fixed;inset:0;background:var(--qc-overlay);display:grid;place-items:center;z-index:1000;animation:qc-fade .15s ease;}@keyframes qc-fade{from{opacity:0}}.qc-modal{background:var(--qc-surface);border:1px solid var(--qc-border);border-radius:var(--qc-radius);padding:16px;max-width:92vw;font-family:var(--qc-font);}.qc-bar{display:flex;justify-content:flex-end;gap:10px;margin-top:12px;}.qc-btn{font:inherit;font-family:var(--qc-font);font-size:.85rem;padding:.3rem .75rem;border:1px solid var(--qc-border);border-radius:6px;background:var(--qc-surface);color:var(--qc-text);cursor:pointer;}.qc-btn:hover{background:var(--qc-surface-hover);}.qc-btn-primary{background:var(--qc-text);border-color:var(--qc-text);color:var(--qc-on-dark);font-weight:600;}.qc-btn-primary:hover{background:var(--qc-text-hover);border-color:var(--qc-text-hover);}.qc-stage{position:relative;line-height:0;user-select:none;-webkit-user-select:none;touch-action:none;}.qc-stage img{display:block;max-width:100%;pointer-events:none;}.qc-dim{position:absolute;inset:0;background:var(--qc-dim);pointer-events:none;}.qc-box{position:absolute;cursor:move;outline:1px solid var(--qc-crop-line);background:linear-gradient(var(--qc-crop-line),var(--qc-crop-line)) 0 33.33%/100% 1px no-repeat,linear-gradient(var(--qc-crop-line),var(--qc-crop-line)) 0 66.66%/100% 1px no-repeat,linear-gradient(var(--qc-crop-line),var(--qc-crop-line)) 33.33% 0/1px 100% no-repeat,linear-gradient(var(--qc-crop-line),var(--qc-crop-line)) 66.66% 0/1px 100% no-repeat;background-blend-mode:overlay;}.qc-handle{position:absolute;box-sizing:border-box;width:28px;height:28px;border:7px solid transparent;background:var(--qc-crop-line);background-clip:padding-box;border-radius:9px;}.qc-nw{left:-14px;top:-14px;cursor:nwse-resize;}.qc-ne{right:-14px;top:-14px;cursor:nesw-resize;}.qc-sw{left:-14px;bottom:-14px;cursor:nesw-resize;}.qc-se{right:-14px;bottom:-14px;cursor:nwse-resize;}.qc-mount{line-height:0;}`;
10
+
11
+ const ENCODABLE = ['image/jpeg', 'image/png', 'image/webp'];
12
+
13
+ // iOS Safari silently produces a blank bitmap above ~16.7M canvas pixels
14
+ // (getContext still succeeds, toBlob still returns a blob), so cap the
15
+ // output area and downscale instead.
16
+ const MAX_AREA = 16777216;
17
+
18
+ let active = false;
19
+
20
+ function el(tag, cls = '', text = '') {
21
+ const n = document.createElement(tag);
22
+ if (cls) n.className = cls;
23
+ if (text) n.textContent = text;
24
+ return n;
25
+ }
26
+
27
+ function escapeHtml(str) {
28
+ return String(str).replace(/[&<>"']/g, c => ({
29
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
30
+ }[c]));
31
+ }
32
+
33
+ function ensureStyles() {
34
+ if (document.querySelector('style[data-quickcrop]')) return;
35
+ const probe = el('div', 'qc-css-probe');
36
+ probe.style.display = 'none';
37
+ document.body.append(probe);
38
+ const linked = getComputedStyle(probe).position === 'absolute';
39
+ probe.remove();
40
+ if (linked) return;
41
+ const style = document.createElement('style');
42
+ style.setAttribute('data-quickcrop', '');
43
+ style.setAttribute('save-remove', '');
44
+ style.setAttribute('snapshot-remove', '');
45
+ style.textContent = CSS;
46
+ document.head.append(style);
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Modal adapters
51
+ //
52
+ // An adapter normalizes any modal system to:
53
+ // open({ content, confirmLabel, onConfirm, onCancel }) -> { close() }
54
+ // fit?() -> { width, height } optional: max content size the modal can host
55
+ // Dismissal affordances (Esc, backdrop, a close button) are the adapter's
56
+ // concern. onConfirm/onCancel must only fire from user interaction, never
57
+ // during open().
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const builtinAdapter = {
61
+ open({ content, confirmLabel, onConfirm, onCancel }) {
62
+ ensureStyles();
63
+ const backdrop = el('div', 'qc-backdrop');
64
+ backdrop.setAttribute('save-remove', '');
65
+ backdrop.setAttribute('snapshot-remove', '');
66
+ const modal = el('div', 'qc-modal');
67
+ const bar = el('div', 'qc-bar');
68
+ const confirmBtn = el('button', 'qc-btn qc-btn-primary', confirmLabel);
69
+ confirmBtn.type = 'button';
70
+ bar.append(confirmBtn);
71
+ modal.append(content, bar);
72
+ backdrop.append(modal);
73
+ document.body.append(backdrop);
74
+
75
+ const onKey = e => { if (e.key === 'Escape') onCancel(); };
76
+ document.addEventListener('keydown', onKey);
77
+ confirmBtn.onclick = () => onConfirm();
78
+ backdrop.onclick = e => { if (e.target === backdrop) onCancel(); };
79
+ confirmBtn.focus();
80
+
81
+ return {
82
+ close() {
83
+ document.removeEventListener('keydown', onKey);
84
+ backdrop.remove();
85
+ },
86
+ };
87
+ },
88
+ };
89
+
90
+ function isThemodal(m) {
91
+ return !!m && typeof m === 'object' &&
92
+ typeof m.open === 'function' &&
93
+ typeof m.close === 'function' &&
94
+ typeof m.onYes === 'function' &&
95
+ 'html' in m;
96
+ }
97
+
98
+ const THEMODAL_CLOSE_X = '<svg viewBox="0 0 134 134" xmlns="http://www.w3.org/2000/svg">' +
99
+ '<path class="micromodal__close-bg" d="M132 132.5 1 1.5h131v131Z"/>' +
100
+ '<path d="M83 27l24 24M107 27L83 51" stroke="#fff" stroke-width="6"/></svg>';
101
+
102
+ function wrapThemodal(tm) {
103
+ // an already-open themodal dialog is a singleton collision: opening again
104
+ // would cross-fire that dialog's callbacks, so fall back to the built-in modal
105
+ const busy = () => tm.isShowing && !!document.querySelector('.micromodal-parent');
106
+ return {
107
+ // themodal's container is min(550px, 100vw - 2rem) with a 2px border and
108
+ // 40px/64px side padding; leave room for the button row below the stage
109
+ fit() {
110
+ if (busy()) return null;
111
+ const pad = window.innerWidth >= 640 ? 128 : 80;
112
+ return {
113
+ width: Math.max(120, Math.min(550, window.innerWidth - 32) - 4 - pad),
114
+ height: Math.max(120, window.innerHeight - 220),
115
+ };
116
+ },
117
+ open(opts) {
118
+ if (busy()) return builtinAdapter.open(opts);
119
+ const { content, confirmLabel, onConfirm, onCancel } = opts;
120
+ ensureStyles();
121
+ let done = false; // true once themodal is closing through its own paths
122
+ const finish = fn => {
123
+ if (done) return;
124
+ done = true;
125
+ document.removeEventListener('keydown', onKey, true);
126
+ fn();
127
+ };
128
+ // themodal's Esc goes straight through MicroModal without firing onNo,
129
+ // so intercept it in capture and run the cancel path ourselves
130
+ const onKey = e => {
131
+ if (e.key !== 'Escape') return;
132
+ e.stopPropagation();
133
+ onCancel();
134
+ };
135
+
136
+ tm.html = '<div class="qc-mount"></div>';
137
+ tm.yes = escapeHtml(confirmLabel);
138
+ tm.no = ''; // no cancel button; dismissal is the close X, backdrop, or Esc
139
+ tm.closeHtml = THEMODAL_CLOSE_X;
140
+ tm.disableFocus = true; // themodal would focus the cancel button; focus confirm instead
141
+ tm.onOpen(() => {
142
+ document.querySelector('.micromodal__content .qc-mount')?.append(content);
143
+ document.querySelector('.micromodal-parent .micromodal__yes')?.focus();
144
+ document.addEventListener('keydown', onKey, true);
145
+ });
146
+ tm.onYes(() => { finish(onConfirm); return true; });
147
+ tm.onNo(() => { finish(onCancel); });
148
+ tm.open();
149
+
150
+ return {
151
+ close() {
152
+ if (done) return;
153
+ done = true;
154
+ document.removeEventListener('keydown', onKey, true);
155
+ tm.close();
156
+ },
157
+ };
158
+ },
159
+ };
160
+ }
161
+
162
+ function resolveAdapter(modal) {
163
+ if (modal === 'builtin') return builtinAdapter;
164
+ if (modal && typeof modal === 'object') {
165
+ if (isThemodal(modal)) return wrapThemodal(modal);
166
+ if (typeof modal.open === 'function') return modal;
167
+ throw new TypeError('quickcrop: modal must be "auto", "builtin", a themodal instance, or an adapter with open()');
168
+ }
169
+ if (modal === 'auto') {
170
+ return isThemodal(window.themodal) ? wrapThemodal(window.themodal) : builtinAdapter;
171
+ }
172
+ throw new TypeError('quickcrop: modal must be "auto", "builtin", a themodal instance, or an adapter with open()');
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // quickcrop(file, options) -> Promise<{ blob, dataURL, width, height } | null>
177
+ // ---------------------------------------------------------------------------
178
+
179
+ function quickcrop(file, options = {}) {
180
+ const {
181
+ aspect = null,
182
+ type,
183
+ quality = 0.92,
184
+ maxWidth = null,
185
+ maxHeight = null,
186
+ minSize = 40,
187
+ labels = {},
188
+ modal = 'auto',
189
+ } = options;
190
+ const { confirm: confirmLabel = 'Crop' } = labels;
191
+
192
+ if (!(file instanceof Blob)) {
193
+ return Promise.reject(new TypeError('quickcrop: expected a File or Blob'));
194
+ }
195
+ if (active) {
196
+ return Promise.reject(new Error('quickcrop: already open'));
197
+ }
198
+
199
+ let adapter;
200
+ try {
201
+ adapter = resolveAdapter(modal);
202
+ } catch (err) {
203
+ return Promise.reject(err);
204
+ }
205
+
206
+ active = true;
207
+ const outType = type || (ENCODABLE.includes(file.type) ? file.type : 'image/png');
208
+ const url = URL.createObjectURL(file);
209
+ const img = new Image();
210
+
211
+ return new Promise((resolve, reject) => {
212
+ img.onerror = () => {
213
+ URL.revokeObjectURL(url);
214
+ active = false;
215
+ reject(new Error('quickcrop: could not decode image'));
216
+ };
217
+
218
+ function mount() {
219
+ // Fit the image inside the host for display. The crop math works in
220
+ // these *display* pixels, then scales back up to the source resolution.
221
+ const fit = adapter.fit ? adapter.fit() : null;
222
+ const fitW = Math.max(1, Math.min(fit?.width ?? window.innerWidth * 0.86, img.naturalWidth));
223
+ const fitH = Math.max(1, fit?.height ?? window.innerHeight * 0.72);
224
+ const scale = Math.min(fitW / img.naturalWidth, fitH / img.naturalHeight, 1);
225
+ const dispW = Math.max(1, Math.round(img.naturalWidth * scale));
226
+ const dispH = Math.max(1, Math.round(img.naturalHeight * scale));
227
+ const min = Math.min(minSize, dispW, dispH);
228
+
229
+ ensureStyles();
230
+
231
+ const stage = el('div', 'qc-stage');
232
+ img.style.width = dispW + 'px';
233
+ img.style.height = dispH + 'px';
234
+ const dim = el('div', 'qc-dim');
235
+ const box = el('div', 'qc-box');
236
+ for (const c of ['nw', 'ne', 'sw', 'se']) box.appendChild(el('div', 'qc-handle qc-' + c));
237
+ stage.append(img, dim, box);
238
+
239
+ // initial crop box: locked aspect -> largest centered rect; free -> whole image
240
+ let w, h;
241
+ if (aspect) {
242
+ w = Math.min(dispW, dispH * aspect);
243
+ h = w / aspect;
244
+ if (h > dispH) { h = dispH; w = h * aspect; }
245
+ } else {
246
+ w = dispW;
247
+ h = dispH;
248
+ }
249
+ let x = (dispW - w) / 2;
250
+ let y = (dispH - h) / 2;
251
+ render();
252
+
253
+ function render() {
254
+ box.style.left = x + 'px';
255
+ box.style.top = y + 'px';
256
+ box.style.width = w + 'px';
257
+ box.style.height = h + 'px';
258
+ // dim the image outside the box: one donut polygon. The outer ring winds
259
+ // clockwise and the inner counter-clockwise, so the default nonzero fill
260
+ // rule punches the hole without the (less supported) evenodd keyword.
261
+ dim.style.clipPath = `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 0, ` +
262
+ `${x}px ${y}px, ${x}px ${y + h}px, ${x + w}px ${y + h}px, ${x + w}px ${y}px, ${x}px ${y}px)`;
263
+ }
264
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
265
+
266
+ // one pointer handler drives both moving and resizing
267
+ stage.addEventListener('pointerdown', e => {
268
+ const handle = e.target.closest('.qc-handle');
269
+ const onBox = e.target === box;
270
+ if (!handle && !onBox) return;
271
+ e.preventDefault();
272
+ stage.setPointerCapture(e.pointerId);
273
+
274
+ const start = { px: e.clientX, py: e.clientY, x, y, w, h };
275
+ const corner = handle && [...handle.classList].find(c => /^qc-(nw|ne|sw|se)$/.test(c))?.slice(3);
276
+
277
+ const move = ev => {
278
+ if (!corner) { // --- drag to move ---
279
+ x = clamp(start.x + (ev.clientX - start.px), 0, dispW - w);
280
+ y = clamp(start.y + (ev.clientY - start.py), 0, dispH - h);
281
+ } else { // --- drag to resize ---
282
+ // Anchor = the corner diagonally opposite the one being dragged; it
283
+ // stays pinned while the dragged corner follows the pointer.
284
+ const ax = corner.includes('w') ? start.x + start.w : start.x;
285
+ const ay = corner.includes('n') ? start.y + start.h : start.y;
286
+ const goingE = corner.includes('e'), goingS = corner.includes('s');
287
+ const rect = stage.getBoundingClientRect();
288
+ const dx = Math.abs(ev.clientX - (rect.left + ax));
289
+ const dy = Math.abs(ev.clientY - (rect.top + ay));
290
+
291
+ if (aspect) {
292
+ // room before an image edge, in width units, so the lock holds in both axes
293
+ const roomW = Math.min(goingE ? dispW - ax : ax, (goingS ? dispH - ay : ay) * aspect);
294
+ const minW = Math.min(Math.max(min, min * aspect), roomW);
295
+ w = clamp(Math.max(dx, dy * aspect), minW, roomW);
296
+ h = w / aspect;
297
+ } else {
298
+ w = clamp(dx, min, goingE ? dispW - ax : ax);
299
+ h = clamp(dy, min, goingS ? dispH - ay : ay);
300
+ }
301
+ x = goingE ? ax : ax - w;
302
+ y = goingS ? ay : ay - h;
303
+ }
304
+ render();
305
+ };
306
+ const up = () => {
307
+ stage.removeEventListener('pointermove', move);
308
+ stage.removeEventListener('pointerup', up);
309
+ stage.removeEventListener('pointercancel', up);
310
+ };
311
+ stage.addEventListener('pointermove', move);
312
+ stage.addEventListener('pointerup', up);
313
+ stage.addEventListener('pointercancel', up);
314
+ });
315
+
316
+ function renderCrop() {
317
+ // display -> source scale, per axis: dispW/dispH round independently,
318
+ // so a shared scale would distort or overrun the source on one axis
319
+ const sx = img.naturalWidth / dispW;
320
+ const sy = img.naturalHeight / dispH;
321
+ const outW = Math.round(w * sx);
322
+ const outH = Math.round(h * sy);
323
+ let shrink = 1;
324
+ if (maxWidth && outW > maxWidth) shrink = maxWidth / outW;
325
+ if (maxHeight && outH * shrink > maxHeight) shrink = maxHeight / outH;
326
+ if (outW * shrink * outH * shrink > MAX_AREA) shrink = Math.sqrt(MAX_AREA / (outW * outH));
327
+ const canvas = document.createElement('canvas');
328
+ canvas.width = Math.max(1, Math.round(outW * shrink));
329
+ canvas.height = Math.max(1, Math.round(outH * shrink));
330
+ const ctx = canvas.getContext('2d');
331
+ if (!ctx) throw new Error('quickcrop: crop too large for a canvas');
332
+ if (outType === 'image/jpeg') { // jpeg has no alpha; avoid black fill
333
+ ctx.fillStyle = '#fff';
334
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
335
+ }
336
+ ctx.imageSmoothingQuality = 'high';
337
+ ctx.drawImage(
338
+ img, x * sx, y * sy, w * sx, h * sy, // source rect
339
+ 0, 0, canvas.width, canvas.height // dest rect
340
+ );
341
+ return canvas;
342
+ }
343
+
344
+ let settled = false;
345
+ const finishOnce = () => {
346
+ if (settled) return false;
347
+ settled = true;
348
+ host.close();
349
+ URL.revokeObjectURL(url);
350
+ active = false;
351
+ return true;
352
+ };
353
+ const host = adapter.open({
354
+ content: stage,
355
+ confirmLabel,
356
+ onConfirm() {
357
+ if (settled) return;
358
+ let canvas, dataURL;
359
+ try {
360
+ canvas = renderCrop();
361
+ dataURL = canvas.toDataURL(outType, quality);
362
+ } catch (err) {
363
+ if (finishOnce()) reject(err);
364
+ return;
365
+ }
366
+ if (!finishOnce()) return;
367
+ canvas.toBlob(blob => {
368
+ if (!blob) {
369
+ reject(new Error('quickcrop: image encoding failed'));
370
+ return;
371
+ }
372
+ resolve({ blob, dataURL, width: canvas.width, height: canvas.height });
373
+ }, outType, quality);
374
+ },
375
+ onCancel() {
376
+ if (finishOnce()) resolve(null);
377
+ },
378
+ });
379
+ }
380
+
381
+ // an adapter that throws during fit()/open() must not strand the cropper
382
+ img.onload = () => {
383
+ try {
384
+ mount();
385
+ } catch (err) {
386
+ URL.revokeObjectURL(url);
387
+ active = false;
388
+ reject(err);
389
+ }
390
+ };
391
+ img.src = url;
392
+ });
393
+ }
394
+
395
+ // __QC_EXPORT_START__
396
+ // clayjs assembles window.clay in its loader and writes no auto-export globals,
397
+ // so this copy only exports. The loader attaches it as clay.quickcrop, which is
398
+ // where the vendored hypercms bundle looks for the cropper.
399
+ export { quickcrop };
400
+ // __QC_EXPORT_END__
401
+ export default quickcrop;