@retouchjs/core 0.0.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.
package/dist/index.cjs ADDED
@@ -0,0 +1,1873 @@
1
+ 'use strict';
2
+
3
+ // src/constants.ts
4
+ var VERSION = "0.0.1";
5
+ var ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
6
+ var DEFAULT_CROP = { x: 0, y: 0, width: 1, height: 1 };
7
+ var DEFAULT_ADJUSTMENTS = {
8
+ brightness: 100,
9
+ contrast: 100,
10
+ saturation: 100
11
+ };
12
+ var DEFAULT_EDITS = {
13
+ crop: { ...DEFAULT_CROP },
14
+ rotation: 0,
15
+ adjustments: { ...DEFAULT_ADJUSTMENTS }
16
+ };
17
+ var ASPECT_RATIOS = {
18
+ free: null,
19
+ "16:9": 16 / 9,
20
+ "4:3": 4 / 3,
21
+ "1:1": 1,
22
+ "3:2": 3 / 2,
23
+ "9:16": 9 / 16
24
+ };
25
+
26
+ // src/event-emitter.ts
27
+ var EventEmitter = class {
28
+ listeners = /* @__PURE__ */ new Map();
29
+ on(event, fn) {
30
+ if (!this.listeners.has(event)) {
31
+ this.listeners.set(event, /* @__PURE__ */ new Set());
32
+ }
33
+ const set = this.listeners.get(event);
34
+ if (set) {
35
+ set.add(fn);
36
+ }
37
+ return () => this.listeners.get(event)?.delete(fn);
38
+ }
39
+ off(event, fn) {
40
+ this.listeners.get(event)?.delete(fn);
41
+ }
42
+ emit(event, data) {
43
+ for (const fn of this.listeners.get(event) ?? []) {
44
+ fn(data);
45
+ }
46
+ }
47
+ removeAll() {
48
+ this.listeners.clear();
49
+ }
50
+ };
51
+
52
+ // src/state-machine.ts
53
+ var StateMachine = class {
54
+ current;
55
+ transitions;
56
+ emitter = new EventEmitter();
57
+ constructor(initial, transitions) {
58
+ this.current = initial;
59
+ this.transitions = transitions;
60
+ }
61
+ get state() {
62
+ return this.current;
63
+ }
64
+ canTransition(to) {
65
+ return (this.transitions[this.current] ?? []).includes(to);
66
+ }
67
+ transition(to) {
68
+ if (!this.canTransition(to)) {
69
+ throw new Error(`[Retouch] Invalid state transition: ${this.current} \u2192 ${to}`);
70
+ }
71
+ const from = this.current;
72
+ this.current = to;
73
+ this.emitter.emit("change", { from, to });
74
+ }
75
+ onChange(fn) {
76
+ return this.emitter.on("change", fn);
77
+ }
78
+ destroy() {
79
+ this.emitter.removeAll();
80
+ }
81
+ };
82
+
83
+ // src/styles.ts
84
+ var STYLE_ID = "rt-styles";
85
+ var CSS = (
86
+ /* css */
87
+ `
88
+ .rt-root {
89
+ --rt-bg: #F7F5F2;
90
+ --rt-bg-elevated: #FFFFFF;
91
+ --rt-bg-subtle: #EEEAE5;
92
+ --rt-border: #DDD8D0;
93
+ --rt-border-strong: #C5BFB5;
94
+ --rt-text: #1A1815;
95
+ --rt-text-secondary: #6B6560;
96
+ --rt-text-tertiary: #9E9890;
97
+ --rt-accent: #D4572A;
98
+ --rt-accent-hover: #BF4D24;
99
+ --rt-accent-soft: rgba(212, 87, 42, 0.08);
100
+ --rt-accent-glow: rgba(212, 87, 42, 0.15);
101
+ --rt-shadow-sm: 0 1px 3px rgba(26,24,21,0.06);
102
+ --rt-shadow-md: 0 4px 16px rgba(26,24,21,0.08);
103
+ --rt-shadow-lg: 0 12px 48px rgba(26,24,21,0.12);
104
+ --rt-radius-sm: 6px;
105
+ --rt-radius-md: 10px;
106
+ --rt-radius-lg: 16px;
107
+ --rt-radius-xl: 24px;
108
+ --rt-transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
109
+
110
+ font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
111
+ color: var(--rt-text);
112
+ line-height: 1.5;
113
+ -webkit-font-smoothing: antialiased;
114
+ position: relative;
115
+ width: 100%;
116
+ }
117
+
118
+ .rt-root *, .rt-root *::before, .rt-root *::after {
119
+ box-sizing: border-box;
120
+ margin: 0;
121
+ padding: 0;
122
+ }
123
+
124
+ /* \u2500\u2500 Drop Zone \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
125
+
126
+ .rt-dropzone-wrapper {
127
+ background: var(--rt-bg-elevated);
128
+ border-radius: var(--rt-radius-xl);
129
+ padding: 48px;
130
+ box-shadow: var(--rt-shadow-md);
131
+ }
132
+
133
+ .rt-dropzone {
134
+ border: 2px dashed var(--rt-border-strong);
135
+ border-radius: var(--rt-radius-lg);
136
+ padding: 80px 40px;
137
+ text-align: center;
138
+ cursor: pointer;
139
+ transition: var(--rt-transition);
140
+ position: relative;
141
+ overflow: hidden;
142
+ }
143
+
144
+ .rt-dropzone::before {
145
+ content: '';
146
+ position: absolute;
147
+ inset: 0;
148
+ background: var(--rt-accent-soft);
149
+ opacity: 0;
150
+ transition: var(--rt-transition);
151
+ }
152
+
153
+ .rt-dropzone:hover {
154
+ border-color: var(--rt-accent);
155
+ }
156
+
157
+ .rt-dropzone:hover::before {
158
+ opacity: 1;
159
+ }
160
+
161
+ .rt-dropzone--active {
162
+ border-color: var(--rt-accent);
163
+ border-style: solid;
164
+ background: var(--rt-accent-soft);
165
+ }
166
+
167
+ .rt-dropzone--active .rt-dropzone__icon {
168
+ background: var(--rt-accent);
169
+ transform: scale(1.1);
170
+ }
171
+
172
+ .rt-dropzone--active .rt-dropzone__icon svg {
173
+ color: white;
174
+ }
175
+
176
+ .rt-dropzone__icon {
177
+ width: 56px;
178
+ height: 56px;
179
+ border-radius: 50%;
180
+ background: var(--rt-bg-subtle);
181
+ display: flex;
182
+ align-items: center;
183
+ justify-content: center;
184
+ margin: 0 auto 20px;
185
+ transition: var(--rt-transition);
186
+ }
187
+
188
+ .rt-dropzone:hover .rt-dropzone__icon {
189
+ background: var(--rt-accent-glow);
190
+ transform: scale(1.05);
191
+ }
192
+
193
+ .rt-dropzone__icon svg {
194
+ width: 24px;
195
+ height: 24px;
196
+ color: var(--rt-text-secondary);
197
+ transition: var(--rt-transition);
198
+ }
199
+
200
+ .rt-dropzone:hover .rt-dropzone__icon svg {
201
+ color: var(--rt-accent);
202
+ }
203
+
204
+ .rt-dropzone__text {
205
+ font-size: 16px;
206
+ color: var(--rt-text-secondary);
207
+ margin-bottom: 4px;
208
+ position: relative;
209
+ }
210
+
211
+ .rt-dropzone__text strong {
212
+ color: var(--rt-accent);
213
+ font-weight: 500;
214
+ }
215
+
216
+ .rt-dropzone__hint {
217
+ font-size: 13px;
218
+ color: var(--rt-text-tertiary);
219
+ position: relative;
220
+ }
221
+
222
+ /* \u2500\u2500 Gallery \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
223
+
224
+ .rt-gallery {
225
+ background: var(--rt-bg-elevated);
226
+ border-radius: var(--rt-radius-xl);
227
+ padding: 32px;
228
+ box-shadow: var(--rt-shadow-md);
229
+ }
230
+
231
+ .rt-gallery__toolbar {
232
+ display: flex;
233
+ align-items: center;
234
+ justify-content: space-between;
235
+ margin-bottom: 24px;
236
+ padding-bottom: 20px;
237
+ border-bottom: 1px solid var(--rt-border);
238
+ }
239
+
240
+ .rt-gallery__count {
241
+ font-size: 14px;
242
+ color: var(--rt-text-secondary);
243
+ }
244
+
245
+ .rt-gallery__count strong {
246
+ color: var(--rt-text);
247
+ font-weight: 600;
248
+ }
249
+
250
+ .rt-gallery__actions {
251
+ display: flex;
252
+ align-items: center;
253
+ gap: 10px;
254
+ }
255
+
256
+ .rt-btn {
257
+ display: inline-flex;
258
+ align-items: center;
259
+ gap: 6px;
260
+ padding: 8px 16px;
261
+ border: 1px solid var(--rt-border);
262
+ border-radius: var(--rt-radius-sm);
263
+ background: var(--rt-bg-elevated);
264
+ font-size: 13px;
265
+ font-weight: 500;
266
+ color: var(--rt-text-secondary);
267
+ cursor: pointer;
268
+ transition: var(--rt-transition);
269
+ font-family: inherit;
270
+ line-height: 1;
271
+ }
272
+
273
+ .rt-btn:hover {
274
+ border-color: var(--rt-border-strong);
275
+ color: var(--rt-text);
276
+ }
277
+
278
+ .rt-btn svg {
279
+ width: 14px;
280
+ height: 14px;
281
+ }
282
+
283
+ .rt-btn--accent {
284
+ background: var(--rt-accent);
285
+ border-color: var(--rt-accent);
286
+ color: white;
287
+ }
288
+
289
+ .rt-btn--accent:hover {
290
+ background: var(--rt-accent-hover);
291
+ border-color: var(--rt-accent-hover);
292
+ color: white;
293
+ }
294
+
295
+ .rt-gallery__grid {
296
+ display: grid;
297
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
298
+ gap: 16px;
299
+ }
300
+
301
+ .rt-gallery__item {
302
+ position: relative;
303
+ aspect-ratio: 1;
304
+ border-radius: var(--rt-radius-md);
305
+ overflow: hidden;
306
+ cursor: pointer;
307
+ }
308
+
309
+ .rt-gallery__item img {
310
+ width: 100%;
311
+ height: 100%;
312
+ object-fit: cover;
313
+ transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
314
+ }
315
+
316
+ .rt-gallery__item:hover img {
317
+ transform: scale(1.04);
318
+ }
319
+
320
+ .rt-gallery__item-overlay {
321
+ position: absolute;
322
+ inset: 0;
323
+ background: linear-gradient(to top, rgba(26,24,21,0.7) 0%, transparent 50%);
324
+ opacity: 0;
325
+ transition: var(--rt-transition);
326
+ display: flex;
327
+ align-items: flex-end;
328
+ padding: 12px;
329
+ }
330
+
331
+ .rt-gallery__item:hover .rt-gallery__item-overlay {
332
+ opacity: 1;
333
+ }
334
+
335
+ .rt-gallery__item-info {
336
+ display: flex;
337
+ align-items: center;
338
+ justify-content: space-between;
339
+ width: 100%;
340
+ }
341
+
342
+ .rt-gallery__item-name {
343
+ font-size: 12px;
344
+ color: rgba(255,255,255,0.85);
345
+ font-weight: 500;
346
+ white-space: nowrap;
347
+ overflow: hidden;
348
+ text-overflow: ellipsis;
349
+ max-width: 55%;
350
+ }
351
+
352
+ .rt-gallery__item-edit {
353
+ display: flex;
354
+ align-items: center;
355
+ gap: 5px;
356
+ padding: 6px 12px;
357
+ background: var(--rt-accent);
358
+ color: white;
359
+ border: none;
360
+ border-radius: var(--rt-radius-sm);
361
+ font-size: 11px;
362
+ font-weight: 600;
363
+ letter-spacing: 0.3px;
364
+ cursor: pointer;
365
+ transition: var(--rt-transition);
366
+ font-family: inherit;
367
+ text-transform: uppercase;
368
+ }
369
+
370
+ .rt-gallery__item-edit:hover {
371
+ background: var(--rt-accent-hover);
372
+ transform: translateY(-1px);
373
+ }
374
+
375
+ .rt-gallery__item-edit svg {
376
+ width: 12px;
377
+ height: 12px;
378
+ }
379
+
380
+ .rt-gallery__item-status {
381
+ position: absolute;
382
+ top: 8px;
383
+ right: 8px;
384
+ width: 8px;
385
+ height: 8px;
386
+ border-radius: 50%;
387
+ border: 2px solid var(--rt-bg-elevated);
388
+ }
389
+
390
+ .rt-gallery__item-status--edited {
391
+ background: #22C55E;
392
+ }
393
+
394
+ .rt-gallery__item-status--pending {
395
+ background: var(--rt-text-tertiary);
396
+ }
397
+
398
+ .rt-gallery__item-remove {
399
+ position: absolute;
400
+ top: 8px;
401
+ left: 8px;
402
+ width: 24px;
403
+ height: 24px;
404
+ border-radius: 50%;
405
+ background: rgba(0,0,0,0.5);
406
+ border: none;
407
+ color: white;
408
+ cursor: pointer;
409
+ display: flex;
410
+ align-items: center;
411
+ justify-content: center;
412
+ opacity: 0;
413
+ transition: var(--rt-transition);
414
+ backdrop-filter: blur(4px);
415
+ }
416
+
417
+ .rt-gallery__item:hover .rt-gallery__item-remove {
418
+ opacity: 1;
419
+ }
420
+
421
+ .rt-gallery__item-remove:hover {
422
+ background: rgba(220, 50, 50, 0.8);
423
+ }
424
+
425
+ .rt-gallery__item-remove svg {
426
+ width: 12px;
427
+ height: 12px;
428
+ }
429
+
430
+ .rt-gallery__footer {
431
+ display: flex;
432
+ justify-content: flex-end;
433
+ margin-top: 24px;
434
+ padding-top: 20px;
435
+ border-top: 1px solid var(--rt-border);
436
+ }
437
+
438
+ /* \u2500\u2500 Editor \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
439
+
440
+ .rt-editor-overlay {
441
+ position: fixed;
442
+ inset: 0;
443
+ z-index: 9999;
444
+ background: #1A1815;
445
+ display: flex;
446
+ flex-direction: column;
447
+ }
448
+
449
+ .rt-editor__topbar {
450
+ display: flex;
451
+ align-items: center;
452
+ justify-content: space-between;
453
+ padding: 12px 20px;
454
+ background: #222019;
455
+ border-bottom: 1px solid rgba(255,255,255,0.06);
456
+ flex-shrink: 0;
457
+ }
458
+
459
+ .rt-editor__topbar-left {
460
+ display: flex;
461
+ align-items: center;
462
+ gap: 12px;
463
+ }
464
+
465
+ .rt-editor__filename {
466
+ font-size: 13px;
467
+ color: rgba(255,255,255,0.6);
468
+ font-weight: 400;
469
+ }
470
+
471
+ .rt-editor__dimensions {
472
+ font-size: 11px;
473
+ color: rgba(255,255,255,0.3);
474
+ padding: 3px 8px;
475
+ background: rgba(255,255,255,0.06);
476
+ border-radius: 4px;
477
+ }
478
+
479
+ .rt-editor__topbar-right {
480
+ display: flex;
481
+ align-items: center;
482
+ gap: 10px;
483
+ }
484
+
485
+ .rt-editor__btn-cancel {
486
+ padding: 7px 16px;
487
+ border: 1px solid rgba(255,255,255,0.12);
488
+ border-radius: var(--rt-radius-sm);
489
+ background: transparent;
490
+ color: rgba(255,255,255,0.6);
491
+ font-size: 13px;
492
+ font-weight: 500;
493
+ cursor: pointer;
494
+ transition: var(--rt-transition);
495
+ font-family: inherit;
496
+ }
497
+
498
+ .rt-editor__btn-cancel:hover {
499
+ border-color: rgba(255,255,255,0.25);
500
+ color: rgba(255,255,255,0.85);
501
+ }
502
+
503
+ .rt-editor__btn-done {
504
+ padding: 7px 20px;
505
+ border: none;
506
+ border-radius: var(--rt-radius-sm);
507
+ background: var(--rt-accent);
508
+ color: white;
509
+ font-size: 13px;
510
+ font-weight: 600;
511
+ cursor: pointer;
512
+ transition: var(--rt-transition);
513
+ font-family: inherit;
514
+ }
515
+
516
+ .rt-editor__btn-done:hover {
517
+ background: var(--rt-accent-hover);
518
+ }
519
+
520
+ .rt-editor__body {
521
+ display: flex;
522
+ flex: 1;
523
+ min-height: 0;
524
+ }
525
+
526
+ /* \u2500\u2500 Toolbar (left sidebar) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
527
+
528
+ .rt-toolbar {
529
+ width: 64px;
530
+ background: #1E1C18;
531
+ border-right: 1px solid rgba(255,255,255,0.06);
532
+ display: flex;
533
+ flex-direction: column;
534
+ align-items: center;
535
+ padding: 16px 0;
536
+ gap: 4px;
537
+ flex-shrink: 0;
538
+ }
539
+
540
+ .rt-toolbar__btn {
541
+ width: 44px;
542
+ height: 44px;
543
+ border: none;
544
+ background: transparent;
545
+ border-radius: var(--rt-radius-sm);
546
+ color: rgba(255,255,255,0.4);
547
+ cursor: pointer;
548
+ display: flex;
549
+ flex-direction: column;
550
+ align-items: center;
551
+ justify-content: center;
552
+ gap: 3px;
553
+ transition: var(--rt-transition);
554
+ position: relative;
555
+ }
556
+
557
+ .rt-toolbar__btn:hover {
558
+ background: rgba(255,255,255,0.06);
559
+ color: rgba(255,255,255,0.7);
560
+ }
561
+
562
+ .rt-toolbar__btn--active {
563
+ background: rgba(212, 87, 42, 0.15);
564
+ color: var(--rt-accent);
565
+ }
566
+
567
+ .rt-toolbar__btn--active::before {
568
+ content: '';
569
+ position: absolute;
570
+ left: 0;
571
+ top: 50%;
572
+ transform: translateY(-50%);
573
+ width: 3px;
574
+ height: 20px;
575
+ background: var(--rt-accent);
576
+ border-radius: 0 2px 2px 0;
577
+ }
578
+
579
+ .rt-toolbar__btn svg {
580
+ width: 20px;
581
+ height: 20px;
582
+ }
583
+
584
+ .rt-toolbar__btn span {
585
+ font-size: 9px;
586
+ font-weight: 500;
587
+ letter-spacing: 0.3px;
588
+ }
589
+
590
+ /* \u2500\u2500 Canvas area \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
591
+
592
+ .rt-editor__canvas-area {
593
+ flex: 1;
594
+ display: flex;
595
+ align-items: center;
596
+ justify-content: center;
597
+ position: relative;
598
+ overflow: hidden;
599
+ background:
600
+ repeating-conic-gradient(
601
+ rgba(255,255,255,0.03) 0% 25%,
602
+ transparent 0% 50%
603
+ )
604
+ 0 0 / 24px 24px;
605
+ }
606
+
607
+ .rt-editor__canvas-container {
608
+ position: relative;
609
+ max-width: 90%;
610
+ max-height: 90%;
611
+ }
612
+
613
+ .rt-editor__canvas-container canvas {
614
+ display: block;
615
+ border-radius: var(--rt-radius-sm);
616
+ box-shadow: 0 8px 40px rgba(0,0,0,0.3);
617
+ }
618
+
619
+ /* \u2500\u2500 Crop overlay \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
620
+
621
+ .rt-crop {
622
+ position: absolute;
623
+ inset: 0;
624
+ pointer-events: none;
625
+ }
626
+
627
+ .rt-crop__mask {
628
+ position: absolute;
629
+ background: rgba(0,0,0,0.45);
630
+ pointer-events: none;
631
+ }
632
+
633
+ .rt-crop__selection {
634
+ position: absolute;
635
+ border: 2px solid white;
636
+ pointer-events: auto;
637
+ cursor: move;
638
+ }
639
+
640
+ .rt-crop__grid {
641
+ position: absolute;
642
+ inset: 0;
643
+ display: grid;
644
+ grid-template-columns: 1fr 1fr 1fr;
645
+ grid-template-rows: 1fr 1fr 1fr;
646
+ pointer-events: none;
647
+ }
648
+
649
+ .rt-crop__grid-cell {
650
+ border-right: 1px solid rgba(255,255,255,0.2);
651
+ border-bottom: 1px solid rgba(255,255,255,0.2);
652
+ }
653
+
654
+ .rt-crop__grid-cell:nth-child(3n) { border-right: none; }
655
+ .rt-crop__grid-cell:nth-child(n+7) { border-bottom: none; }
656
+
657
+ .rt-crop__handle {
658
+ position: absolute;
659
+ width: 12px;
660
+ height: 12px;
661
+ background: white;
662
+ border-radius: 2px;
663
+ box-shadow: 0 1px 4px rgba(0,0,0,0.3);
664
+ pointer-events: auto;
665
+ }
666
+
667
+ .rt-crop__handle--nw { top: -6px; left: -6px; cursor: nw-resize; }
668
+ .rt-crop__handle--ne { top: -6px; right: -6px; cursor: ne-resize; }
669
+ .rt-crop__handle--sw { bottom: -6px; left: -6px; cursor: sw-resize; }
670
+ .rt-crop__handle--se { bottom: -6px; right: -6px; cursor: se-resize; }
671
+ .rt-crop__handle--n { top: -6px; left: 50%; transform: translateX(-50%); cursor: n-resize; }
672
+ .rt-crop__handle--s { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: s-resize; }
673
+ .rt-crop__handle--w { top: 50%; left: -6px; transform: translateY(-50%); cursor: w-resize; }
674
+ .rt-crop__handle--e { top: 50%; right: -6px; transform: translateY(-50%); cursor: e-resize; }
675
+
676
+ /* \u2500\u2500 Properties panel (right) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
677
+
678
+ .rt-props {
679
+ width: 260px;
680
+ background: #1E1C18;
681
+ border-left: 1px solid rgba(255,255,255,0.06);
682
+ padding: 20px 16px;
683
+ overflow-y: auto;
684
+ flex-shrink: 0;
685
+ }
686
+
687
+ .rt-props__title {
688
+ font-size: 11px;
689
+ font-weight: 600;
690
+ letter-spacing: 1.5px;
691
+ text-transform: uppercase;
692
+ color: rgba(255,255,255,0.35);
693
+ margin-bottom: 16px;
694
+ }
695
+
696
+ .rt-props__row {
697
+ margin-bottom: 16px;
698
+ }
699
+
700
+ .rt-props__label {
701
+ font-size: 12px;
702
+ color: rgba(255,255,255,0.5);
703
+ margin-bottom: 6px;
704
+ }
705
+
706
+ .rt-props__slider {
707
+ display: flex;
708
+ align-items: center;
709
+ gap: 10px;
710
+ }
711
+
712
+ .rt-props__slider input[type="range"] {
713
+ flex: 1;
714
+ height: 4px;
715
+ -webkit-appearance: none;
716
+ appearance: none;
717
+ background: rgba(255,255,255,0.1);
718
+ border-radius: 2px;
719
+ outline: none;
720
+ }
721
+
722
+ .rt-props__slider input[type="range"]::-webkit-slider-thumb {
723
+ -webkit-appearance: none;
724
+ width: 14px;
725
+ height: 14px;
726
+ background: white;
727
+ border-radius: 50%;
728
+ box-shadow: 0 1px 4px rgba(0,0,0,0.3);
729
+ cursor: pointer;
730
+ }
731
+
732
+ .rt-props__slider-value {
733
+ font-size: 12px;
734
+ color: rgba(255,255,255,0.5);
735
+ width: 36px;
736
+ text-align: right;
737
+ font-variant-numeric: tabular-nums;
738
+ }
739
+
740
+ .rt-props__aspect-grid {
741
+ display: grid;
742
+ grid-template-columns: repeat(3, 1fr);
743
+ gap: 6px;
744
+ }
745
+
746
+ .rt-props__aspect-btn {
747
+ padding: 8px 4px;
748
+ border: 1px solid rgba(255,255,255,0.1);
749
+ border-radius: var(--rt-radius-sm);
750
+ background: transparent;
751
+ color: rgba(255,255,255,0.5);
752
+ font-size: 11px;
753
+ font-weight: 500;
754
+ cursor: pointer;
755
+ transition: var(--rt-transition);
756
+ text-align: center;
757
+ font-family: inherit;
758
+ }
759
+
760
+ .rt-props__aspect-btn:hover {
761
+ border-color: rgba(255,255,255,0.2);
762
+ color: rgba(255,255,255,0.8);
763
+ }
764
+
765
+ .rt-props__aspect-btn--active {
766
+ border-color: var(--rt-accent);
767
+ background: rgba(212, 87, 42, 0.12);
768
+ color: var(--rt-accent);
769
+ }
770
+
771
+ .rt-props__divider {
772
+ height: 1px;
773
+ background: rgba(255,255,255,0.06);
774
+ margin: 20px 0;
775
+ }
776
+ `
777
+ );
778
+ var injected = false;
779
+ function injectStyles() {
780
+ if (injected || document.getElementById(STYLE_ID)) {
781
+ injected = true;
782
+ return;
783
+ }
784
+ const style = document.createElement("style");
785
+ style.id = STYLE_ID;
786
+ style.textContent = CSS;
787
+ document.head.appendChild(style);
788
+ injected = true;
789
+ }
790
+
791
+ // src/ui/h.ts
792
+ function h(tag, attrs, ...children) {
793
+ const el = document.createElement(tag);
794
+ if (attrs) {
795
+ for (const [key, value] of Object.entries(attrs)) {
796
+ if (key.startsWith("on") && typeof value === "function") {
797
+ el.addEventListener(key.slice(2).toLowerCase(), value);
798
+ } else if (typeof value === "boolean") {
799
+ if (value) el.setAttribute(key, "");
800
+ } else {
801
+ el.setAttribute(key, String(value));
802
+ }
803
+ }
804
+ }
805
+ for (const child of children) {
806
+ el.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
807
+ }
808
+ return el;
809
+ }
810
+
811
+ // src/ui/drop-zone.ts
812
+ function createDropZone(options) {
813
+ const input = h("input", {
814
+ type: "file",
815
+ accept: "image/*",
816
+ multiple: true,
817
+ style: "display:none"
818
+ });
819
+ const zone = h(
820
+ "div",
821
+ { class: "rt-dropzone" },
822
+ h("div", { class: "rt-dropzone__icon" }, createUploadIcon()),
823
+ h("div", { class: "rt-dropzone__text" }, "Drop images here or ", h("strong", null, "browse")),
824
+ h("div", { class: "rt-dropzone__hint" }, "PNG, JPG, WebP"),
825
+ input
826
+ );
827
+ const root = h("div", { class: "rt-dropzone-wrapper" }, zone);
828
+ const abort = new AbortController();
829
+ const signal = abort.signal;
830
+ zone.addEventListener("click", () => input.click(), { signal });
831
+ input.addEventListener(
832
+ "change",
833
+ () => {
834
+ if (input.files && input.files.length > 0) {
835
+ options.onFiles(Array.from(input.files));
836
+ input.value = "";
837
+ }
838
+ },
839
+ { signal }
840
+ );
841
+ let dragCounter = 0;
842
+ zone.addEventListener(
843
+ "dragenter",
844
+ (e) => {
845
+ e.preventDefault();
846
+ dragCounter++;
847
+ zone.classList.add("rt-dropzone--active");
848
+ },
849
+ { signal }
850
+ );
851
+ zone.addEventListener(
852
+ "dragover",
853
+ (e) => {
854
+ e.preventDefault();
855
+ },
856
+ { signal }
857
+ );
858
+ zone.addEventListener(
859
+ "dragleave",
860
+ (e) => {
861
+ e.preventDefault();
862
+ dragCounter--;
863
+ if (dragCounter <= 0) {
864
+ dragCounter = 0;
865
+ zone.classList.remove("rt-dropzone--active");
866
+ }
867
+ },
868
+ { signal }
869
+ );
870
+ zone.addEventListener(
871
+ "drop",
872
+ (e) => {
873
+ e.preventDefault();
874
+ dragCounter = 0;
875
+ zone.classList.remove("rt-dropzone--active");
876
+ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
877
+ options.onFiles(Array.from(e.dataTransfer.files));
878
+ }
879
+ },
880
+ { signal }
881
+ );
882
+ return {
883
+ root,
884
+ destroy() {
885
+ abort.abort();
886
+ root.remove();
887
+ }
888
+ };
889
+ }
890
+ function createUploadIcon() {
891
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
892
+ svg.setAttribute("viewBox", "0 0 24 24");
893
+ svg.setAttribute("fill", "none");
894
+ svg.setAttribute("stroke", "currentColor");
895
+ svg.setAttribute("stroke-width", "1.5");
896
+ const p1 = document.createElementNS("http://www.w3.org/2000/svg", "path");
897
+ p1.setAttribute("d", "M4 14.899A7 7 0 1115.71 8h1.79a4.5 4.5 0 012.5 8.242");
898
+ const p2 = document.createElementNS("http://www.w3.org/2000/svg", "path");
899
+ p2.setAttribute("d", "M12 12v9m0-9l-3 3m3-3 3 3");
900
+ svg.appendChild(p1);
901
+ svg.appendChild(p2);
902
+ return svg;
903
+ }
904
+
905
+ // src/ui/editor/adjust-tool.ts
906
+ function createAdjustTool(options) {
907
+ const adj = { ...options.adjustments };
908
+ const abort = new AbortController();
909
+ const signal = abort.signal;
910
+ function createSlider(label, key, min, max) {
911
+ const valueEl = h("span", { class: "rt-props__slider-value" }, formatValue(adj[key]));
912
+ const input = h("input", {
913
+ type: "range",
914
+ min,
915
+ max,
916
+ step: 1,
917
+ value: adj[key]
918
+ });
919
+ input.addEventListener(
920
+ "input",
921
+ () => {
922
+ adj[key] = Number(input.value);
923
+ valueEl.textContent = formatValue(adj[key]);
924
+ options.onChange({ ...adj });
925
+ },
926
+ { signal }
927
+ );
928
+ return h(
929
+ "div",
930
+ { class: "rt-props__row" },
931
+ h("div", { class: "rt-props__label" }, label),
932
+ h("div", { class: "rt-props__slider" }, input, valueEl)
933
+ );
934
+ }
935
+ const root = h(
936
+ "div",
937
+ null,
938
+ h("div", { class: "rt-props__title" }, "Adjustments"),
939
+ createSlider("Brightness", "brightness", 0, 200),
940
+ createSlider("Contrast", "contrast", 0, 200),
941
+ createSlider("Saturation", "saturation", 0, 200)
942
+ );
943
+ return {
944
+ root,
945
+ getAdjustments: () => ({ ...adj }),
946
+ destroy() {
947
+ abort.abort();
948
+ }
949
+ };
950
+ }
951
+ function formatValue(value, _key) {
952
+ const diff = value - 100;
953
+ if (diff === 0) return "0";
954
+ return diff > 0 ? `+${diff}` : `${diff}`;
955
+ }
956
+
957
+ // src/ui/editor/canvas-renderer.ts
958
+ var CanvasRenderer = class {
959
+ canvas;
960
+ ctx;
961
+ container;
962
+ image;
963
+ adjustments;
964
+ rotation = 0;
965
+ imageRect = { x: 0, y: 0, width: 0, height: 0 };
966
+ constructor(container, image, edits) {
967
+ this.container = container;
968
+ this.image = image;
969
+ this.adjustments = { ...edits.adjustments };
970
+ this.rotation = edits.rotation;
971
+ this.canvas = document.createElement("canvas");
972
+ this.canvas.style.display = "block";
973
+ this.container.appendChild(this.canvas);
974
+ const ctx = this.canvas.getContext("2d");
975
+ if (!ctx) throw new Error("[Retouch] Failed to get 2D context");
976
+ this.ctx = ctx;
977
+ }
978
+ setAdjustments(adj) {
979
+ this.adjustments = { ...adj };
980
+ }
981
+ setRotation(deg) {
982
+ this.rotation = deg;
983
+ }
984
+ getImageRect() {
985
+ return { ...this.imageRect };
986
+ }
987
+ getCanvasElement() {
988
+ return this.canvas;
989
+ }
990
+ render() {
991
+ const containerWidth = this.container.clientWidth || 800;
992
+ const containerHeight = this.container.clientHeight || 600;
993
+ const imgW = this.image.naturalWidth;
994
+ const imgH = this.image.naturalHeight;
995
+ if (imgW === 0 || imgH === 0) return;
996
+ const scale = Math.min(containerWidth * 0.9 / imgW, containerHeight * 0.9 / imgH, 1);
997
+ const drawW = Math.round(imgW * scale);
998
+ const drawH = Math.round(imgH * scale);
999
+ this.canvas.width = drawW;
1000
+ this.canvas.height = drawH;
1001
+ this.canvas.style.width = `${drawW}px`;
1002
+ this.canvas.style.height = `${drawH}px`;
1003
+ this.imageRect = { x: 0, y: 0, width: drawW, height: drawH };
1004
+ const ctx = this.ctx;
1005
+ ctx.clearRect(0, 0, drawW, drawH);
1006
+ const { brightness, contrast, saturation } = this.adjustments;
1007
+ ctx.filter = `brightness(${brightness / 100}) contrast(${contrast / 100}) saturate(${saturation / 100})`;
1008
+ if (this.rotation !== 0) {
1009
+ const radians = this.rotation * Math.PI / 180;
1010
+ ctx.save();
1011
+ ctx.translate(drawW / 2, drawH / 2);
1012
+ ctx.rotate(radians);
1013
+ ctx.drawImage(this.image, -drawW / 2, -drawH / 2, drawW, drawH);
1014
+ ctx.restore();
1015
+ } else {
1016
+ ctx.drawImage(this.image, 0, 0, drawW, drawH);
1017
+ }
1018
+ ctx.filter = "none";
1019
+ }
1020
+ /** Convert screen coordinates (relative to canvas) to normalized image coords (0–1). */
1021
+ screenToImage(sx, sy) {
1022
+ const r = this.imageRect;
1023
+ return {
1024
+ x: r.width > 0 ? (sx - r.x) / r.width : 0,
1025
+ y: r.height > 0 ? (sy - r.y) / r.height : 0
1026
+ };
1027
+ }
1028
+ /** Convert normalized image coords (0–1) to screen coordinates relative to canvas. */
1029
+ imageToScreen(ix, iy) {
1030
+ const r = this.imageRect;
1031
+ return {
1032
+ x: r.x + ix * r.width,
1033
+ y: r.y + iy * r.height
1034
+ };
1035
+ }
1036
+ destroy() {
1037
+ this.canvas.remove();
1038
+ }
1039
+ };
1040
+
1041
+ // src/utils/math.ts
1042
+ function clamp(value, min, max) {
1043
+ return Math.min(Math.max(value, min), max);
1044
+ }
1045
+
1046
+ // src/ui/editor/crop-tool.ts
1047
+ var MIN_SIZE = 0.05;
1048
+ function createCropTool(options) {
1049
+ const { container, renderer, onChange } = options;
1050
+ const crop = { ...options.edits.crop };
1051
+ let aspectRatio = "free";
1052
+ const abort = new AbortController();
1053
+ const signal = abort.signal;
1054
+ const root = document.createElement("div");
1055
+ root.className = "rt-crop";
1056
+ const maskTop = createMask();
1057
+ const maskRight = createMask();
1058
+ const maskBottom = createMask();
1059
+ const maskLeft = createMask();
1060
+ const selection = document.createElement("div");
1061
+ selection.className = "rt-crop__selection";
1062
+ const grid = document.createElement("div");
1063
+ grid.className = "rt-crop__grid";
1064
+ for (let i = 0; i < 9; i++) {
1065
+ const cell = document.createElement("div");
1066
+ cell.className = "rt-crop__grid-cell";
1067
+ grid.appendChild(cell);
1068
+ }
1069
+ selection.appendChild(grid);
1070
+ const handles = {};
1071
+ for (const pos of ["nw", "ne", "sw", "se", "n", "s", "w", "e"]) {
1072
+ const handle = document.createElement("div");
1073
+ handle.className = `rt-crop__handle rt-crop__handle--${pos}`;
1074
+ handle.dataset.handle = pos;
1075
+ selection.appendChild(handle);
1076
+ handles[pos] = handle;
1077
+ }
1078
+ root.appendChild(maskTop);
1079
+ root.appendChild(maskRight);
1080
+ root.appendChild(maskBottom);
1081
+ root.appendChild(maskLeft);
1082
+ root.appendChild(selection);
1083
+ container.appendChild(root);
1084
+ function updateLayout() {
1085
+ const imgRect = renderer.getImageRect();
1086
+ const sx = imgRect.x + crop.x * imgRect.width;
1087
+ const sy = imgRect.y + crop.y * imgRect.height;
1088
+ const sw = crop.width * imgRect.width;
1089
+ const sh = crop.height * imgRect.height;
1090
+ selection.style.left = `${sx}px`;
1091
+ selection.style.top = `${sy}px`;
1092
+ selection.style.width = `${sw}px`;
1093
+ selection.style.height = `${sh}px`;
1094
+ const cw = container.clientWidth || imgRect.width;
1095
+ const ch = container.clientHeight || imgRect.height;
1096
+ maskTop.style.left = "0";
1097
+ maskTop.style.top = "0";
1098
+ maskTop.style.width = `${cw}px`;
1099
+ maskTop.style.height = `${sy}px`;
1100
+ maskBottom.style.left = "0";
1101
+ maskBottom.style.top = `${sy + sh}px`;
1102
+ maskBottom.style.width = `${cw}px`;
1103
+ maskBottom.style.height = `${ch - sy - sh}px`;
1104
+ maskLeft.style.left = "0";
1105
+ maskLeft.style.top = `${sy}px`;
1106
+ maskLeft.style.width = `${sx}px`;
1107
+ maskLeft.style.height = `${sh}px`;
1108
+ maskRight.style.left = `${sx + sw}px`;
1109
+ maskRight.style.top = `${sy}px`;
1110
+ maskRight.style.width = `${cw - sx - sw}px`;
1111
+ maskRight.style.height = `${sh}px`;
1112
+ }
1113
+ let dragging = null;
1114
+ selection.addEventListener(
1115
+ "pointerdown",
1116
+ (e) => {
1117
+ if (e.target.dataset.handle) return;
1118
+ e.preventDefault();
1119
+ dragging = { type: "move", startX: e.clientX, startY: e.clientY, startCrop: { ...crop } };
1120
+ },
1121
+ { signal }
1122
+ );
1123
+ for (const [pos, handle] of Object.entries(handles)) {
1124
+ handle.addEventListener(
1125
+ "pointerdown",
1126
+ (e) => {
1127
+ e.preventDefault();
1128
+ e.stopPropagation();
1129
+ dragging = {
1130
+ type: pos,
1131
+ startX: e.clientX,
1132
+ startY: e.clientY,
1133
+ startCrop: { ...crop }
1134
+ };
1135
+ },
1136
+ { signal }
1137
+ );
1138
+ }
1139
+ document.addEventListener(
1140
+ "pointermove",
1141
+ (e) => {
1142
+ if (!dragging) return;
1143
+ e.preventDefault();
1144
+ const imgRect = renderer.getImageRect();
1145
+ if (imgRect.width === 0 || imgRect.height === 0) return;
1146
+ const dx = (e.clientX - dragging.startX) / imgRect.width;
1147
+ const dy = (e.clientY - dragging.startY) / imgRect.height;
1148
+ const sc = dragging.startCrop;
1149
+ if (dragging.type === "move") {
1150
+ crop.x = clamp(sc.x + dx, 0, 1 - sc.width);
1151
+ crop.y = clamp(sc.y + dy, 0, 1 - sc.height);
1152
+ } else {
1153
+ handleResize(dragging.type, sc, dx, dy);
1154
+ }
1155
+ updateLayout();
1156
+ onChange(crop);
1157
+ },
1158
+ { signal }
1159
+ );
1160
+ document.addEventListener(
1161
+ "pointerup",
1162
+ () => {
1163
+ dragging = null;
1164
+ },
1165
+ { signal }
1166
+ );
1167
+ function handleResize(pos, sc, dx, dy) {
1168
+ const ratio = ASPECT_RATIOS[aspectRatio];
1169
+ let newX = sc.x;
1170
+ let newY = sc.y;
1171
+ let newW = sc.width;
1172
+ let newH = sc.height;
1173
+ if (pos.includes("w")) {
1174
+ const maxDx = sc.width - MIN_SIZE;
1175
+ const clampedDx = clamp(dx, -sc.x, maxDx);
1176
+ newX = sc.x + clampedDx;
1177
+ newW = sc.width - clampedDx;
1178
+ }
1179
+ if (pos.includes("e")) {
1180
+ newW = clamp(sc.width + dx, MIN_SIZE, 1 - sc.x);
1181
+ }
1182
+ if (pos.includes("n")) {
1183
+ const maxDy = sc.height - MIN_SIZE;
1184
+ const clampedDy = clamp(dy, -sc.y, maxDy);
1185
+ newY = sc.y + clampedDy;
1186
+ newH = sc.height - clampedDy;
1187
+ }
1188
+ if (pos.includes("s")) {
1189
+ newH = clamp(sc.height + dy, MIN_SIZE, 1 - sc.y);
1190
+ }
1191
+ if (ratio !== null && ratio !== void 0) {
1192
+ const imgRect = renderer.getImageRect();
1193
+ const pixelRatio = imgRect.width / imgRect.height;
1194
+ const normalizedRatio = ratio / pixelRatio;
1195
+ if (pos === "n" || pos === "s") {
1196
+ newW = newH * normalizedRatio;
1197
+ if (newX + newW > 1) newW = 1 - newX;
1198
+ newH = newW / normalizedRatio;
1199
+ } else {
1200
+ newH = newW / normalizedRatio;
1201
+ if (newY + newH > 1) newH = 1 - newY;
1202
+ newW = newH * normalizedRatio;
1203
+ }
1204
+ }
1205
+ newW = clamp(newW, MIN_SIZE, 1 - newX);
1206
+ newH = clamp(newH, MIN_SIZE, 1 - newY);
1207
+ crop.x = newX;
1208
+ crop.y = newY;
1209
+ crop.width = newW;
1210
+ crop.height = newH;
1211
+ }
1212
+ updateLayout();
1213
+ return {
1214
+ root,
1215
+ getCrop: () => ({ ...crop }),
1216
+ setAspectRatio(preset) {
1217
+ aspectRatio = preset;
1218
+ const ratio = ASPECT_RATIOS[preset];
1219
+ if (ratio !== null && ratio !== void 0) {
1220
+ const imgRect = renderer.getImageRect();
1221
+ const pixelRatio = imgRect.width / imgRect.height;
1222
+ const normalizedRatio = ratio / pixelRatio;
1223
+ let newW = crop.width;
1224
+ let newH = newW / normalizedRatio;
1225
+ if (newH > 1) {
1226
+ newH = 1;
1227
+ newW = newH * normalizedRatio;
1228
+ }
1229
+ if (crop.x + newW > 1) crop.x = Math.max(0, 1 - newW);
1230
+ if (crop.y + newH > 1) crop.y = Math.max(0, 1 - newH);
1231
+ crop.width = newW;
1232
+ crop.height = newH;
1233
+ updateLayout();
1234
+ onChange(crop);
1235
+ }
1236
+ },
1237
+ getAspectRatio: () => aspectRatio,
1238
+ setVisible(visible) {
1239
+ root.style.display = visible ? "" : "none";
1240
+ },
1241
+ destroy() {
1242
+ abort.abort();
1243
+ root.remove();
1244
+ }
1245
+ };
1246
+ }
1247
+ function createMask() {
1248
+ const div = document.createElement("div");
1249
+ div.className = "rt-crop__mask";
1250
+ return div;
1251
+ }
1252
+
1253
+ // src/ui/editor/properties-panel.ts
1254
+ var ASPECT_PRESETS = [
1255
+ { id: "free", label: "Free" },
1256
+ { id: "16:9", label: "16:9" },
1257
+ { id: "4:3", label: "4:3" },
1258
+ { id: "1:1", label: "1:1" },
1259
+ { id: "3:2", label: "3:2" },
1260
+ { id: "9:16", label: "9:16" }
1261
+ ];
1262
+ function createPropertiesPanel(options) {
1263
+ const { cropTool, adjustTool, edits, onRotationChange } = options;
1264
+ const abort = new AbortController();
1265
+ const signal = abort.signal;
1266
+ const aspectBtns = /* @__PURE__ */ new Map();
1267
+ const aspectGrid = h("div", { class: "rt-props__aspect-grid" });
1268
+ for (const preset of ASPECT_PRESETS) {
1269
+ const isActive = cropTool.getAspectRatio() === preset.id;
1270
+ const btn = h(
1271
+ "button",
1272
+ {
1273
+ class: `rt-props__aspect-btn${isActive ? " rt-props__aspect-btn--active" : ""}`
1274
+ },
1275
+ preset.label
1276
+ );
1277
+ btn.addEventListener(
1278
+ "click",
1279
+ () => {
1280
+ for (const b of aspectBtns.values()) {
1281
+ b.classList.remove("rt-props__aspect-btn--active");
1282
+ }
1283
+ btn.classList.add("rt-props__aspect-btn--active");
1284
+ cropTool.setAspectRatio(preset.id);
1285
+ },
1286
+ { signal }
1287
+ );
1288
+ aspectBtns.set(preset.id, btn);
1289
+ aspectGrid.appendChild(btn);
1290
+ }
1291
+ const rotationValue = h("span", { class: "rt-props__slider-value" }, `${edits.rotation}\xB0`);
1292
+ const rotationInput = h("input", {
1293
+ type: "range",
1294
+ min: -45,
1295
+ max: 45,
1296
+ step: 1,
1297
+ value: edits.rotation
1298
+ });
1299
+ rotationInput.addEventListener(
1300
+ "input",
1301
+ () => {
1302
+ const deg = Number(rotationInput.value);
1303
+ rotationValue.textContent = `${deg}\xB0`;
1304
+ onRotationChange(deg);
1305
+ },
1306
+ { signal }
1307
+ );
1308
+ const cropProps = h(
1309
+ "div",
1310
+ null,
1311
+ h("div", { class: "rt-props__title" }, "Crop"),
1312
+ h(
1313
+ "div",
1314
+ { class: "rt-props__row" },
1315
+ h("div", { class: "rt-props__label" }, "Aspect Ratio"),
1316
+ aspectGrid
1317
+ ),
1318
+ h(
1319
+ "div",
1320
+ { class: "rt-props__row" },
1321
+ h("div", { class: "rt-props__label" }, "Rotation"),
1322
+ h("div", { class: "rt-props__slider" }, rotationInput, rotationValue)
1323
+ ),
1324
+ h("div", { class: "rt-props__divider" })
1325
+ );
1326
+ const adjustProps = adjustTool.root;
1327
+ const content = h("div");
1328
+ content.appendChild(cropProps);
1329
+ content.appendChild(adjustProps);
1330
+ adjustProps.style.display = "none";
1331
+ const root = h("div", { class: "rt-props" }, content);
1332
+ return {
1333
+ root,
1334
+ setActiveTool(tool) {
1335
+ cropProps.style.display = tool === "crop" ? "" : "none";
1336
+ adjustProps.style.display = tool === "adjust" ? "" : "none";
1337
+ },
1338
+ destroy() {
1339
+ abort.abort();
1340
+ root.remove();
1341
+ }
1342
+ };
1343
+ }
1344
+
1345
+ // src/ui/editor/toolbar.ts
1346
+ var TOOLS = [
1347
+ {
1348
+ id: "crop",
1349
+ label: "Crop",
1350
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4zM3 6h18"/></svg>'
1351
+ },
1352
+ {
1353
+ id: "adjust",
1354
+ label: "Adjust",
1355
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="9"/><path d="M12 3v18M3 12h18"/></svg>'
1356
+ }
1357
+ ];
1358
+ function createToolbar(options) {
1359
+ const abort = new AbortController();
1360
+ const signal = abort.signal;
1361
+ let active = options.activeTool;
1362
+ const buttons = /* @__PURE__ */ new Map();
1363
+ const root = h("div", { class: "rt-toolbar" });
1364
+ for (const tool of TOOLS) {
1365
+ const btn = h("button", {
1366
+ class: `rt-toolbar__btn${tool.id === active ? " rt-toolbar__btn--active" : ""}`,
1367
+ title: tool.label
1368
+ });
1369
+ btn.innerHTML = `${tool.icon}<span>${tool.label}</span>`;
1370
+ btn.addEventListener(
1371
+ "click",
1372
+ () => {
1373
+ if (active === tool.id) return;
1374
+ buttons.get(active)?.classList.remove("rt-toolbar__btn--active");
1375
+ btn.classList.add("rt-toolbar__btn--active");
1376
+ active = tool.id;
1377
+ options.onToolChange(tool.id);
1378
+ },
1379
+ { signal }
1380
+ );
1381
+ buttons.set(tool.id, btn);
1382
+ root.appendChild(btn);
1383
+ }
1384
+ return {
1385
+ root,
1386
+ setActiveTool(tool) {
1387
+ buttons.get(active)?.classList.remove("rt-toolbar__btn--active");
1388
+ buttons.get(tool)?.classList.add("rt-toolbar__btn--active");
1389
+ active = tool;
1390
+ },
1391
+ destroy() {
1392
+ abort.abort();
1393
+ root.remove();
1394
+ }
1395
+ };
1396
+ }
1397
+
1398
+ // src/ui/editor/editor.ts
1399
+ function createEditor(options) {
1400
+ const { entry, onDone, onCancel } = options;
1401
+ const abort = new AbortController();
1402
+ const signal = abort.signal;
1403
+ const editSnapshot = structuredClone(entry.edits);
1404
+ let activeTool = "crop";
1405
+ const filenameEl = h("span", { class: "rt-editor__filename" }, entry.file.name);
1406
+ const dimsEl = h(
1407
+ "span",
1408
+ { class: "rt-editor__dimensions" },
1409
+ `${entry.image.naturalWidth} \xD7 ${entry.image.naturalHeight}`
1410
+ );
1411
+ const cancelBtn = h("button", { class: "rt-editor__btn-cancel" }, "Cancel");
1412
+ const doneBtn = h("button", { class: "rt-editor__btn-done" }, "Done");
1413
+ const topbar = h(
1414
+ "div",
1415
+ { class: "rt-editor__topbar" },
1416
+ h("div", { class: "rt-editor__topbar-left" }, filenameEl, dimsEl),
1417
+ h("div", { class: "rt-editor__topbar-right" }, cancelBtn, doneBtn)
1418
+ );
1419
+ const canvasContainer = h("div", { class: "rt-editor__canvas-container" });
1420
+ const canvasArea = h("div", { class: "rt-editor__canvas-area" }, canvasContainer);
1421
+ const renderer = new CanvasRenderer(canvasContainer, entry.image, entry.edits);
1422
+ const cropTool = createCropTool({
1423
+ container: canvasContainer,
1424
+ renderer,
1425
+ edits: entry.edits,
1426
+ onChange: (crop) => {
1427
+ entry.edits.crop = crop;
1428
+ renderer.render();
1429
+ }
1430
+ });
1431
+ const adjustTool = createAdjustTool({
1432
+ adjustments: entry.edits.adjustments,
1433
+ onChange: (adj) => {
1434
+ entry.edits.adjustments = adj;
1435
+ renderer.setAdjustments(adj);
1436
+ renderer.render();
1437
+ }
1438
+ });
1439
+ const propsPanel = createPropertiesPanel({
1440
+ cropTool,
1441
+ adjustTool,
1442
+ edits: entry.edits,
1443
+ onRotationChange: (deg) => {
1444
+ entry.edits.rotation = deg;
1445
+ renderer.setRotation(deg);
1446
+ renderer.render();
1447
+ }
1448
+ });
1449
+ const toolbar = createToolbar({
1450
+ activeTool,
1451
+ onToolChange: (tool) => {
1452
+ activeTool = tool;
1453
+ cropTool.setVisible(tool === "crop");
1454
+ propsPanel.setActiveTool(tool);
1455
+ }
1456
+ });
1457
+ const body = h("div", { class: "rt-editor__body" }, toolbar.root, canvasArea, propsPanel.root);
1458
+ const root = h("div", { class: "rt-editor-overlay" }, topbar, body);
1459
+ cancelBtn.addEventListener(
1460
+ "click",
1461
+ () => {
1462
+ Object.assign(entry.edits, editSnapshot);
1463
+ onCancel();
1464
+ },
1465
+ { signal }
1466
+ );
1467
+ doneBtn.addEventListener(
1468
+ "click",
1469
+ () => {
1470
+ onDone();
1471
+ },
1472
+ { signal }
1473
+ );
1474
+ renderer.render();
1475
+ return {
1476
+ root,
1477
+ destroy() {
1478
+ abort.abort();
1479
+ cropTool.destroy();
1480
+ adjustTool.destroy();
1481
+ propsPanel.destroy();
1482
+ toolbar.destroy();
1483
+ renderer.destroy();
1484
+ root.remove();
1485
+ }
1486
+ };
1487
+ }
1488
+
1489
+ // src/ui/gallery.ts
1490
+ function createGallery(options) {
1491
+ const abort = new AbortController();
1492
+ const signal = abort.signal;
1493
+ const input = h("input", {
1494
+ type: "file",
1495
+ accept: "image/*",
1496
+ multiple: true,
1497
+ style: "display:none"
1498
+ });
1499
+ input.addEventListener(
1500
+ "change",
1501
+ () => {
1502
+ if (input.files && input.files.length > 0) {
1503
+ options.onAddMore(Array.from(input.files));
1504
+ input.value = "";
1505
+ }
1506
+ },
1507
+ { signal }
1508
+ );
1509
+ const countEl = h("div", { class: "rt-gallery__count" });
1510
+ const updateCount = () => {
1511
+ const n = grid.children.length;
1512
+ countEl.innerHTML = `<strong>${n}</strong> image${n !== 1 ? "s" : ""}`;
1513
+ };
1514
+ const addMoreBtn = h(
1515
+ "button",
1516
+ {
1517
+ class: "rt-btn",
1518
+ onClick: () => input.click()
1519
+ },
1520
+ createPlusIcon(),
1521
+ "Add more"
1522
+ );
1523
+ const doneBtn = h(
1524
+ "button",
1525
+ {
1526
+ class: "rt-btn rt-btn--accent",
1527
+ onClick: () => options.onDone()
1528
+ },
1529
+ "Done"
1530
+ );
1531
+ const grid = h("div", { class: "rt-gallery__grid" });
1532
+ for (const entry of options.images) {
1533
+ grid.appendChild(createGridItem(entry, options, signal));
1534
+ }
1535
+ const toolbar = h(
1536
+ "div",
1537
+ { class: "rt-gallery__toolbar" },
1538
+ countEl,
1539
+ h("div", { class: "rt-gallery__actions" }, addMoreBtn, input)
1540
+ );
1541
+ const footer = h("div", { class: "rt-gallery__footer" }, doneBtn);
1542
+ const root = h("div", { class: "rt-gallery" }, toolbar, grid, footer);
1543
+ updateCount();
1544
+ return {
1545
+ root,
1546
+ destroy() {
1547
+ abort.abort();
1548
+ root.remove();
1549
+ }
1550
+ };
1551
+ }
1552
+ function createGridItem(entry, options, signal) {
1553
+ const img = h("img", { src: entry.thumbnailUrl, alt: entry.file.name });
1554
+ const statusClass = entry.edited ? "rt-gallery__item-status--edited" : "rt-gallery__item-status--pending";
1555
+ const status = h("div", { class: `rt-gallery__item-status ${statusClass}` });
1556
+ const removeBtn = h("button", { class: "rt-gallery__item-remove" });
1557
+ removeBtn.innerHTML = '<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 2l8 8M10 2l-8 8"/></svg>';
1558
+ const editBtn = h("button", { class: "rt-gallery__item-edit" });
1559
+ editBtn.innerHTML = '<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8.5 1.5a1.414 1.414 0 012 2L4 10l-2.5.5L2 8l6.5-6.5z"/></svg> Edit';
1560
+ const overlay = h(
1561
+ "div",
1562
+ { class: "rt-gallery__item-overlay" },
1563
+ h(
1564
+ "div",
1565
+ { class: "rt-gallery__item-info" },
1566
+ h("span", { class: "rt-gallery__item-name" }, entry.file.name),
1567
+ editBtn
1568
+ )
1569
+ );
1570
+ const item = h(
1571
+ "div",
1572
+ { class: "rt-gallery__item", "data-id": entry.id },
1573
+ img,
1574
+ status,
1575
+ removeBtn,
1576
+ overlay
1577
+ );
1578
+ editBtn.addEventListener(
1579
+ "click",
1580
+ (e) => {
1581
+ e.stopPropagation();
1582
+ options.onEdit(entry.id);
1583
+ },
1584
+ { signal }
1585
+ );
1586
+ removeBtn.addEventListener(
1587
+ "click",
1588
+ (e) => {
1589
+ e.stopPropagation();
1590
+ item.remove();
1591
+ options.onRemove(entry.id);
1592
+ },
1593
+ { signal }
1594
+ );
1595
+ return item;
1596
+ }
1597
+ function createPlusIcon() {
1598
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1599
+ svg.setAttribute("viewBox", "0 0 16 16");
1600
+ svg.setAttribute("fill", "none");
1601
+ svg.setAttribute("stroke", "currentColor");
1602
+ svg.setAttribute("stroke-width", "1.5");
1603
+ const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
1604
+ p.setAttribute("d", "M8 3v10M3 8h10");
1605
+ svg.appendChild(p);
1606
+ return svg;
1607
+ }
1608
+
1609
+ // src/utils/image.ts
1610
+ function generateId() {
1611
+ return crypto.randomUUID();
1612
+ }
1613
+ function isAcceptedType(file, accepted) {
1614
+ return accepted.some((type) => {
1615
+ if (type.endsWith("/*")) {
1616
+ return file.type.startsWith(type.slice(0, -1));
1617
+ }
1618
+ return file.type === type;
1619
+ });
1620
+ }
1621
+ function createThumbnailUrl(file) {
1622
+ return URL.createObjectURL(file);
1623
+ }
1624
+ function revokeThumbnailUrl(url) {
1625
+ URL.revokeObjectURL(url);
1626
+ }
1627
+ function loadImage(file) {
1628
+ return new Promise((resolve, reject) => {
1629
+ const img = new Image();
1630
+ const url = URL.createObjectURL(file);
1631
+ img.onload = () => {
1632
+ URL.revokeObjectURL(url);
1633
+ resolve(img);
1634
+ };
1635
+ img.onerror = () => {
1636
+ URL.revokeObjectURL(url);
1637
+ reject(new Error(`[Retouch] Failed to load image: ${file.name}`));
1638
+ };
1639
+ img.src = url;
1640
+ });
1641
+ }
1642
+ async function processFiles(files, acceptedTypes) {
1643
+ const entries = [];
1644
+ const fileArray = Array.from(files);
1645
+ for (const file of fileArray) {
1646
+ if (!isAcceptedType(file, acceptedTypes)) continue;
1647
+ const image = await loadImage(file);
1648
+ entries.push({
1649
+ id: generateId(),
1650
+ file,
1651
+ image,
1652
+ thumbnailUrl: createThumbnailUrl(file),
1653
+ edits: structuredClone(DEFAULT_EDITS),
1654
+ edited: false
1655
+ });
1656
+ }
1657
+ return entries;
1658
+ }
1659
+ function buildFilterString(adj) {
1660
+ return `brightness(${adj.brightness / 100}) contrast(${adj.contrast / 100}) saturate(${adj.saturation / 100})`;
1661
+ }
1662
+ function exportImage(image, edits) {
1663
+ return new Promise((resolve, reject) => {
1664
+ const { crop, rotation, adjustments } = edits;
1665
+ const sx = crop.x * image.naturalWidth;
1666
+ const sy = crop.y * image.naturalHeight;
1667
+ const sw = crop.width * image.naturalWidth;
1668
+ const sh = crop.height * image.naturalHeight;
1669
+ const radians = rotation * Math.PI / 180;
1670
+ const cos = Math.abs(Math.cos(radians));
1671
+ const sin = Math.abs(Math.sin(radians));
1672
+ const outWidth = Math.round(sw * cos + sh * sin);
1673
+ const outHeight = Math.round(sh * cos + sw * sin);
1674
+ const canvas = document.createElement("canvas");
1675
+ canvas.width = outWidth;
1676
+ canvas.height = outHeight;
1677
+ const ctx = canvas.getContext("2d");
1678
+ if (!ctx) {
1679
+ reject(new Error("[Retouch] Failed to create export canvas context"));
1680
+ return;
1681
+ }
1682
+ ctx.filter = buildFilterString(adjustments);
1683
+ ctx.translate(outWidth / 2, outHeight / 2);
1684
+ ctx.rotate(radians);
1685
+ ctx.drawImage(image, sx, sy, sw, sh, -sw / 2, -sh / 2, sw, sh);
1686
+ canvas.toBlob((blob) => {
1687
+ if (blob) {
1688
+ resolve(blob);
1689
+ } else {
1690
+ reject(new Error("[Retouch] Failed to export image as blob"));
1691
+ }
1692
+ }, "image/png");
1693
+ });
1694
+ }
1695
+
1696
+ // src/retouch.ts
1697
+ var STATE_TRANSITIONS = {
1698
+ idle: ["dropzone"],
1699
+ dropzone: ["gallery", "destroyed"],
1700
+ gallery: ["editor", "dropzone", "destroyed"],
1701
+ editor: ["gallery", "destroyed"],
1702
+ destroyed: []
1703
+ };
1704
+ var Retouch = class {
1705
+ root;
1706
+ container;
1707
+ images = /* @__PURE__ */ new Map();
1708
+ sm;
1709
+ emitter = new EventEmitter();
1710
+ options;
1711
+ currentView = null;
1712
+ editingImageId = null;
1713
+ constructor(options) {
1714
+ if (typeof options.target === "string") {
1715
+ const el = document.querySelector(options.target);
1716
+ if (!el) {
1717
+ throw new Error(`[Retouch] Target element not found: ${options.target}`);
1718
+ }
1719
+ this.container = el;
1720
+ } else {
1721
+ this.container = options.target;
1722
+ }
1723
+ this.options = {
1724
+ maxFiles: options.maxFiles ?? Number.POSITIVE_INFINITY,
1725
+ acceptedTypes: options.acceptedTypes ?? ACCEPTED_TYPES,
1726
+ onDone: options.onDone
1727
+ };
1728
+ injectStyles();
1729
+ this.root = h("div", { class: "rt-root" });
1730
+ this.container.appendChild(this.root);
1731
+ this.sm = new StateMachine("idle", STATE_TRANSITIONS);
1732
+ this.sm.onChange(({ to }) => {
1733
+ this.handleStateChange(to);
1734
+ });
1735
+ this.sm.transition("dropzone");
1736
+ }
1737
+ get state() {
1738
+ return this.sm.state;
1739
+ }
1740
+ on(event, fn) {
1741
+ return this.emitter.on(event, fn);
1742
+ }
1743
+ async addFiles(files) {
1744
+ const remaining = this.options.maxFiles - this.images.size;
1745
+ if (remaining <= 0) return;
1746
+ const sliced = files.slice(0, remaining);
1747
+ const entries = await processFiles(sliced, this.options.acceptedTypes);
1748
+ if (entries.length === 0) return;
1749
+ for (const entry of entries) {
1750
+ this.images.set(entry.id, entry);
1751
+ }
1752
+ this.emitter.emit("images:add", { entries });
1753
+ if (this.sm.state === "dropzone") {
1754
+ this.sm.transition("gallery");
1755
+ }
1756
+ }
1757
+ removeImage(id) {
1758
+ const entry = this.images.get(id);
1759
+ if (!entry) return;
1760
+ revokeThumbnailUrl(entry.thumbnailUrl);
1761
+ this.images.delete(id);
1762
+ this.emitter.emit("images:remove", { id });
1763
+ if (this.images.size === 0 && this.sm.state === "gallery") {
1764
+ this.sm.transition("dropzone");
1765
+ }
1766
+ }
1767
+ openEditor(id) {
1768
+ if (!this.images.has(id)) return;
1769
+ this.editingImageId = id;
1770
+ this.emitter.emit("editor:open", { id });
1771
+ this.sm.transition("editor");
1772
+ }
1773
+ closeEditor(commit) {
1774
+ if (this.sm.state !== "editor" || !this.editingImageId) return;
1775
+ const id = this.editingImageId;
1776
+ const entry = this.images.get(id);
1777
+ if (commit && entry) {
1778
+ entry.edited = true;
1779
+ this.emitter.emit("editor:done", { id, edits: entry.edits });
1780
+ } else {
1781
+ this.emitter.emit("editor:cancel", { id });
1782
+ }
1783
+ this.editingImageId = null;
1784
+ this.sm.transition("gallery");
1785
+ }
1786
+ getEditingEntry() {
1787
+ if (!this.editingImageId) return null;
1788
+ return this.images.get(this.editingImageId) ?? null;
1789
+ }
1790
+ getImages() {
1791
+ return Array.from(this.images.values());
1792
+ }
1793
+ async exportAll() {
1794
+ const blobs = [];
1795
+ for (const entry of this.images.values()) {
1796
+ const blob = await exportImage(entry.image, entry.edits);
1797
+ blobs.push(blob);
1798
+ }
1799
+ return blobs;
1800
+ }
1801
+ async done() {
1802
+ const blobs = await this.exportAll();
1803
+ this.emitter.emit("done", { blobs });
1804
+ this.options.onDone?.(blobs);
1805
+ }
1806
+ destroy() {
1807
+ if (this.sm.state === "destroyed") return;
1808
+ this.unmountCurrentView();
1809
+ for (const entry of this.images.values()) {
1810
+ revokeThumbnailUrl(entry.thumbnailUrl);
1811
+ }
1812
+ this.images.clear();
1813
+ this.root.remove();
1814
+ this.sm.transition("destroyed");
1815
+ this.sm.destroy();
1816
+ this.emitter.removeAll();
1817
+ }
1818
+ // ── View lifecycle ────────────────────────
1819
+ handleStateChange(to) {
1820
+ this.unmountCurrentView();
1821
+ switch (to) {
1822
+ case "dropzone":
1823
+ this.mountDropZone();
1824
+ break;
1825
+ case "gallery":
1826
+ this.mountGallery();
1827
+ break;
1828
+ case "editor":
1829
+ this.mountEditor();
1830
+ break;
1831
+ }
1832
+ }
1833
+ unmountCurrentView() {
1834
+ if (this.currentView) {
1835
+ this.currentView.destroy();
1836
+ this.currentView = null;
1837
+ }
1838
+ }
1839
+ mountDropZone() {
1840
+ const view = createDropZone({
1841
+ onFiles: (files) => this.addFiles(files)
1842
+ });
1843
+ this.root.appendChild(view.root);
1844
+ this.currentView = view;
1845
+ }
1846
+ mountGallery() {
1847
+ const view = createGallery({
1848
+ images: this.getImages(),
1849
+ onEdit: (id) => this.openEditor(id),
1850
+ onRemove: (id) => this.removeImage(id),
1851
+ onAddMore: (files) => this.addFiles(files),
1852
+ onDone: () => this.done()
1853
+ });
1854
+ this.root.appendChild(view.root);
1855
+ this.currentView = view;
1856
+ }
1857
+ mountEditor() {
1858
+ const entry = this.getEditingEntry();
1859
+ if (!entry) return;
1860
+ const view = createEditor({
1861
+ entry,
1862
+ onDone: () => this.closeEditor(true),
1863
+ onCancel: () => this.closeEditor(false)
1864
+ });
1865
+ this.root.appendChild(view.root);
1866
+ this.currentView = view;
1867
+ }
1868
+ };
1869
+
1870
+ exports.Retouch = Retouch;
1871
+ exports.VERSION = VERSION;
1872
+ //# sourceMappingURL=index.cjs.map
1873
+ //# sourceMappingURL=index.cjs.map