image-to-pixel 3.1.0 → 3.2.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 +4 -1
- package/dist/editor.css +1 -1
- package/dist/editor.css.map +2 -2
- package/dist/editor.esm.js +54 -54
- package/dist/editor.esm.js.map +4 -4
- package/dist/image-to-pixel.bundle.esm.js +13 -13
- package/dist/image-to-pixel.bundle.esm.js.map +3 -3
- package/dist/image-to-pixel.cjs +13 -9
- package/dist/image-to-pixel.cjs.map +2 -2
- package/dist/image-to-pixel.esm.js +13 -9
- package/dist/image-to-pixel.esm.js.map +2 -2
- package/dist/image-to-pixel.js +13 -9
- package/dist/image-to-pixel.js.map +2 -2
- package/dist/image-to-pixel.min.js +11 -11
- package/dist/image-to-pixel.min.js.map +3 -3
- package/dist/video.esm.js +2 -2
- package/dist/video.esm.js.map +3 -3
- package/lib/core/video/frames.js +189 -182
- package/lib/editor/exporter.js +267 -225
- package/lib/editor/index.js +1051 -1002
- package/lib/editor/inspector.js +379 -343
- package/lib/editor/storyboard.js +566 -0
- package/lib/editor/styles/editor.css +455 -418
- package/lib/editor/timeline-preview.js +142 -0
- package/package.json +1 -1
- package/types/index.d.ts +2 -0
package/lib/editor/index.js
CHANGED
|
@@ -1,1002 +1,1051 @@
|
|
|
1
|
-
// The editor UI. mountEditor(element) builds the whole app inside `element`.
|
|
2
|
-
// The core engine (lib/core) has no idea this file exists.
|
|
3
|
-
|
|
4
|
-
import { createPixelator } from '../core/engine.js';
|
|
5
|
-
import { defaultSettings, decodeSettings, encodeSettings, settingsToJSON, settingsFromJSON, mergeSettings } from '../core/settings.js';
|
|
6
|
-
import { parsePalette, toGPL, toHexFile, toPaletteJSON } from '../core/palette/parse.js';
|
|
7
|
-
import { fetchLospec } from '../core/palette/lospec.js';
|
|
8
|
-
import { detectGrid } from '../core/export/scale.js';
|
|
9
|
-
import { toHex } from '../core/color.js';
|
|
10
|
-
import { estimateFps, videoReady, seekTo, sniffVideoCodec, hasAudioTrack } from '../core/video/frames.js';
|
|
11
|
-
import { downloadBlob } from '../core/export/canvas.js';
|
|
12
|
-
import { PRESETS } from '../core/presets.js';
|
|
13
|
-
import { h, render, pickFiles } from './dom.js';
|
|
14
|
-
import { icon } from './icons.js';
|
|
15
|
-
import { createStore } from './store.js';
|
|
16
|
-
import { createStage } from './stage.js';
|
|
17
|
-
import { createInspector } from './inspector.js';
|
|
18
|
-
import { createLibrary, fmtTime } from './library.js';
|
|
19
|
-
import { createExporter } from './exporter.js';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
/** Settings
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
fileName
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
h('
|
|
93
|
-
h('
|
|
94
|
-
|
|
95
|
-
h('span.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
h('
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
btn('
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (app.result) updateStatus();
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
app.
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
/**
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
const
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
}
|
|
695
|
-
function
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
if (
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
const
|
|
713
|
-
|
|
714
|
-
if (
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
const
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
function
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
const
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
const
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
const
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
if (
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
const
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
} }
|
|
831
|
-
});
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
};
|
|
858
|
-
app.
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
};
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
const
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
const
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
if (
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
if (
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1
|
+
// The editor UI. mountEditor(element) builds the whole app inside `element`.
|
|
2
|
+
// The core engine (lib/core) has no idea this file exists.
|
|
3
|
+
|
|
4
|
+
import { createPixelator } from '../core/engine.js';
|
|
5
|
+
import { defaultSettings, decodeSettings, encodeSettings, settingsToJSON, settingsFromJSON, mergeSettings } from '../core/settings.js';
|
|
6
|
+
import { parsePalette, toGPL, toHexFile, toPaletteJSON } from '../core/palette/parse.js';
|
|
7
|
+
import { fetchLospec } from '../core/palette/lospec.js';
|
|
8
|
+
import { detectGrid } from '../core/export/scale.js';
|
|
9
|
+
import { toHex } from '../core/color.js';
|
|
10
|
+
import { estimateFps, videoReady, seekTo, sniffVideoCodec, hasAudioTrack } from '../core/video/frames.js';
|
|
11
|
+
import { downloadBlob } from '../core/export/canvas.js';
|
|
12
|
+
import { PRESETS } from '../core/presets.js';
|
|
13
|
+
import { h, render, pickFiles } from './dom.js';
|
|
14
|
+
import { icon } from './icons.js';
|
|
15
|
+
import { createStore } from './store.js';
|
|
16
|
+
import { createStage } from './stage.js';
|
|
17
|
+
import { createInspector } from './inspector.js';
|
|
18
|
+
import { createLibrary, fmtTime } from './library.js';
|
|
19
|
+
import { createExporter } from './exporter.js';
|
|
20
|
+
import { createVideoStoryboard } from './storyboard.js';
|
|
21
|
+
import { createTimelinePreview } from './timeline-preview.js';
|
|
22
|
+
import { toast, menu, closeMenu, dialog } from './ui.js';
|
|
23
|
+
import { SAMPLES, loadSample, sampleURL } from './samples.js';
|
|
24
|
+
import * as persist from './persist.js';
|
|
25
|
+
import { saveWorkspace, loadWorkspace, forgetWorkspace, isQuotaError, fmtBytes } from './workspace.js';
|
|
26
|
+
|
|
27
|
+
const IMAGE_TYPES = /^image\//;
|
|
28
|
+
const VIDEO_TYPES = /^video\//;
|
|
29
|
+
const PALETTE_EXT = /\.(gpl|hex|pal|txt|json)$/i;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mount the editor.
|
|
33
|
+
* @param {HTMLElement} root
|
|
34
|
+
* @param {{ settings?: object, persist?: boolean, samples?: boolean, homeHref?: string, docsHref?: string }} [options]
|
|
35
|
+
*/
|
|
36
|
+
export async function mountEditor(root, options = {}) {
|
|
37
|
+
const usePersist = options.persist !== false;
|
|
38
|
+
const narrow = () => matchMedia('(max-width: 900px)').matches;
|
|
39
|
+
|
|
40
|
+
// ---- initial settings: share link > saved > defaults
|
|
41
|
+
let initial = options.settings || (usePersist && persist.load('settings', null)) || defaultSettings();
|
|
42
|
+
if (/[#&]s=/.test(location.hash)) {
|
|
43
|
+
try { initial = decodeSettings(location.hash); } catch { toast('That share link could not be read; using your last settings', { kind: 'error' }); }
|
|
44
|
+
}
|
|
45
|
+
const store = createStore(initial);
|
|
46
|
+
|
|
47
|
+
const app = {
|
|
48
|
+
store,
|
|
49
|
+
engine: null,
|
|
50
|
+
items: [],
|
|
51
|
+
current: null,
|
|
52
|
+
result: null,
|
|
53
|
+
exporting: false,
|
|
54
|
+
activePreset: null,
|
|
55
|
+
userPalettes: usePersist ? persist.load('palettes', []) : [],
|
|
56
|
+
/** Settings shared by every file that doesn't have its own. */
|
|
57
|
+
shared: store.settings,
|
|
58
|
+
/** Settings copied with "Copy settings", ready to paste onto another file. */
|
|
59
|
+
clipboard: null,
|
|
60
|
+
videoSupport: { mp4: false, webm: false },
|
|
61
|
+
toast: (m, o) => toast(m, o),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ================================================================ layout
|
|
65
|
+
const stage = createStage({ onPick: pickAt, onViewChange: () => updateZoomLabel() });
|
|
66
|
+
const inspector = createInspector(app);
|
|
67
|
+
const library = createLibrary(app);
|
|
68
|
+
const exporter = createExporter(app);
|
|
69
|
+
|
|
70
|
+
const btn = (ic, label, onclick, extra = {}) => h('button.itp-btn.icon', { type: 'button', 'aria-label': label, title: label, html: icon(ic), onclick, ...extra });
|
|
71
|
+
|
|
72
|
+
const fileName = h('span.name', { text: 'No image' });
|
|
73
|
+
// click the name to rename (F2); the extension stays as it was
|
|
74
|
+
fileName.addEventListener('click', () => { if (app.current) startRename(app.current); });
|
|
75
|
+
const fileMeta = h('span.meta.num', {});
|
|
76
|
+
const undoBtn = btn('undo', 'Undo (Ctrl+Z)', () => store.undo());
|
|
77
|
+
const redoBtn = btn('redo', 'Redo (Ctrl+Shift+Z)', () => store.redo());
|
|
78
|
+
const compareBtn = btn('compare', 'Compare with original (C)', () => toggleCompare(), { 'aria-pressed': 'false' });
|
|
79
|
+
const gridBtn = btn('grid', 'Pixel grid (G)', () => toggleGrid(), { 'aria-pressed': 'false' });
|
|
80
|
+
const zoomBtn = h('button.itp-btn.itp-zoom.num', { type: 'button', 'aria-label': 'Zoom', 'aria-haspopup': 'menu', html: `<span>Fit</span>${icon('chevronDown')}`, onclick: e => zoomMenu(e.currentTarget) });
|
|
81
|
+
const themeBtn = btn('moon', 'Toggle theme (T)', () => toggleTheme());
|
|
82
|
+
const libBtn = btn('sidebarLeft', 'Toggle library (\\)', () => togglePanel('lib'));
|
|
83
|
+
const inspBtn = btn('sidebarRight', 'Toggle inspector (/)', () => togglePanel('insp'));
|
|
84
|
+
const helpBtn = btn('keyboard', 'Keyboard shortcuts (?)', () => showHelp());
|
|
85
|
+
const exportBtn = h('button.itp-btn.primary', { type: 'button', 'aria-haspopup': 'menu', html: `${icon('download')}<span>Export</span>`, onclick: e => exportMenu(e.currentTarget) });
|
|
86
|
+
const saveBtn = h('button.itp-btn.outline', { type: 'button', title: 'Save these files and their settings in this browser', html: `${icon('save')}<span class="hide-sm">Save</span>`, onclick: () => saveNow() });
|
|
87
|
+
saveBtn.hidden = !usePersist;
|
|
88
|
+
const brand = h('a.itp-brand', { href: options.homeHref || '#', title: 'Image-to-Pixel' }, h('span.itp-mark', { 'aria-hidden': 'true' }, ...Array.from({ length: 9 }, () => h('i'))), h('span.hide-sm', { text: 'Image-to-Pixel' }));
|
|
89
|
+
if (!options.homeHref) brand.addEventListener('click', e => e.preventDefault());
|
|
90
|
+
|
|
91
|
+
const top = h('header.itp-top', {},
|
|
92
|
+
libBtn, brand, h('span.sep.hide-sm'),
|
|
93
|
+
h('div.itp-file', {}, fileName, fileMeta),
|
|
94
|
+
h('div.spacer'),
|
|
95
|
+
h('span.hide-sm.contents', {}, undoBtn, redoBtn, h('span.sep')),
|
|
96
|
+
compareBtn, gridBtn, zoomBtn,
|
|
97
|
+
h('span.sep.hide-sm'), h('span.hide-sm.contents', {}, themeBtn, helpBtn), inspBtn,
|
|
98
|
+
h('span', { style: { width: '6px' } }), saveBtn, exportBtn);
|
|
99
|
+
|
|
100
|
+
// stage extras
|
|
101
|
+
const sampleButtons = options.samples === false ? null : h('div.samples', {}, ...SAMPLES.map(s => h('button', { type: 'button', title: `Try "${s.name}"`, 'aria-label': `Open sample: ${s.name}`, onclick: () => openSample(s) }, h('img', { src: sampleURL(s), alt: '' }))));
|
|
102
|
+
const empty = h('div.itp-empty', {}, h('div.itp-drop', {},
|
|
103
|
+
h('h2', { text: 'Drop an image or a video' }),
|
|
104
|
+
h('p', { text: 'PNG, JPG, WebP, GIF, SVG, MP4 or WebM. Add several files to process them as a batch. You can also paste with Ctrl+V.' }),
|
|
105
|
+
h('div.actions', {},
|
|
106
|
+
h('button.itp-btn.primary', { type: 'button', html: `${icon('upload')}<span>Choose files</span>`, onclick: () => openFiles() }),
|
|
107
|
+
h('button.itp-btn.outline', { type: 'button', html: `${icon('clipboard')}<span>Paste</span>`, onclick: () => pasteFromClipboard() })),
|
|
108
|
+
sampleButtons ? h('p', { style: { fontSize: 'var(--fs-xs)', color: 'var(--faint)' }, text: 'Or start from a sample' }) : null,
|
|
109
|
+
sampleButtons));
|
|
110
|
+
const status = h('div.itp-float.bl', { 'aria-live': 'polite' });
|
|
111
|
+
const busy = h('div.itp-busy.itp-float', { hidden: true, style: { padding: '6px 10px', alignItems: 'center', gap: '8px', fontSize: 'var(--fs-xs)', color: 'var(--muted)', display: 'flex' } }, h('span.itp-spinner'), h('span', { text: 'Rendering' }));
|
|
112
|
+
const viewTools = h('div.itp-float.br', {},
|
|
113
|
+
btn('zoomOut', 'Zoom out (-)', () => stage.zoomOut()),
|
|
114
|
+
btn('fit', 'Fit to view (F)', () => stage.fit()),
|
|
115
|
+
btn('zoomIn', 'Zoom in (+)', () => stage.zoomIn()));
|
|
116
|
+
stage.el.append(empty, status, busy, viewTools);
|
|
117
|
+
|
|
118
|
+
// transport
|
|
119
|
+
const playBtn = btn('play', 'Play (Space)', () => togglePlay());
|
|
120
|
+
const timeLabel = h('span.itp-time', {});
|
|
121
|
+
const scrub = h('input.itp-scrub', { type: 'range', min: 0, max: 1000, value: 0, 'aria-label': 'Video position' });
|
|
122
|
+
const fpsLabel = h('span.muted.num', { style: { fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' } });
|
|
123
|
+
// sound: muted by default; the choice (and volume) is remembered and applies to every video
|
|
124
|
+
const sound = { muted: persist.load('muted', true) !== false, volume: Number(persist.load('volume', 1)) };
|
|
125
|
+
if (!(sound.volume >= 0 && sound.volume <= 1)) sound.volume = 1;
|
|
126
|
+
// YouTube-style: the speaker button opens a vertical volume slider; all the way down is mute.
|
|
127
|
+
const muteBtn = btn('volumeOff', 'Volume (M mutes)', () => toggleVolPop(), { 'aria-haspopup': 'true', 'aria-expanded': 'false' });
|
|
128
|
+
const volFill = h('div.fill');
|
|
129
|
+
const volThumb = h('div.thumb');
|
|
130
|
+
const volSlider = h('div.itp-vslider', { role: 'slider', tabindex: '0', 'aria-label': 'Volume', 'aria-orientation': 'vertical', 'aria-valuemin': '0', 'aria-valuemax': '100' }, h('div.track', {}, volFill, volThumb));
|
|
131
|
+
const volValue = h('span.num', {});
|
|
132
|
+
const volPop = h('div.itp-volpop', { hidden: true }, volValue, volSlider);
|
|
133
|
+
const soundBox = h('div.itp-sound', { hidden: true }, volPop, muteBtn);
|
|
134
|
+
const setLevel = v => setSound(v <= 0.005 ? { muted: true } : { muted: false, volume: Math.min(1, v) });
|
|
135
|
+
const levelAt = e => { const r = volSlider.querySelector('.track').getBoundingClientRect(); return Math.max(0, Math.min(1, (r.bottom - e.clientY) / r.height)); };
|
|
136
|
+
volSlider.addEventListener('pointerdown', e => {
|
|
137
|
+
e.preventDefault(); volSlider.focus(); volSlider.setPointerCapture(e.pointerId);
|
|
138
|
+
const before = sound.muted ? 0 : sound.volume;
|
|
139
|
+
setLevel(levelAt(e));
|
|
140
|
+
const move = ev => setLevel(levelAt(ev));
|
|
141
|
+
const up = () => {
|
|
142
|
+
volSlider.removeEventListener('pointermove', move); volSlider.removeEventListener('pointerup', up); volSlider.removeEventListener('pointercancel', up);
|
|
143
|
+
// dragged down to mute: M brings back the volume it had before the drag
|
|
144
|
+
if (sound.muted && before > 0) setSound({ muted: true, volume: before });
|
|
145
|
+
};
|
|
146
|
+
volSlider.addEventListener('pointermove', move); volSlider.addEventListener('pointerup', up); volSlider.addEventListener('pointercancel', up);
|
|
147
|
+
});
|
|
148
|
+
volSlider.addEventListener('keydown', e => {
|
|
149
|
+
const cur = sound.muted ? 0 : sound.volume;
|
|
150
|
+
const next = { ArrowUp: cur + 0.05, ArrowRight: cur + 0.05, ArrowDown: cur - 0.05, ArrowLeft: cur - 0.05, PageUp: cur + 0.2, PageDown: cur - 0.2, Home: 0, End: 1 }[e.key];
|
|
151
|
+
if (next === undefined) { if (e.key === 'Escape') { e.preventDefault(); toggleVolPop(false); muteBtn.focus(); } return; }
|
|
152
|
+
e.preventDefault(); e.stopPropagation(); setLevel(next);
|
|
153
|
+
});
|
|
154
|
+
volSlider.addEventListener('wheel', e => { e.preventDefault(); setLevel((sound.muted ? 0 : sound.volume) - Math.sign(e.deltaY) * 0.05); }, { passive: false });
|
|
155
|
+
function toggleVolPop(open = volPop.hidden) {
|
|
156
|
+
volPop.hidden = !open;
|
|
157
|
+
muteBtn.setAttribute('aria-expanded', String(open));
|
|
158
|
+
if (open) {
|
|
159
|
+
const close = e => { if (!soundBox.contains(e.target)) { toggleVolPop(false); document.removeEventListener('pointerdown', close, true); } };
|
|
160
|
+
document.addEventListener('pointerdown', close, true);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const transport = h('div.itp-transport', { role: 'group', 'aria-label': 'Video' },
|
|
164
|
+
btn('stepBack', 'Previous frame (,)', () => stepFrame(-1)), playBtn, btn('stepForward', 'Next frame (.)', () => stepFrame(1)),
|
|
165
|
+
timeLabel, scrub, soundBox, fpsLabel);
|
|
166
|
+
// The frame-selection canvas replaces the preview while it is on, so its tiles feed the stats.
|
|
167
|
+
let statsResult = null;
|
|
168
|
+
function updateStats(res) {
|
|
169
|
+
if (res === statsResult) return false;
|
|
170
|
+
statsResult = res;
|
|
171
|
+
inspector.setStats(res);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const timelinePreview = createTimelinePreview(app);
|
|
175
|
+
const storyboard = createVideoStoryboard(app, {
|
|
176
|
+
onModeChange: active => {
|
|
177
|
+
timelinePreview.hide();
|
|
178
|
+
stage.el.hidden = active;
|
|
179
|
+
transport.hidden = active;
|
|
180
|
+
compareBtn.disabled = gridBtn.disabled = active;
|
|
181
|
+
if (!active) { stage.resize(); schedule(); }
|
|
182
|
+
updateZoomLabel();
|
|
183
|
+
},
|
|
184
|
+
onZoomChange: updateZoomLabel,
|
|
185
|
+
onRangeChange: range => inspector.setAnimationRange(range),
|
|
186
|
+
onSelectionChange: state => {
|
|
187
|
+
inspector.setFrameSelectionState(state);
|
|
188
|
+
const res = state.active ? state.result : app.result;
|
|
189
|
+
if (updateStats(res) && res && state.active) inspector.sync(store.settings, res);
|
|
190
|
+
},
|
|
191
|
+
animationOptions: () => inspector.animationOptions(),
|
|
192
|
+
});
|
|
193
|
+
app.setFrameSelection = enabled => storyboard.setMode(enabled);
|
|
194
|
+
app.setFrameRange = (start, end) => storyboard.setRange(start, end);
|
|
195
|
+
app.setAnimationFps = fps => storyboard.setPlaybackFps(fps);
|
|
196
|
+
app.frameSelection = () => storyboard.exportSelection();
|
|
197
|
+
/** The result the stats and size readouts describe: the main preview, or the selection's frame. */
|
|
198
|
+
const previewResult = () => (storyboard.active ? storyboard.currentResult() : app.result);
|
|
199
|
+
const stageWrap = h('main.itp-stage', { 'data-video': 'false' }, stage.el, storyboard.el, transport, storyboard.transport, timelinePreview.el);
|
|
200
|
+
timelinePreview.bind(scrub);
|
|
201
|
+
timelinePreview.bind(storyboard.range, { getFrame: storyboard.frameCanvas });
|
|
202
|
+
|
|
203
|
+
// on narrow screens the library is a drawer: a tap on the canvas closes it
|
|
204
|
+
stageWrap.addEventListener('pointerdown', () => { if (narrow() && shell.dataset.lib === 'open') togglePanel('lib'); }, true);
|
|
205
|
+
const shell = h('div.itp.itp-app', { 'data-lib': narrow() ? 'closed' : persist.load('lib', 'open'), 'data-insp': persist.load('insp', 'open') }, top, library.el, stageWrap, inspector.el);
|
|
206
|
+
render(root, shell);
|
|
207
|
+
applyTheme(persist.load('theme', null));
|
|
208
|
+
|
|
209
|
+
// ================================================================ engine
|
|
210
|
+
status.textContent = 'Starting…';
|
|
211
|
+
try {
|
|
212
|
+
app.engine = await createPixelator();
|
|
213
|
+
} catch (e) {
|
|
214
|
+
toast(`Could not start: ${e.message}`, { kind: 'error' });
|
|
215
|
+
throw e;
|
|
216
|
+
}
|
|
217
|
+
inspector.el.querySelector('#itp-backend').textContent = app.engine.backend === 'webgl' ? 'GPU · ShaderMate' : 'CPU';
|
|
218
|
+
inspector.el.querySelector('#itp-backend').title = app.engine.backend === 'webgl' ? `WebGL 2: ${app.engine.renderer}` : (app.engine.fallbackReason || 'CPU renderer');
|
|
219
|
+
import('../video.js').then(m => m.videoSupport()).then(v => { app.videoSupport = v; }).catch(() => { });
|
|
220
|
+
|
|
221
|
+
// ================================================================ rendering
|
|
222
|
+
let dirty = false, running = false, lastSource = null;
|
|
223
|
+
const outCanvas = document.createElement('canvas');
|
|
224
|
+
|
|
225
|
+
function schedule() {
|
|
226
|
+
dirty = true;
|
|
227
|
+
if (!running) requestAnimationFrame(run);
|
|
228
|
+
}
|
|
229
|
+
app.schedule = schedule;
|
|
230
|
+
|
|
231
|
+
async function run() {
|
|
232
|
+
if (running || !dirty) return;
|
|
233
|
+
if (app.exporting || storyboard.active) return;
|
|
234
|
+
dirty = false;
|
|
235
|
+
const item = app.current;
|
|
236
|
+
if (!item) { app.result = null; stage.clear(); updateStatus(); return; }
|
|
237
|
+
running = true;
|
|
238
|
+
const slow = setTimeout(() => { busy.hidden = false; }, 250);
|
|
239
|
+
try {
|
|
240
|
+
if (item.type === 'video') app.engine.invalidate();
|
|
241
|
+
const res = await app.engine.process(item.source, store.settings);
|
|
242
|
+
if (item !== app.current || storyboard.active) return;
|
|
243
|
+
app.result = res;
|
|
244
|
+
res.toCanvas(1, { canvas: outCanvas });
|
|
245
|
+
stage.show(outCanvas, item.source, res.crop);
|
|
246
|
+
updateStats(res);
|
|
247
|
+
inspector.sync(store.settings, res);
|
|
248
|
+
if (lastSource !== item) { lastSource = item; }
|
|
249
|
+
updateStatus();
|
|
250
|
+
} catch (e) {
|
|
251
|
+
console.error(e);
|
|
252
|
+
toast(`Rendering failed: ${e.message}`, { kind: 'error' });
|
|
253
|
+
} finally {
|
|
254
|
+
clearTimeout(slow); busy.hidden = true;
|
|
255
|
+
running = false;
|
|
256
|
+
if (dirty) requestAnimationFrame(run);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function updateStatus() {
|
|
261
|
+
const r = app.result, it = app.current;
|
|
262
|
+
fileName.textContent = it ? it.name : 'No image';
|
|
263
|
+
fileMeta.textContent = it ? (it.type === 'video' ? `${it.width} × ${it.height}, ${fmtTime(it.duration)}` : `${it.width} × ${it.height}`) : '';
|
|
264
|
+
empty.hidden = !!it;
|
|
265
|
+
viewTools.hidden = !it;
|
|
266
|
+
if (!it || !r) { status.hidden = true; return; }
|
|
267
|
+
status.hidden = false;
|
|
268
|
+
render(status, h('span', {}, h('b', { text: `${r.width} × ${r.height}` }), ' px'), h('span', { text: `${Math.round(stage.zoom * 100) / 100}× zoom` }));
|
|
269
|
+
undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
|
|
270
|
+
}
|
|
271
|
+
function updateZoomLabel() {
|
|
272
|
+
const view = storyboard.active ? storyboard : stage;
|
|
273
|
+
zoomBtn.querySelector('span').textContent = !app.current || view.fitted ? 'Fit' : view.zoom >= 1 ? `${Math.round(view.zoom * 10) / 10}×` : `${Math.round(view.zoom * 100)}%`;
|
|
274
|
+
if (app.result) updateStatus();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
store.subscribe((s, why) => {
|
|
278
|
+
// write the edit back to whatever is being edited: this file's own settings, or the shared ones
|
|
279
|
+
const cur = app.current;
|
|
280
|
+
if (cur && cur.own && store.target === cur.id) cur.own = s;
|
|
281
|
+
else if (store.target === 'shared') app.shared = s;
|
|
282
|
+
inspector.sync(s, previewResult());
|
|
283
|
+
undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
|
|
284
|
+
if (why !== 'preset') app.activePreset = null;
|
|
285
|
+
if (usePersist) saveSoon();
|
|
286
|
+
if (library.tab === 'palettes') library.refresh();
|
|
287
|
+
timelinePreview.reset();
|
|
288
|
+
storyboard.refresh();
|
|
289
|
+
schedule();
|
|
290
|
+
});
|
|
291
|
+
let saveT = null;
|
|
292
|
+
const saveSoon = () => { clearTimeout(saveT); saveT = setTimeout(() => persist.save('settings', app.shared), 300); };
|
|
293
|
+
|
|
294
|
+
// ================================================================ files
|
|
295
|
+
async function makeThumb(drawable, w, hgt) {
|
|
296
|
+
const c = document.createElement('canvas');
|
|
297
|
+
const k = 72 / Math.max(w, hgt);
|
|
298
|
+
c.width = Math.max(1, Math.round(w * k)); c.height = Math.max(1, Math.round(hgt * k));
|
|
299
|
+
try { c.getContext('2d').drawImage(drawable, 0, 0, c.width, c.height); } catch { /* not ready */ }
|
|
300
|
+
return c.toDataURL();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function addImage(name, drawable, file = null) {
|
|
304
|
+
const w = drawable.naturalWidth || drawable.width, hh = drawable.naturalHeight || drawable.height;
|
|
305
|
+
const item = { id: crypto.randomUUID?.() || String(Math.random()), name, type: 'image', source: drawable, width: w, height: hh, file };
|
|
306
|
+
item.thumb = await makeThumb(drawable, w, hh);
|
|
307
|
+
app.items.push(item);
|
|
308
|
+
return item;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function addVideo(name, file) {
|
|
312
|
+
// No `loop` attribute: looping by hand on 'ended' is more reliable (some MOV/MP4 files stalled at the
|
|
313
|
+
// end and lost their sound after Chrome's built-in loop).
|
|
314
|
+
const video = h('video', { muted: true, playsInline: true, preload: 'auto', crossOrigin: 'anonymous' });
|
|
315
|
+
video.muted = true;
|
|
316
|
+
video.src = URL.createObjectURL(file);
|
|
317
|
+
try {
|
|
318
|
+
await videoReady(video);
|
|
319
|
+
} catch (e) {
|
|
320
|
+
URL.revokeObjectURL(video.src);
|
|
321
|
+
if (e.code === 3 || e.code === 4) {
|
|
322
|
+
const codec = await sniffVideoCodec(file).catch(() => '');
|
|
323
|
+
const alt = codec === 'H.264' ? 'WebM (VP9)' : codec === 'VP9' ? 'H.264 MP4' : 'H.264 MP4 or WebM (VP9)';
|
|
324
|
+
const err = new Error(codec
|
|
325
|
+
? `it's ${codec}, which this browser can't play. Re-save it as ${alt}, or open the editor in a browser that plays ${codec}.`
|
|
326
|
+
: `this browser can't play this video's format. Re-save it as ${alt}.`);
|
|
327
|
+
err.code = e.code;
|
|
328
|
+
throw err;
|
|
329
|
+
}
|
|
330
|
+
throw e;
|
|
331
|
+
}
|
|
332
|
+
const item = { id: crypto.randomUUID?.() || String(Math.random()), name, type: 'video', source: video, width: video.videoWidth, height: video.videoHeight, duration: video.duration, fps: 30, file };
|
|
333
|
+
await seekTo(video, Math.min(0.05, video.duration / 2)).catch(() => { });
|
|
334
|
+
item.thumb = await makeThumb(video, item.width, item.height);
|
|
335
|
+
app.items.push(item);
|
|
336
|
+
// measuring plays the video muted for a moment; Play waits for it so the two don't fight
|
|
337
|
+
item.fpsReady = estimateFps(video, 30).catch(() => 30).then(f => { item.fps = f; item.fpsReady = null; if (app.current === item) updateTransport(); inspector.syncAnim(); });
|
|
338
|
+
// null = can't tell from the container: the sound controls stay, just in case
|
|
339
|
+
hasAudioTrack(file).then(a => { item.hasAudio = a; if (app.current === item) updateTransport(); });
|
|
340
|
+
video.addEventListener('timeupdate', () => { if (app.current === item) updateTransport(); });
|
|
341
|
+
video.addEventListener('play', () => { if (app.current === item) { updateTransport(); pumpVideo(item); } });
|
|
342
|
+
video.addEventListener('pause', () => { if (app.current === item) { updateTransport(); schedule(); } });
|
|
343
|
+
video.addEventListener('seeked', () => { if (app.current === item && !app.exporting) schedule(); });
|
|
344
|
+
video.addEventListener('ended', () => {
|
|
345
|
+
if (app.current !== item || app.exporting || item.fpsReady || storyboard.active) return;
|
|
346
|
+
video.currentTime = 0;
|
|
347
|
+
video.play().catch(() => { });
|
|
348
|
+
});
|
|
349
|
+
return item;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function addFiles(files) {
|
|
353
|
+
const media = [], palettes = [], settingsFiles = [];
|
|
354
|
+
for (const f of files) {
|
|
355
|
+
if (PALETTE_EXT.test(f.name) && !/^image|^video/.test(f.type)) {
|
|
356
|
+
if (/\.json$/i.test(f.name)) settingsFiles.push(f); else palettes.push(f);
|
|
357
|
+
} else if (IMAGE_TYPES.test(f.type) || VIDEO_TYPES.test(f.type) || /\.(png|jpe?g|gif|webp|bmp|svg|avif|mp4|webm|mov)$/i.test(f.name)) media.push(f);
|
|
358
|
+
}
|
|
359
|
+
for (const f of palettes) await importPaletteFile(f);
|
|
360
|
+
for (const f of settingsFiles) {
|
|
361
|
+
const text = await f.text();
|
|
362
|
+
try {
|
|
363
|
+
const obj = JSON.parse(text);
|
|
364
|
+
if (obj.colors || Array.isArray(obj)) await importPaletteFile(f, text);
|
|
365
|
+
else { store.replace(settingsFromJSON(obj)); toast(`Loaded settings from ${f.name}`, { kind: 'ok' }); }
|
|
366
|
+
} catch (e) { toast(`${f.name}: ${e.message}`, { kind: 'error' }); }
|
|
367
|
+
}
|
|
368
|
+
let first = null;
|
|
369
|
+
const t = media.length > 2 ? toast(`Loading ${media.length} files`, { progress: true }) : null;
|
|
370
|
+
for (let i = 0; i < media.length; i++) {
|
|
371
|
+
const f = media[i];
|
|
372
|
+
try {
|
|
373
|
+
let item;
|
|
374
|
+
if (VIDEO_TYPES.test(f.type) || /\.(mp4|webm|mov)$/i.test(f.name)) item = await addVideo(f.name, f);
|
|
375
|
+
else {
|
|
376
|
+
let drawable;
|
|
377
|
+
try { drawable = await createImageBitmap(f); } catch {
|
|
378
|
+
drawable = new Image(); drawable.src = URL.createObjectURL(f); await drawable.decode();
|
|
379
|
+
}
|
|
380
|
+
item = await addImage(f.name, drawable, f);
|
|
381
|
+
}
|
|
382
|
+
first ||= item;
|
|
383
|
+
} catch (e) { toast(`${f.name} could not be opened: ${e.message}`, { kind: 'error' }); }
|
|
384
|
+
t?.progress((i + 1) / media.length);
|
|
385
|
+
}
|
|
386
|
+
t?.close();
|
|
387
|
+
if (first) select(first);
|
|
388
|
+
library.refresh();
|
|
389
|
+
if (media.length > 1) toast(`Added ${media.length} files. They share one set of settings.`, { kind: 'ok' });
|
|
390
|
+
}
|
|
391
|
+
app.addFiles = addFiles;
|
|
392
|
+
|
|
393
|
+
async function openFiles() {
|
|
394
|
+
const files = await pickFiles({ accept: 'image/*,video/*,.gpl,.hex,.pal,.txt,.json', multiple: true });
|
|
395
|
+
if (files.length) addFiles(files);
|
|
396
|
+
}
|
|
397
|
+
app.openFiles = openFiles;
|
|
398
|
+
|
|
399
|
+
async function openSample(s) {
|
|
400
|
+
const img = await loadSample(s);
|
|
401
|
+
const item = await addImage(`${s.name}.svg`, img, new File([s.svg], `${s.name}.svg`, { type: 'image/svg+xml' }));
|
|
402
|
+
select(item);
|
|
403
|
+
library.refresh();
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function pasteFromClipboard() {
|
|
407
|
+
try {
|
|
408
|
+
const items = await navigator.clipboard.read();
|
|
409
|
+
const files = [];
|
|
410
|
+
for (const it of items) for (const type of it.types) if (IMAGE_TYPES.test(type)) {
|
|
411
|
+
const b = await it.getType(type);
|
|
412
|
+
files.push(new File([b], `pasted-${new Date().toISOString().slice(11, 19).replace(/:/g, '')}.${type.split('/')[1]}`, { type }));
|
|
413
|
+
}
|
|
414
|
+
if (files.length) addFiles(files); else toast('The clipboard has no image', { kind: 'error' });
|
|
415
|
+
} catch {
|
|
416
|
+
toast('Press Ctrl+V (⌘V) to paste. The browser blocked direct clipboard access.');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
app.pasteFromClipboard = pasteFromClipboard;
|
|
420
|
+
|
|
421
|
+
function select(item) {
|
|
422
|
+
if (app.current && app.current.type === 'video' && app.current !== item) app.current.source.pause();
|
|
423
|
+
app.current = item;
|
|
424
|
+
timelinePreview.reset();
|
|
425
|
+
storyboard.setItem(item);
|
|
426
|
+
editTarget(item);
|
|
427
|
+
stageWrap.dataset.video = String(item && item.type === 'video');
|
|
428
|
+
if (item && item.type === 'video') updateTransport();
|
|
429
|
+
stage.clear();
|
|
430
|
+
app.result = null;
|
|
431
|
+
if (stage.picking) togglePicker();
|
|
432
|
+
library.refresh();
|
|
433
|
+
library.sourceChanged();
|
|
434
|
+
schedule();
|
|
435
|
+
updateStatus();
|
|
436
|
+
}
|
|
437
|
+
app.select = select;
|
|
438
|
+
|
|
439
|
+
function removeItem(item) {
|
|
440
|
+
const i = app.items.indexOf(item);
|
|
441
|
+
if (i < 0) return;
|
|
442
|
+
app.items.splice(i, 1);
|
|
443
|
+
store.dropTarget(item.id);
|
|
444
|
+
if (item.type === 'video') { item.source.pause(); URL.revokeObjectURL(item.source.src); }
|
|
445
|
+
if (item.source.close && !app.items.some(i => i.source === item.source)) item.source.close(); // duplicates share a bitmap
|
|
446
|
+
if (app.current === item) select(app.items[Math.min(i, app.items.length - 1)] || null);
|
|
447
|
+
library.refresh();
|
|
448
|
+
inspector.sync(store.settings, previewResult());
|
|
449
|
+
}
|
|
450
|
+
app.removeItem = removeItem;
|
|
451
|
+
app.clearItems = () => { for (const it of app.items.slice()) removeItem(it); };
|
|
452
|
+
|
|
453
|
+
// ================================================================ per-file settings
|
|
454
|
+
/** The settings a file renders with. */
|
|
455
|
+
const settingsFor = item => (item === app.current ? store.settings : (item && item.own) || app.shared);
|
|
456
|
+
app.settingsFor = settingsFor;
|
|
457
|
+
const clone = v => JSON.parse(JSON.stringify(v));
|
|
458
|
+
|
|
459
|
+
/** Point the store at the right settings for this file (its own, or the shared ones). */
|
|
460
|
+
function editTarget(item, force = false) {
|
|
461
|
+
const key = item && item.own ? item.id : 'shared';
|
|
462
|
+
if (store.target === key && !force) return;
|
|
463
|
+
store.setTarget(key, key === 'shared' ? app.shared : item.own);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Give a file its own settings (a copy of what it uses now), or link it back to the shared ones. */
|
|
467
|
+
function setOwn(item, own, { quiet = false } = {}) {
|
|
468
|
+
if (!item || !!item.own === own) return;
|
|
469
|
+
if (own) {
|
|
470
|
+
item.own = clone(settingsFor(item));
|
|
471
|
+
if (item === app.current) editTarget(item);
|
|
472
|
+
if (!quiet) toast(`${item.name} now has its own settings. Changes no longer affect other files.`);
|
|
473
|
+
} else {
|
|
474
|
+
const was = item.own;
|
|
475
|
+
item.own = null;
|
|
476
|
+
store.dropTarget(item.id);
|
|
477
|
+
if (item === app.current) editTarget(item);
|
|
478
|
+
if (!quiet) toast(`${item.name} uses the shared settings again`, { action: { label: 'Undo', onClick: () => { item.own = was; if (item === app.current) editTarget(item); afterScopeChange(); } } });
|
|
479
|
+
}
|
|
480
|
+
afterScopeChange();
|
|
481
|
+
}
|
|
482
|
+
function afterScopeChange() {
|
|
483
|
+
library.refresh();
|
|
484
|
+
const res = previewResult();
|
|
485
|
+
inspector.sync(store.settings, res);
|
|
486
|
+
if (res) updateStats(res);
|
|
487
|
+
schedule();
|
|
488
|
+
}
|
|
489
|
+
app.setOwn = setOwn;
|
|
490
|
+
|
|
491
|
+
app.copySettings = item => {
|
|
492
|
+
app.clipboard = clone(settingsFor(item));
|
|
493
|
+
toast(`Copied the settings of ${item.name}`, { timeout: 1800 });
|
|
494
|
+
library.refresh();
|
|
495
|
+
};
|
|
496
|
+
app.pasteSettings = item => {
|
|
497
|
+
if (!app.clipboard) return;
|
|
498
|
+
if (!item.own) setOwn(item, true, { quiet: true });
|
|
499
|
+
if (item === app.current) store.replace(app.clipboard, 'paste');
|
|
500
|
+
else item.own = clone(app.clipboard);
|
|
501
|
+
toast(`Pasted settings onto ${item.name}`, { kind: 'ok', timeout: 1800 });
|
|
502
|
+
afterScopeChange();
|
|
503
|
+
};
|
|
504
|
+
/** Make this file's settings the shared ones, and link every file to them. */
|
|
505
|
+
app.applyToAll = item => {
|
|
506
|
+
const snapshot = { shared: app.shared, own: new Map(app.items.map(i => [i, i.own])) };
|
|
507
|
+
app.shared = clone(settingsFor(item));
|
|
508
|
+
for (const it of app.items) { if (it.own) store.dropTarget(it.id); it.own = null; }
|
|
509
|
+
store.setTarget('shared', app.shared);
|
|
510
|
+
if (usePersist) saveSoon();
|
|
511
|
+
toast(`Every file now uses the settings of ${item.name}`, {
|
|
512
|
+
action: { label: 'Undo', onClick: () => {
|
|
513
|
+
app.shared = snapshot.shared;
|
|
514
|
+
for (const [it, own] of snapshot.own) it.own = own;
|
|
515
|
+
editTarget(app.current, true);
|
|
516
|
+
afterScopeChange();
|
|
517
|
+
} },
|
|
518
|
+
});
|
|
519
|
+
afterScopeChange();
|
|
520
|
+
};
|
|
521
|
+
/** A second copy of a file with its own settings, for comparing two looks. */
|
|
522
|
+
app.duplicate = async item => {
|
|
523
|
+
let copy;
|
|
524
|
+
if (item.type === 'video') copy = await addVideo(nameCopy(item.name), item.file);
|
|
525
|
+
else copy = await addImage(nameCopy(item.name), item.source, item.file);
|
|
526
|
+
// place it right after the original
|
|
527
|
+
app.items.splice(app.items.indexOf(copy), 1);
|
|
528
|
+
app.items.splice(app.items.indexOf(item) + 1, 0, copy);
|
|
529
|
+
copy.own = clone(settingsFor(item));
|
|
530
|
+
select(copy);
|
|
531
|
+
toast(`Duplicated ${item.name}. The copy has its own settings.`, { timeout: 2400 });
|
|
532
|
+
};
|
|
533
|
+
const nameCopy = name => { const m = /^(.*?)(\.[^.]*)?$/.exec(name); return `${m[1]} copy${m[2] || ''}`; };
|
|
534
|
+
app.move = (item, dir) => {
|
|
535
|
+
const i = app.items.indexOf(item), j = i + dir;
|
|
536
|
+
if (i < 0 || j < 0 || j >= app.items.length) return;
|
|
537
|
+
[app.items[i], app.items[j]] = [app.items[j], app.items[i]];
|
|
538
|
+
library.refresh();
|
|
539
|
+
if (document.activeElement === document.body || document.activeElement?.closest?.('.itp-files')) library.focusFile(item);
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
// ================================================================ rename
|
|
543
|
+
const splitExt = name => { const m = /^(.*?)(\.[^.\s]{1,5})?$/.exec(name); return [m[1], m[2] || '']; };
|
|
544
|
+
/** Rename a file. The extension is kept: exports are named after it ("<name>@4x.png"). */
|
|
545
|
+
app.rename = (item, next) => {
|
|
546
|
+
const [, ext] = splitExt(item.name);
|
|
547
|
+
let base = String(next || '').trim().replace(/[\\/:*?"<>|]+/g, '-');
|
|
548
|
+
if (ext && base.toLowerCase().endsWith(ext.toLowerCase())) base = base.slice(0, -ext.length);
|
|
549
|
+
if (!base || base + ext === item.name) return;
|
|
550
|
+
item.name = base + ext;
|
|
551
|
+
library.refresh();
|
|
552
|
+
updateStatus();
|
|
553
|
+
};
|
|
554
|
+
/** Edit the name in place in the header, with the name (not the extension) selected. */
|
|
555
|
+
function startRename(item) {
|
|
556
|
+
if (!item || fileName.hidden) return;
|
|
557
|
+
if (app.current !== item) select(item);
|
|
558
|
+
const [base] = splitExt(item.name);
|
|
559
|
+
const input = h('input.itp-name-input', { type: 'text', value: item.name, 'aria-label': 'File name', spellcheck: false });
|
|
560
|
+
input.style.width = `${Math.min(40, Math.max(12, item.name.length + 2))}ch`;
|
|
561
|
+
fileName.hidden = true;
|
|
562
|
+
fileName.after(input);
|
|
563
|
+
input.focus();
|
|
564
|
+
input.setSelectionRange(0, base.length);
|
|
565
|
+
let done = false;
|
|
566
|
+
const finish = commit => {
|
|
567
|
+
if (done) return;
|
|
568
|
+
done = true;
|
|
569
|
+
if (commit) app.rename(item, input.value);
|
|
570
|
+
input.remove();
|
|
571
|
+
fileName.hidden = false;
|
|
572
|
+
};
|
|
573
|
+
input.addEventListener('keydown', e => {
|
|
574
|
+
e.stopPropagation();
|
|
575
|
+
if (e.key === 'Enter') { e.preventDefault(); finish(true); }
|
|
576
|
+
if (e.key === 'Escape') { e.preventDefault(); finish(false); }
|
|
577
|
+
});
|
|
578
|
+
input.addEventListener('blur', () => finish(true));
|
|
579
|
+
}
|
|
580
|
+
app.startRename = startRename;
|
|
581
|
+
|
|
582
|
+
// ================================================================ saved workspace
|
|
583
|
+
// Files (their bytes), names, per-file settings and the shared settings, in this browser's IndexedDB.
|
|
584
|
+
let savedInfo = null; // { count, bytes, savedAt } of what is saved, or null
|
|
585
|
+
app.savedWorkspace = () => savedInfo;
|
|
586
|
+
|
|
587
|
+
/** A Blob for a file, for saving: the original bytes, or a PNG of what was added without a file. */
|
|
588
|
+
async function bytesOf(item) {
|
|
589
|
+
if (item.file) return item.file;
|
|
590
|
+
const c = document.createElement('canvas');
|
|
591
|
+
c.width = item.width; c.height = item.height;
|
|
592
|
+
c.getContext('2d').drawImage(item.source, 0, 0);
|
|
593
|
+
return new Promise(res => c.toBlob(b => res(b), 'image/png'));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function saveNow() {
|
|
597
|
+
if (!usePersist) return;
|
|
598
|
+
if (!app.items.length) {
|
|
599
|
+
if (savedInfo) { await forgetSaved(); return; }
|
|
600
|
+
toast('Add files first, then Save keeps them here for next time.');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
saveBtn.disabled = true;
|
|
604
|
+
try {
|
|
605
|
+
const items = [];
|
|
606
|
+
for (const it of app.items) items.push({ name: it.name, type: it.type, file: await bytesOf(it), own: it.own ? clone(it.own) : null });
|
|
607
|
+
const bytes = await saveWorkspace({ shared: clone(app.shared), current: Math.max(0, app.items.indexOf(app.current)), items });
|
|
608
|
+
const first = !savedInfo;
|
|
609
|
+
savedInfo = { count: items.length, bytes, savedAt: Date.now() };
|
|
610
|
+
library.refresh();
|
|
611
|
+
toast(first
|
|
612
|
+
? `Saved ${items.length} file${items.length === 1 ? '' : 's'} (${fmtBytes(bytes)}) in this browser only. They reopen next time; remove them with Forget in the Files list.`
|
|
613
|
+
: `Saved ${items.length} file${items.length === 1 ? '' : 's'} (${fmtBytes(bytes)})`, { kind: 'ok', timeout: first ? 7000 : 2200 });
|
|
614
|
+
} catch (e) {
|
|
615
|
+
console.error(e);
|
|
616
|
+
toast(isQuotaError(e) ? "Not enough browser storage to save these files. Remove some (videos are large) and try again." : `Couldn't save: ${e.message}`, { kind: 'error' });
|
|
617
|
+
} finally {
|
|
618
|
+
saveBtn.disabled = false;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
app.saveWorkspace = saveNow;
|
|
622
|
+
|
|
623
|
+
async function forgetSaved() {
|
|
624
|
+
try { await forgetWorkspace(); } catch (e) { toast(`Couldn't remove the saved files: ${e.message}`, { kind: 'error' }); return; }
|
|
625
|
+
savedInfo = null;
|
|
626
|
+
library.refresh();
|
|
627
|
+
toast('Removed the saved files from this browser. Files open now stay open.');
|
|
628
|
+
}
|
|
629
|
+
app.forgetWorkspace = forgetSaved;
|
|
630
|
+
|
|
631
|
+
/** Reopen the saved workspace (on start, when nothing else was opened). */
|
|
632
|
+
async function restoreSaved() {
|
|
633
|
+
const ws = await loadWorkspace();
|
|
634
|
+
if (!ws) return;
|
|
635
|
+
savedInfo = { count: ws.items.length, bytes: ws.items.reduce((a, it) => a + (it.file ? it.file.size : 0), 0), savedAt: ws.savedAt };
|
|
636
|
+
if (app.items.length || !ws.items.length) { library.refresh(); return; }
|
|
637
|
+
if (ws.shared && !/[#&]s=/.test(location.hash)) store.setTarget('shared', ws.shared); // a share link's settings win
|
|
638
|
+
const t = ws.items.length > 2 ? toast(`Opening ${ws.items.length} saved files`, { progress: true }) : null;
|
|
639
|
+
const added = [];
|
|
640
|
+
for (let i = 0; i < ws.items.length; i++) {
|
|
641
|
+
const s = ws.items[i];
|
|
642
|
+
try {
|
|
643
|
+
const file = s.file instanceof File ? s.file : new File([s.file], s.name, { type: s.file.type });
|
|
644
|
+
let item;
|
|
645
|
+
if (s.type === 'video') item = await addVideo(s.name, file);
|
|
646
|
+
else {
|
|
647
|
+
let drawable;
|
|
648
|
+
try { drawable = await createImageBitmap(file); } catch { drawable = new Image(); drawable.src = URL.createObjectURL(file); await drawable.decode(); }
|
|
649
|
+
item = await addImage(s.name, drawable, file);
|
|
650
|
+
}
|
|
651
|
+
item.own = s.own || null;
|
|
652
|
+
added.push(item);
|
|
653
|
+
} catch (e) { console.warn('[workspace] skipped', s.name, e); }
|
|
654
|
+
t?.progress((i + 1) / ws.items.length);
|
|
655
|
+
}
|
|
656
|
+
t?.close();
|
|
657
|
+
const cur = added[Math.min(ws.current || 0, added.length - 1)];
|
|
658
|
+
if (cur) select(cur);
|
|
659
|
+
library.refresh();
|
|
660
|
+
if (added.length) toast(`Reopened your saved files (${added.length})`, { timeout: 2200 });
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// ================================================================ video
|
|
664
|
+
function updateTransport() {
|
|
665
|
+
const it = app.current;
|
|
666
|
+
if (!it || it.type !== 'video') return;
|
|
667
|
+
const v = it.source;
|
|
668
|
+
playBtn.innerHTML = icon(v.paused ? 'play' : 'pause');
|
|
669
|
+
playBtn.setAttribute('aria-label', v.paused ? 'Play (Space)' : 'Pause (Space)');
|
|
670
|
+
const frame = Math.floor(v.currentTime * it.fps + 1e-6);
|
|
671
|
+
timeLabel.innerHTML = `${fmtTime(v.currentTime)} <span>/ ${fmtTime(v.duration)}</span>`;
|
|
672
|
+
timeLabel.title = `Frame ${frame}`;
|
|
673
|
+
scrub.value = String(Math.round(v.currentTime / (v.duration || 1) * 1000));
|
|
674
|
+
scrub.style.setProperty('--p', `${scrub.value / 10}%`);
|
|
675
|
+
fpsLabel.textContent = `${it.fps} fps`;
|
|
676
|
+
soundBox.hidden = it.hasAudio === false;
|
|
677
|
+
applySound(v);
|
|
678
|
+
}
|
|
679
|
+
/** Mute/volume on the current video and in the controls. */
|
|
680
|
+
function applySound(v) {
|
|
681
|
+
if (v && !app.exporting && !app.current?.fpsReady) {
|
|
682
|
+
if (v.muted !== sound.muted) v.muted = sound.muted;
|
|
683
|
+
if (v.volume !== sound.volume) v.volume = sound.volume;
|
|
684
|
+
}
|
|
685
|
+
const level = sound.muted ? 0 : sound.volume;
|
|
686
|
+
const pct = Math.round(level * 100);
|
|
687
|
+
muteBtn.innerHTML = icon(level ? 'volume' : 'volumeOff');
|
|
688
|
+
const label = level ? `Volume ${pct}% (M mutes)` : 'Muted (M unmutes)';
|
|
689
|
+
muteBtn.setAttribute('aria-label', label); muteBtn.title = label;
|
|
690
|
+
volSlider.setAttribute('aria-valuenow', String(pct));
|
|
691
|
+
volSlider.setAttribute('aria-valuetext', level ? `${pct}%` : 'Muted');
|
|
692
|
+
volSlider.style.setProperty('--v', `${pct}%`);
|
|
693
|
+
volValue.textContent = level ? String(pct) : 'Off';
|
|
694
|
+
}
|
|
695
|
+
function setSound(patch) {
|
|
696
|
+
Object.assign(sound, patch);
|
|
697
|
+
// unmuting at zero volume brings the volume back up
|
|
698
|
+
if (patch.muted === false && !sound.volume) sound.volume = 1;
|
|
699
|
+
persist.save('muted', sound.muted); persist.save('volume', sound.volume);
|
|
700
|
+
applySound(app.current && app.current.type === 'video' ? app.current.source : null);
|
|
701
|
+
}
|
|
702
|
+
function pumpVideo(item) {
|
|
703
|
+
const v = item.source;
|
|
704
|
+
const tick = () => {
|
|
705
|
+
if (v.paused || app.current !== item) return;
|
|
706
|
+
schedule();
|
|
707
|
+
if ('requestVideoFrameCallback' in v) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick);
|
|
708
|
+
};
|
|
709
|
+
tick();
|
|
710
|
+
}
|
|
711
|
+
async function togglePlay() {
|
|
712
|
+
const it = app.current;
|
|
713
|
+
if (!it || it.type !== 'video') return;
|
|
714
|
+
if (storyboard.active) { storyboard.toggleAnimation(); return; }
|
|
715
|
+
if (!it.source.paused) { it.source.pause(); return; }
|
|
716
|
+
if (it.fpsReady) await it.fpsReady; // still measuring the frame rate (plays muted for a moment)
|
|
717
|
+
if (app.current !== it || storyboard.active) return;
|
|
718
|
+
applySound(it.source);
|
|
719
|
+
if (it.source.ended) it.source.currentTime = 0;
|
|
720
|
+
it.source.play().catch(e => toast(e.message, { kind: 'error' }));
|
|
721
|
+
}
|
|
722
|
+
function stepFrame(dir) {
|
|
723
|
+
const it = app.current;
|
|
724
|
+
if (!it || it.type !== 'video' || storyboard.active) return;
|
|
725
|
+
const v = it.source; v.pause();
|
|
726
|
+
const frame = Math.floor(v.currentTime * it.fps + 1e-6) + dir;
|
|
727
|
+
seekTo(v, (Math.max(0, frame) + 0.5) / it.fps).then(() => { updateTransport(); schedule(); });
|
|
728
|
+
}
|
|
729
|
+
scrub.addEventListener('input', () => {
|
|
730
|
+
const it = app.current; if (!it || it.type !== 'video') return;
|
|
731
|
+
it.source.currentTime = scrub.value / 1000 * it.source.duration;
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// ================================================================ view
|
|
735
|
+
function toggleCompare(force) {
|
|
736
|
+
if (storyboard.active) return;
|
|
737
|
+
const on = force ?? !stage.compare;
|
|
738
|
+
stage.setCompare(on); compareBtn.setAttribute('aria-pressed', String(on));
|
|
739
|
+
}
|
|
740
|
+
function toggleGrid(force) {
|
|
741
|
+
if (storyboard.active) return;
|
|
742
|
+
const on = force ?? !stage.grid;
|
|
743
|
+
stage.setGrid(on); gridBtn.setAttribute('aria-pressed', String(on));
|
|
744
|
+
if (on && stage.zoom < 6 && app.result) toast('The grid shows from 6× zoom');
|
|
745
|
+
}
|
|
746
|
+
function zoomMenu(anchor) {
|
|
747
|
+
const view = storyboard.active ? storyboard : stage;
|
|
748
|
+
menu(anchor, [
|
|
749
|
+
{ label: storyboard.active ? 'Fit canvas to view' : 'Fit to view', kbd: 'F', icon: 'fit', onClick: () => view.fit() },
|
|
750
|
+
'sep',
|
|
751
|
+
...[1, 2, 4, 8, 16, 32].map(z => ({ label: `${z}×`, sub: z === 1 && !storyboard.active ? 'One screen pixel per pixel' : null, onClick: () => view.zoomTo(z) })),
|
|
752
|
+
'sep',
|
|
753
|
+
{ label: 'Zoom in', kbd: '+', icon: 'zoomIn', onClick: () => view.zoomStep(1) },
|
|
754
|
+
{ label: 'Zoom out', kbd: '-', icon: 'zoomOut', onClick: () => view.zoomStep(-1) },
|
|
755
|
+
]);
|
|
756
|
+
}
|
|
757
|
+
function togglePanel(which) {
|
|
758
|
+
const key = which === 'lib' ? 'lib' : 'insp';
|
|
759
|
+
const open = shell.dataset[key] !== 'open';
|
|
760
|
+
shell.dataset[key] = open ? 'open' : 'closed';
|
|
761
|
+
if (!narrow() || which === 'insp') persist.save(key, shell.dataset[key]);
|
|
762
|
+
requestAnimationFrame(() => stage.resize());
|
|
763
|
+
}
|
|
764
|
+
function applyTheme(t) {
|
|
765
|
+
if (t) document.documentElement.dataset.theme = t; else delete document.documentElement.dataset.theme;
|
|
766
|
+
const dark = t ? t === 'dark' : !matchMedia('(prefers-color-scheme: light)').matches;
|
|
767
|
+
themeBtn.innerHTML = icon(dark ? 'sun' : 'moon');
|
|
768
|
+
themeBtn.setAttribute('aria-label', dark ? 'Switch to light theme (T)' : 'Switch to dark theme (T)');
|
|
769
|
+
stage.redraw();
|
|
770
|
+
}
|
|
771
|
+
function toggleTheme() {
|
|
772
|
+
const cur = document.documentElement.dataset.theme || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
|
773
|
+
const next = cur === 'dark' ? 'light' : 'dark';
|
|
774
|
+
persist.save('theme', next);
|
|
775
|
+
applyTheme(next);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ================================================================ palettes
|
|
779
|
+
function pixelsOf(item, maxSize = 4096) {
|
|
780
|
+
const w = item.width, hh = item.height, k = Math.min(1, maxSize / Math.max(w, hh));
|
|
781
|
+
const c = document.createElement('canvas'); c.width = Math.round(w * k); c.height = Math.round(hh * k);
|
|
782
|
+
const ctx = c.getContext('2d', { willReadFrequently: true });
|
|
783
|
+
ctx.drawImage(item.source, 0, 0, c.width, c.height);
|
|
784
|
+
return ctx.getImageData(0, 0, c.width, c.height);
|
|
785
|
+
}
|
|
786
|
+
function pickAt(u, v) {
|
|
787
|
+
const it = app.current; if (!it) return;
|
|
788
|
+
const img = pixelsOf(it, 1024);
|
|
789
|
+
const c = app.result ? app.result.crop : { x: 0, y: 0, w: 1, h: 1 };
|
|
790
|
+
const x = Math.min(img.width - 1, Math.floor((c.x + u * c.w) * img.width)), y = Math.min(img.height - 1, Math.floor((c.y + v * c.h) * img.height));
|
|
791
|
+
const o = (y * img.width + x) * 4;
|
|
792
|
+
const hex = toHex([img.data[o], img.data[o + 1], img.data[o + 2]]);
|
|
793
|
+
const s = store.settings;
|
|
794
|
+
const colors = s.palette.source === 'custom' ? s.palette.colors : [];
|
|
795
|
+
if (colors.includes(hex)) return toast(`${hex} is already in the palette`);
|
|
796
|
+
store.update({ palette: { source: 'custom', name: s.palette.source === 'custom' ? s.palette.name : 'Picked', colors: [...colors, hex] } });
|
|
797
|
+
toast(`Added ${hex}`, { kind: 'ok', timeout: 1500 });
|
|
798
|
+
}
|
|
799
|
+
let pickerBtn = null;
|
|
800
|
+
function togglePicker(button) {
|
|
801
|
+
if (button) pickerBtn = button;
|
|
802
|
+
const on = !stage.picking;
|
|
803
|
+
stage.setPicking(on);
|
|
804
|
+
pickerBtn?.setAttribute('aria-pressed', String(on));
|
|
805
|
+
if (on) {
|
|
806
|
+
toggleCompare(true);
|
|
807
|
+
toast('Click the image to add colours. Press Esc when done.');
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
app.togglePicker = togglePicker;
|
|
811
|
+
|
|
812
|
+
function usePalette(p) {
|
|
813
|
+
if (p.id && !p.custom) store.update({ palette: { source: 'builtin', id: p.id } });
|
|
814
|
+
else store.update({ palette: { source: 'custom', name: p.name, colors: p.colors } });
|
|
815
|
+
}
|
|
816
|
+
app.usePalette = usePalette;
|
|
817
|
+
|
|
818
|
+
function rememberPalette(name, colors) {
|
|
819
|
+
const list = app.userPalettes.filter(p => p.name !== name);
|
|
820
|
+
list.unshift({ name, colors, custom: true });
|
|
821
|
+
app.userPalettes = list.slice(0, 100);
|
|
822
|
+
if (usePersist) persist.save('palettes', app.userPalettes);
|
|
823
|
+
if (library.tab === 'palettes') library.refresh();
|
|
824
|
+
}
|
|
825
|
+
async function importPaletteFile(file, text) {
|
|
826
|
+
try {
|
|
827
|
+
const p = parsePalette(text ?? await file.text(), file.name);
|
|
828
|
+
rememberPalette(p.name || 'Imported', p.colors);
|
|
829
|
+
store.update({ palette: { source: 'custom', name: p.name || 'Imported', colors: p.colors } });
|
|
830
|
+
toast(`Imported "${p.name}" (${p.colors.length} colours)`, { kind: 'ok' });
|
|
831
|
+
} catch (e) { toast(`${file.name}: ${e.message}`, { kind: 'error' }); }
|
|
832
|
+
}
|
|
833
|
+
app.importPalette = async () => {
|
|
834
|
+
const [f] = await pickFiles({ accept: '.gpl,.hex,.pal,.txt,.json' });
|
|
835
|
+
if (f) importPaletteFile(f);
|
|
836
|
+
};
|
|
837
|
+
app.savePalette = () => {
|
|
838
|
+
const s = store.settings;
|
|
839
|
+
const colors = s.palette.source === 'extract' ? (app.result?.palette || []) : s.palette.colors;
|
|
840
|
+
if (!colors.length) return toast('There is no palette to save', { kind: 'error' });
|
|
841
|
+
const input = h('input.itp-input', { value: s.palette.source === 'custom' ? s.palette.name : `${s.palette.name} copy`, 'aria-label': 'Palette name' });
|
|
842
|
+
const fmt = h('select.itp-select', { 'aria-label': 'Also download as' }, h('option', { value: '', text: "Don't download" }), h('option', { value: 'gpl', text: '.gpl (GIMP, Aseprite)' }), h('option', { value: 'hex', text: '.hex (Lospec)' }), h('option', { value: 'json', text: '.json' }));
|
|
843
|
+
dialog('Save palette', [h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Name' })), input), h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Also download as' })), fmt),
|
|
844
|
+
h('div.itp-strip', { style: { height: '18px' } }, ...colors.map(c => h('i', { style: { background: c } })))], {
|
|
845
|
+
actions: [{ label: 'Cancel' }, { label: 'Save', primary: true, onClick: () => {
|
|
846
|
+
const name = input.value.trim() || 'Palette';
|
|
847
|
+
rememberPalette(name, colors);
|
|
848
|
+
store.update({ palette: { source: 'custom', name, colors } });
|
|
849
|
+
const slug = name.replace(/[^\w-]+/g, '-').toLowerCase();
|
|
850
|
+
if (fmt.value === 'gpl') downloadBlob(toGPL(name, colors), `${slug}.gpl`, 'text/plain');
|
|
851
|
+
if (fmt.value === 'hex') downloadBlob(toHexFile(colors), `${slug}.hex`, 'text/plain');
|
|
852
|
+
if (fmt.value === 'json') downloadBlob(toPaletteJSON(name, colors), `${slug}.json`, 'application/json');
|
|
853
|
+
toast(`Saved "${name}" to Your palettes`, { kind: 'ok' });
|
|
854
|
+
} }],
|
|
855
|
+
});
|
|
856
|
+
input.select();
|
|
857
|
+
};
|
|
858
|
+
app.deletePalette = p => {
|
|
859
|
+
app.userPalettes = app.userPalettes.filter(x => x !== p);
|
|
860
|
+
if (usePersist) persist.save('palettes', app.userPalettes);
|
|
861
|
+
library.refresh();
|
|
862
|
+
toast(`Deleted "${p.name}"`, { action: { label: 'Undo', onClick: () => rememberPalette(p.name, p.colors) } });
|
|
863
|
+
};
|
|
864
|
+
app.lospecDialog = () => {
|
|
865
|
+
const input = h('input.itp-input', { placeholder: 'e.g. rgbg-36 or a lospec.com palette URL', 'aria-label': 'Lospec slug or URL' });
|
|
866
|
+
const note = h('div.help', { text: 'Looks the palette up on lospec.com. Needs an internet connection.' });
|
|
867
|
+
const d = dialog('Palette from Lospec', [h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Slug or URL' })), input, note)], {
|
|
868
|
+
actions: [{ label: 'Cancel' }, { label: 'Fetch', primary: true, onClick: async () => {
|
|
869
|
+
note.textContent = 'Fetching…'; note.style.color = '';
|
|
870
|
+
try {
|
|
871
|
+
const p = await fetchLospec(input.value);
|
|
872
|
+
rememberPalette(p.name, p.colors);
|
|
873
|
+
store.update({ palette: { source: 'custom', name: p.name, colors: p.colors } });
|
|
874
|
+
toast(`Loaded "${p.name}" from Lospec`, { kind: 'ok' });
|
|
875
|
+
return true;
|
|
876
|
+
} catch (e) { note.textContent = e.message; note.style.color = 'var(--danger)'; return false; }
|
|
877
|
+
} }],
|
|
878
|
+
});
|
|
879
|
+
input.addEventListener('keydown', e => { if (e.key === 'Enter') d.querySelector('footer .primary').click(); });
|
|
880
|
+
input.focus();
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
app.detectGrid = () => {
|
|
884
|
+
const it = app.current; if (!it || it.type !== 'image') return toast('Open an image first', { kind: 'error' });
|
|
885
|
+
const b = detectGrid(pixelsOf(it));
|
|
886
|
+
if (b <= 1) return toast('No pixel grid found. This image doesn\'t look upscaled.');
|
|
887
|
+
store.update({ size: { mode: 'block', block: b, resample: 'nearest' } });
|
|
888
|
+
toast(`Found ${b} px blocks: switched to pixel size ${b} with nearest sampling`, { kind: 'ok' });
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
// ================================================================ settings
|
|
892
|
+
app.settingsJSON = () => settingsToJSON(store.settings);
|
|
893
|
+
app.copyLink = async () => {
|
|
894
|
+
const url = `${location.origin}${location.pathname}#s=${encodeSettings(store.settings)}`;
|
|
895
|
+
try { await navigator.clipboard.writeText(url); toast('Link copied. It holds the settings, not the image.', { kind: 'ok' }); }
|
|
896
|
+
catch { dialog('Share link', [h('textarea.itp-input', { rows: 3, readOnly: true, value: url })]); }
|
|
897
|
+
history.replaceState(null, '', `#s=${encodeSettings(store.settings)}`);
|
|
898
|
+
};
|
|
899
|
+
app.saveSettingsFile = () => downloadBlob(settingsToJSON(store.settings), 'image-to-pixel-settings.json', 'application/json');
|
|
900
|
+
app.loadSettingsFile = async () => {
|
|
901
|
+
const [f] = await pickFiles({ accept: '.json,application/json' });
|
|
902
|
+
if (!f) return;
|
|
903
|
+
try { store.replace(settingsFromJSON(await f.text())); toast(`Loaded ${f.name}`, { kind: 'ok' }); } catch (e) { toast(`${f.name}: ${e.message}`, { kind: 'error' }); }
|
|
904
|
+
};
|
|
905
|
+
app.resetSettings = () => { store.replace(defaultSettings()); toast('Settings reset', { action: { label: 'Undo', onClick: () => store.undo() } }); };
|
|
906
|
+
app.applyPreset = p => {
|
|
907
|
+
store.replace(mergeSettings(defaultSettings(), { ...p.settings, export: store.settings.export }), 'preset');
|
|
908
|
+
app.activePreset = p.id;
|
|
909
|
+
library.refresh();
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
Object.assign(app, exporter);
|
|
913
|
+
|
|
914
|
+
function exportMenu(anchor) {
|
|
915
|
+
const it = app.current;
|
|
916
|
+
const video = it && it.type === 'video';
|
|
917
|
+
const images = app.items.filter(i => i.type === 'image').length;
|
|
918
|
+
const s = store.settings;
|
|
919
|
+
const selecting = storyboard.active;
|
|
920
|
+
const offNote = selecting ? 'Turn off frame selection first' : null;
|
|
921
|
+
menu(anchor, [
|
|
922
|
+
{ group: 'Image' },
|
|
923
|
+
{ label: 'PNG', sub: offNote || (app.result ? `${app.result.width * s.export.scale} × ${app.result.height * s.export.scale}, ${s.export.scale}× scale` : 'Open an image first'), icon: 'image', kbd: 'Ctrl S', disabled: !app.result || selecting, onClick: () => app.exportPNG() },
|
|
924
|
+
{ label: 'Copy to clipboard', sub: offNote, icon: 'copy', disabled: !app.result || selecting, onClick: () => app.copyImage() },
|
|
925
|
+
{ label: app.items.length > 1 ? `All ${app.items.length} files as ZIP` : 'All files as ZIP', sub: app.items.length > 1 ? null : 'Add two or more files', icon: 'zip', disabled: app.items.length < 2, onClick: () => app.exportZip() },
|
|
926
|
+
{ group: 'Animation' },
|
|
927
|
+
{ label: video ? 'GIF from this video' : 'GIF from all images', icon: 'gif', disabled: !video && images < 2, onClick: () => app.exportAnimation({ ...animDefaults(), format: 'gif' }) },
|
|
928
|
+
{ label: 'More formats…', sub: 'WebM, MP4, PNG sequence, sprite sheet', icon: 'film', onClick: () => { inspector.setTab('export'); if (shell.dataset.insp !== 'open') togglePanel('insp'); } },
|
|
929
|
+
{ group: 'Other' },
|
|
930
|
+
{ label: 'Tileset', sub: offNote || '16 × 16 tiles, duplicates removed', icon: 'tiles', disabled: !app.result || selecting, onClick: () => app.exportTileset({ tile: 16, flips: false }) },
|
|
931
|
+
{ label: 'Copy settings link', icon: 'link', onClick: () => app.copyLink() },
|
|
932
|
+
{ label: 'Settings as JSON', icon: 'braces', onClick: () => app.saveSettingsFile() },
|
|
933
|
+
]);
|
|
934
|
+
}
|
|
935
|
+
const animDefaults = () => {
|
|
936
|
+
const it = app.current;
|
|
937
|
+
const video = it && it.type === 'video';
|
|
938
|
+
return { source: video ? 'video' : 'files', fps: video ? Math.min(30, Math.round(it.fps || 12)) : 8, start: 0, end: video ? it.duration : 0 };
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
// ================================================================ help
|
|
942
|
+
function showHelp() {
|
|
943
|
+
const keys = [
|
|
944
|
+
['Open files', 'Ctrl O'], ['Paste image', 'Ctrl V'], ['Export PNG', 'Ctrl S'], ['Undo', 'Ctrl Z'], ['Redo', 'Ctrl Shift Z'],
|
|
945
|
+
['Compare', 'C'], ['Pixel grid', 'G'], ['Fit', 'F'], ['Zoom in / out', '+ -'], ['Pixel width ±1', '[ ]'], ['Pixel width ±8', 'Shift [ ]'],
|
|
946
|
+
['Next / previous dither', 'D Shift D'], ['Play / pause video', 'Space'], ['Mute / unmute video', 'M'], ['Rename file', 'F2'], ['Step one frame', ', .'], ['Library', '\\'], ['Inspector', '/'],
|
|
947
|
+
['Theme', 'T'], ['Next / previous file', 'Alt ↓ ↑'], ['Move file down / up', 'Alt Shift ↓ ↑'], ['This list', '?'],
|
|
948
|
+
];
|
|
949
|
+
dialog('Keyboard shortcuts', [h('div.itp-keys', {}, ...keys.map(([a, k]) => h('div', {}, h('span', { text: a }), h('span', {}, ...k.split(' ').filter(Boolean).map(x => h('kbd', { text: x }))))))]);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// ================================================================ input
|
|
953
|
+
const typing = e => e.target.closest('input, select, textarea, [contenteditable="true"]') && !(e.target.type === 'range' || e.target.type === 'checkbox');
|
|
954
|
+
const DITHER_ORDER = ['none', 'bayer4', 'bayer8', 'bluenoise', 'cluster4', 'floyd-steinberg', 'atkinson', 'sierra-lite', 'jarvis'];
|
|
955
|
+
window.addEventListener('keydown', e => {
|
|
956
|
+
if (!shell.isConnected) return;
|
|
957
|
+
if (document.querySelector('dialog[open]')) return;
|
|
958
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
959
|
+
const k = e.key;
|
|
960
|
+
if (mod && k.toLowerCase() === 'z') { if (typing(e)) return; e.preventDefault(); e.shiftKey ? store.redo() : store.undo(); return; }
|
|
961
|
+
if (mod && k.toLowerCase() === 'y') { if (typing(e)) return; e.preventDefault(); store.redo(); return; }
|
|
962
|
+
if (mod && k.toLowerCase() === 's') { e.preventDefault(); app.exportPNG(); return; }
|
|
963
|
+
if (mod && k.toLowerCase() === 'o') { e.preventDefault(); openFiles(); return; }
|
|
964
|
+
if (mod || e.altKey && !['ArrowUp', 'ArrowDown'].includes(k)) return;
|
|
965
|
+
if (typing(e)) { if (k === 'Escape') e.target.blur(); return; }
|
|
966
|
+
const s = store.settings;
|
|
967
|
+
switch (k) {
|
|
968
|
+
case 'c': case 'C': toggleCompare(); break;
|
|
969
|
+
case 'g': case 'G': toggleGrid(); break;
|
|
970
|
+
case 'f': case 'F': if (storyboard.active) storyboard.fit(); else stage.fit(); break;
|
|
971
|
+
case '+': case '=': if (storyboard.active) storyboard.zoomStep(1); else stage.zoomIn(); break;
|
|
972
|
+
case '-': case '_': if (storyboard.active) storyboard.zoomStep(-1); else stage.zoomOut(); break;
|
|
973
|
+
case 't': case 'T': toggleTheme(); break;
|
|
974
|
+
case '\\': togglePanel('lib'); break;
|
|
975
|
+
case '/': togglePanel('insp'); break;
|
|
976
|
+
case '?': showHelp(); break;
|
|
977
|
+
case '[': case '{': store.update({ size: { mode: 'width', width: Math.max(8, s.size.width - (e.shiftKey ? 8 : 1)) } }, { key: 'kw' }); break;
|
|
978
|
+
case ']': case '}': store.update({ size: { mode: 'width', width: s.size.width + (e.shiftKey ? 8 : 1) } }, { key: 'kw' }); break;
|
|
979
|
+
case 'd': case 'D': {
|
|
980
|
+
const i = DITHER_ORDER.indexOf(s.dither.method);
|
|
981
|
+
const next = DITHER_ORDER[(i + (e.shiftKey ? -1 : 1) + DITHER_ORDER.length) % DITHER_ORDER.length];
|
|
982
|
+
store.update({ dither: { method: next } });
|
|
983
|
+
toast(next === 'none' ? 'No dither' : next.replace(/-/g, ' '), { timeout: 900 });
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
case ' ': if (e.target.closest('button')) break; if (app.current?.type === 'video') { e.preventDefault(); togglePlay(); } break;
|
|
987
|
+
case 'F2': if (app.current) { e.preventDefault(); startRename(app.current); } break;
|
|
988
|
+
case 'm': case 'M': if (app.current?.type === 'video' && !soundBox.hidden) { e.preventDefault(); setSound({ muted: !sound.muted }); } break;
|
|
989
|
+
case ',': stepFrame(-1); break;
|
|
990
|
+
case '.': stepFrame(1); break;
|
|
991
|
+
case 'Escape': if (stage.picking) togglePicker(); else closeMenu(); break;
|
|
992
|
+
case 'ArrowDown': case 'ArrowUp':
|
|
993
|
+
if (e.altKey && e.shiftKey && app.current) { e.preventDefault(); app.move(app.current, k === 'ArrowDown' ? 1 : -1); break; }
|
|
994
|
+
if (e.altKey && app.items.length > 1) {
|
|
995
|
+
e.preventDefault();
|
|
996
|
+
const i = app.items.indexOf(app.current);
|
|
997
|
+
select(app.items[(i + (k === 'ArrowDown' ? 1 : -1) + app.items.length) % app.items.length]);
|
|
998
|
+
}
|
|
999
|
+
break;
|
|
1000
|
+
default: return;
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
// drag & drop, paste
|
|
1005
|
+
let dragDepth = 0;
|
|
1006
|
+
shell.addEventListener('dragenter', e => { if ([...e.dataTransfer.types].includes('Files')) { dragDepth++; shell.classList.add('itp-dragging'); } });
|
|
1007
|
+
shell.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; shell.classList.remove('itp-dragging'); } });
|
|
1008
|
+
shell.addEventListener('dragover', e => { if ([...e.dataTransfer.types].includes('Files')) e.preventDefault(); });
|
|
1009
|
+
shell.addEventListener('drop', e => {
|
|
1010
|
+
e.preventDefault(); dragDepth = 0; shell.classList.remove('itp-dragging');
|
|
1011
|
+
if (e.dataTransfer.files.length) addFiles([...e.dataTransfer.files]);
|
|
1012
|
+
});
|
|
1013
|
+
window.addEventListener('paste', e => {
|
|
1014
|
+
if (!shell.isConnected || typing(e)) return;
|
|
1015
|
+
const files = [...(e.clipboardData?.files || [])];
|
|
1016
|
+
if (files.length) { e.preventDefault(); addFiles(files); return; }
|
|
1017
|
+
const text = e.clipboardData?.getData('text') || '';
|
|
1018
|
+
if (/#?[0-9a-f]{6}/i.test(text) && /\n|,|\s/.test(text.trim())) {
|
|
1019
|
+
try { const p = parsePalette(text); store.update({ palette: { source: 'custom', name: 'Pasted', colors: p.colors } }); toast(`Pasted a ${p.colors.length}-colour palette`, { kind: 'ok' }); } catch { /* not a palette */ }
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
1022
|
+
window.addEventListener('hashchange', () => {
|
|
1023
|
+
if (/[#&]s=/.test(location.hash)) { try { store.replace(decodeSettings(location.hash)); } catch { /* ignore */ } }
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
// first paint
|
|
1027
|
+
inspector.sync(store.settings, null);
|
|
1028
|
+
library.refresh();
|
|
1029
|
+
updateStatus();
|
|
1030
|
+
undoBtn.disabled = redoBtn.disabled = true;
|
|
1031
|
+
if (usePersist && options.restore !== false) restoreSaved().catch(e => console.warn('[workspace]', e));
|
|
1032
|
+
|
|
1033
|
+
const api = {
|
|
1034
|
+
app, store,
|
|
1035
|
+
/** Add File objects (images, videos, palettes, settings JSON). */
|
|
1036
|
+
addFiles,
|
|
1037
|
+
/** Add an image-like source directly. */
|
|
1038
|
+
async addImage(source, name = 'image') { const it = await addImage(name, source); select(it); library.refresh(); return it; },
|
|
1039
|
+
openSample: id => openSample(SAMPLES.find(s => s.id === id) || SAMPLES[0]),
|
|
1040
|
+
applyPreset: id => { const p = PRESETS.find(x => x.id === id); if (p) app.applyPreset(p); },
|
|
1041
|
+
setTheme: t => { persist.save('theme', t); applyTheme(t); },
|
|
1042
|
+
get result() { return app.result; },
|
|
1043
|
+
/** On-screen rectangle of the image in the canvas. */
|
|
1044
|
+
get view() { return stage.rect; },
|
|
1045
|
+
/** Resolves after pending renders finish. */
|
|
1046
|
+
async idle() { for (let i = 0; i < 200 && (dirty || running); i++) await new Promise(r => setTimeout(r, 25)); },
|
|
1047
|
+
};
|
|
1048
|
+
return api;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
export { SAMPLES };
|