@muxiuesd/dsh-pixel-studio 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +10 -1
- package/client.js +1228 -36
- package/package.json +3 -2
package/client.js
CHANGED
|
@@ -65,12 +65,25 @@ window.__ModuleLoader__.load({
|
|
|
65
65
|
];
|
|
66
66
|
/** Keep in sync with MAX_SIDE in index.js. */
|
|
67
67
|
const MAX_SIDE = 256;
|
|
68
|
+
/** Keep in sync with MAX_EXPORT_PIXELS in index.js. */
|
|
69
|
+
const MAX_EXPORT_PIXELS = 4096 * 4096;
|
|
70
|
+
/** Magnifications offered by the Save-as dialog. */
|
|
71
|
+
const EXPORT_SCALES = [1, 2, 3, 4, 6, 8, 12, 16];
|
|
72
|
+
/** One character per palette slot, so an import can never use more colours than this. */
|
|
73
|
+
const IMPORT_MAX_COLORS = CHARS.length;
|
|
74
|
+
/** Where existing content sits when the canvas grows or shrinks. */
|
|
75
|
+
const ANCHORS = [
|
|
76
|
+
{ id: 'nw', label: '↖' }, { id: 'n', label: '↑' }, { id: 'ne', label: '↗' },
|
|
77
|
+
{ id: 'w', label: '←' }, { id: 'c', label: '·' }, { id: 'e', label: '→' },
|
|
78
|
+
{ id: 'sw', label: '↙' }, { id: 's', label: '↓' }, { id: 'se', label: '↘' },
|
|
79
|
+
];
|
|
68
80
|
|
|
69
81
|
const TOOLS = [
|
|
70
82
|
{ id: 'pencil', label: '铅笔', key: 'B', icon: 'pencil' },
|
|
71
83
|
{ id: 'eraser', label: '橡皮', key: 'E', icon: 'eraser' },
|
|
72
84
|
{ id: 'bucket', label: '油漆桶', key: 'G', icon: 'bucket' },
|
|
73
85
|
{ id: 'picker', label: '吸管', key: 'I', icon: 'picker' },
|
|
86
|
+
{ id: 'select', label: '选区', key: 'M', icon: 'select' },
|
|
74
87
|
];
|
|
75
88
|
|
|
76
89
|
/* ------------------------------------------------------------------ */
|
|
@@ -346,6 +359,431 @@ window.__ModuleLoader__.load({
|
|
|
346
359
|
return /\.png$/i.test(trimmed) ? trimmed : `${trimmed}.png`;
|
|
347
360
|
}
|
|
348
361
|
|
|
362
|
+
/* ------------------------------------------------------------------ */
|
|
363
|
+
/* canvas resize */
|
|
364
|
+
/* ------------------------------------------------------------------ */
|
|
365
|
+
|
|
366
|
+
/** Undo snapshot; width/height ride along so a canvas resize can be undone. */
|
|
367
|
+
function snapshotOf(doc) {
|
|
368
|
+
return {
|
|
369
|
+
palette: doc.palette.slice(),
|
|
370
|
+
layers: doc.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
|
|
371
|
+
w: doc.w,
|
|
372
|
+
h: doc.h,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Offset that keeps existing pixels at the chosen anchor when the canvas changes size. */
|
|
377
|
+
function anchorOffsets(anchor, oldWidth, oldHeight, newWidth, newHeight) {
|
|
378
|
+
const id = typeof anchor === 'string' && anchor.length > 0 ? anchor : 'c';
|
|
379
|
+
const dx = id.includes('w') ? 0
|
|
380
|
+
: id.includes('e') ? newWidth - oldWidth
|
|
381
|
+
: Math.round((newWidth - oldWidth) / 2);
|
|
382
|
+
const dy = id.includes('n') ? 0
|
|
383
|
+
: id.includes('s') ? newHeight - oldHeight
|
|
384
|
+
: Math.round((newHeight - oldHeight) / 2);
|
|
385
|
+
return [dx, dy];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Re-frames one layer onto a new canvas: grown areas become transparent, cropped pixels go away. */
|
|
389
|
+
function resizeRows(rows, oldWidth, oldHeight, newWidth, newHeight, anchor) {
|
|
390
|
+
const [dx, dy] = anchorOffsets(anchor, oldWidth, oldHeight, newWidth, newHeight);
|
|
391
|
+
const next = [];
|
|
392
|
+
for (let y = 0; y < newHeight; y += 1) {
|
|
393
|
+
const sourceY = y - dy;
|
|
394
|
+
const row = sourceY >= 0 && sourceY < oldHeight ? String(rows[sourceY] === undefined ? '' : rows[sourceY]) : '';
|
|
395
|
+
let line = '';
|
|
396
|
+
for (let x = 0; x < newWidth; x += 1) {
|
|
397
|
+
const sourceX = x - dx;
|
|
398
|
+
const ch = sourceX >= 0 && sourceX < oldWidth ? row[sourceX] : undefined;
|
|
399
|
+
line += ch === undefined || ch === '' ? '.' : ch;
|
|
400
|
+
}
|
|
401
|
+
next.push(line);
|
|
402
|
+
}
|
|
403
|
+
return next;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* ------------------------------------------------------------------ */
|
|
407
|
+
/* selection */
|
|
408
|
+
/* ------------------------------------------------------------------ */
|
|
409
|
+
|
|
410
|
+
const MIN_SELECTION = 1;
|
|
411
|
+
|
|
412
|
+
/** Normalised rectangle spanning two grid cells (both inclusive). */
|
|
413
|
+
function selectionBetween(a, b) {
|
|
414
|
+
return {
|
|
415
|
+
x: Math.min(a.x, b.x),
|
|
416
|
+
y: Math.min(a.y, b.y),
|
|
417
|
+
w: Math.abs(a.x - b.x) + 1,
|
|
418
|
+
h: Math.abs(a.y - b.y) + 1,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Keeps a rectangle inside the canvas, shrinking it at the far edges. */
|
|
423
|
+
function clampRectangle(rect, width, height) {
|
|
424
|
+
const x = clamp(Math.round(rect.x), 0, Math.max(0, width - MIN_SELECTION));
|
|
425
|
+
const y = clamp(Math.round(rect.y), 0, Math.max(0, height - MIN_SELECTION));
|
|
426
|
+
const w = clamp(Math.round(rect.w), MIN_SELECTION, Math.max(MIN_SELECTION, width - x));
|
|
427
|
+
const h = clamp(Math.round(rect.h), MIN_SELECTION, Math.max(MIN_SELECTION, height - y));
|
|
428
|
+
return { x, y, w, h };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Slides a rectangle to (x, y) without changing its size or leaving the canvas. */
|
|
432
|
+
function moveRectangle(rect, x, y, width, height) {
|
|
433
|
+
return {
|
|
434
|
+
x: clamp(Math.round(x), 0, Math.max(0, width - rect.w)),
|
|
435
|
+
y: clamp(Math.round(y), 0, Math.max(0, height - rect.h)),
|
|
436
|
+
w: rect.w,
|
|
437
|
+
h: rect.h,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function pointInRectangle(rect, cell) {
|
|
442
|
+
return cell.x >= rect.x && cell.x < rect.x + rect.w && cell.y >= rect.y && cell.y < rect.y + rect.h;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Copies the selected characters out of one layer, padding short rows. */
|
|
446
|
+
function readRegion(rows, rect) {
|
|
447
|
+
const region = [];
|
|
448
|
+
for (let y = 0; y < rect.h; y += 1) {
|
|
449
|
+
const line = rows[rect.y + y] || '';
|
|
450
|
+
region.push(line.slice(rect.x, rect.x + rect.w).padEnd(rect.w, '.'));
|
|
451
|
+
}
|
|
452
|
+
return region;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Writes characters back into one layer, leaving every other cell untouched. */
|
|
456
|
+
function writeRegion(rows, region, x, y) {
|
|
457
|
+
const next = rows.slice();
|
|
458
|
+
for (let row = 0; row < region.length; row += 1) {
|
|
459
|
+
const target = y + row;
|
|
460
|
+
if (target < 0 || target >= next.length) continue;
|
|
461
|
+
const chars = (next[target] || '').split('');
|
|
462
|
+
for (let column = 0; column < region[row].length; column += 1) {
|
|
463
|
+
const at = x + column;
|
|
464
|
+
if (at >= 0 && at < chars.length) chars[at] = region[row][column] || '.';
|
|
465
|
+
}
|
|
466
|
+
next[target] = chars.join('');
|
|
467
|
+
}
|
|
468
|
+
return next;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function blankRegion(rect) {
|
|
472
|
+
const region = [];
|
|
473
|
+
for (let y = 0; y < rect.h; y += 1) region.push('.'.repeat(rect.w));
|
|
474
|
+
return region;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function fillRows(rows, rect, ch) {
|
|
478
|
+
const region = [];
|
|
479
|
+
for (let y = 0; y < rect.h; y += 1) region.push(ch.repeat(rect.w));
|
|
480
|
+
return writeRegion(rows, region, rect.x, rect.y);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function emptyRows(rows, rect) {
|
|
484
|
+
return writeRegion(rows, blankRegion(rect), rect.x, rect.y);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Mirrors the selected characters inside the box. */
|
|
488
|
+
function mirrorRows(rows, rect, horizontal) {
|
|
489
|
+
const region = readRegion(rows, rect);
|
|
490
|
+
const flipped = horizontal
|
|
491
|
+
? region.map((line) => line.split('').reverse().join(''))
|
|
492
|
+
: region.slice().reverse();
|
|
493
|
+
return writeRegion(rows, flipped, rect.x, rect.y);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Top-most visible character in every cell of the rectangle, for the move preview. */
|
|
497
|
+
function mergedRegion(layers, rect) {
|
|
498
|
+
const region = blankRegion(rect);
|
|
499
|
+
layers.forEach((layer) => {
|
|
500
|
+
if (layer.visible === false) return;
|
|
501
|
+
for (let y = 0; y < rect.h; y += 1) {
|
|
502
|
+
const line = layer.rows[rect.y + y] || '';
|
|
503
|
+
const chars = region[y].split('');
|
|
504
|
+
for (let x = 0; x < rect.w; x += 1) {
|
|
505
|
+
const ch = line[rect.x + x];
|
|
506
|
+
if (ch && ch !== '.') chars[x] = ch;
|
|
507
|
+
}
|
|
508
|
+
region[y] = chars.join('');
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
return region;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/* ------------------------------------------------------------------ */
|
|
515
|
+
/* image import */
|
|
516
|
+
/* ------------------------------------------------------------------ */
|
|
517
|
+
|
|
518
|
+
/** Largest integer magnification whose PNG stays under the host's pixel cap. */
|
|
519
|
+
function maxScaleFor(width, height) {
|
|
520
|
+
return Math.max(1, Math.floor(Math.sqrt(MAX_EXPORT_PIXELS / Math.max(1, width * height))));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Remembered export magnification, clamped to what the host can encode for this canvas. */
|
|
524
|
+
function clampExportScale(value, width, height) {
|
|
525
|
+
const wanted = Math.round(Number(value) || 1);
|
|
526
|
+
if (!Number.isFinite(wanted) || wanted < 1) return 1;
|
|
527
|
+
const ceiling = Math.min(64, maxScaleFor(width, height));
|
|
528
|
+
return wanted > ceiling ? ceiling : wanted;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function clampByte(value) {
|
|
532
|
+
const number = Math.round(Number(value) || 0);
|
|
533
|
+
if (number < 0) return 0;
|
|
534
|
+
return number > 255 ? 255 : number;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function hex2(value) {
|
|
538
|
+
return clampByte(value).toString(16).padStart(2, '0');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function rgbOf(color) {
|
|
542
|
+
return [Number(color[0]) || 0, Number(color[1]) || 0, Number(color[2]) || 0];
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function colorDistanceSq(a, b) {
|
|
546
|
+
const dr = a[0] - b[0];
|
|
547
|
+
const dg = a[1] - b[1];
|
|
548
|
+
const db = a[2] - b[2];
|
|
549
|
+
return dr * dr + dg * dg + db * db;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function nearestColorIndex(paletteRgb, color) {
|
|
553
|
+
let best = 0;
|
|
554
|
+
let bestDistance = Infinity;
|
|
555
|
+
for (let i = 0; i < paletteRgb.length; i += 1) {
|
|
556
|
+
const distance = colorDistanceSq(paletteRgb[i], color);
|
|
557
|
+
if (distance < bestDistance) {
|
|
558
|
+
bestDistance = distance;
|
|
559
|
+
best = i;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return best;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** RGB extent of a colour box: [ranges, channel with the widest range]. */
|
|
566
|
+
function boxExtent(box) {
|
|
567
|
+
let minR = 255; let minG = 255; let minB = 255;
|
|
568
|
+
let maxR = 0; let maxG = 0; let maxB = 0;
|
|
569
|
+
for (const color of box) {
|
|
570
|
+
if (color[0] < minR) minR = color[0];
|
|
571
|
+
if (color[1] < minG) minG = color[1];
|
|
572
|
+
if (color[2] < minB) minB = color[2];
|
|
573
|
+
if (color[0] > maxR) maxR = color[0];
|
|
574
|
+
if (color[1] > maxG) maxG = color[1];
|
|
575
|
+
if (color[2] > maxB) maxB = color[2];
|
|
576
|
+
}
|
|
577
|
+
const ranges = [maxR - minR, maxG - minG, maxB - minB];
|
|
578
|
+
let channel = 0;
|
|
579
|
+
if (ranges[1] > ranges[channel]) channel = 1;
|
|
580
|
+
if (ranges[2] > ranges[channel]) channel = 2;
|
|
581
|
+
return { ranges, channel, widest: ranges[channel] };
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function averageColor(box) {
|
|
585
|
+
let r = 0; let g = 0; let b = 0;
|
|
586
|
+
for (const color of box) {
|
|
587
|
+
r += color[0]; g += color[1]; b += color[2];
|
|
588
|
+
}
|
|
589
|
+
const count = box.length === 0 ? 1 : box.length;
|
|
590
|
+
return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Median cut: fold thousands of photo colours into at most `limit` representative ones. */
|
|
594
|
+
function quantizeColors(colors, limit) {
|
|
595
|
+
if (colors.length <= limit) return colors.slice();
|
|
596
|
+
const boxes = [colors.slice()];
|
|
597
|
+
while (boxes.length < limit) {
|
|
598
|
+
let index = -1;
|
|
599
|
+
let score = -1;
|
|
600
|
+
for (let i = 0; i < boxes.length; i += 1) {
|
|
601
|
+
const box = boxes[i];
|
|
602
|
+
if (box.length < 2) continue;
|
|
603
|
+
const extent = boxExtent(box);
|
|
604
|
+
const weight = extent.widest * Math.min(box.length, 256);
|
|
605
|
+
if (weight > score) {
|
|
606
|
+
score = weight;
|
|
607
|
+
index = i;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (index === -1) break;
|
|
611
|
+
const box = boxes[index];
|
|
612
|
+
const channel = boxExtent(box).channel;
|
|
613
|
+
const sorted = box.slice().sort((a, b) => a[channel] - b[channel]);
|
|
614
|
+
const middle = Math.max(1, Math.floor(sorted.length / 2));
|
|
615
|
+
boxes.splice(index, 1, sorted.slice(0, middle), sorted.slice(middle));
|
|
616
|
+
}
|
|
617
|
+
return boxes.map(averageColor);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Most common opaque colours of a sampled image, as hex strings plus RGB triples. */
|
|
621
|
+
function paletteFromSample(sample, limit) {
|
|
622
|
+
const counts = new Map();
|
|
623
|
+
const total = sample.width * sample.height;
|
|
624
|
+
for (let i = 0; i < total; i += 1) {
|
|
625
|
+
if (sample.data[i * 4 + 3] < 128) continue;
|
|
626
|
+
const key = (sample.data[i * 4] << 16) | (sample.data[i * 4 + 1] << 8) | sample.data[i * 4 + 2];
|
|
627
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
628
|
+
}
|
|
629
|
+
const ordered = Array.from(counts.entries())
|
|
630
|
+
.sort((a, b) => b[1] - a[1])
|
|
631
|
+
.map(([key]) => [Number((key >> 16) & 255), Number((key >> 8) & 255), Number(key & 255)]);
|
|
632
|
+
const limited = ordered.length > limit ? quantizeColors(ordered, limit) : ordered;
|
|
633
|
+
const rgb = limited.map((color) => [color[0], color[1], color[2]]);
|
|
634
|
+
return {
|
|
635
|
+
palette: rgb.map(([r, g, b]) => `#${hex2(r)}${hex2(g)}${hex2(b)}`),
|
|
636
|
+
rgb,
|
|
637
|
+
original: ordered.length,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Maps sampled pixels onto the palette; optional Floyd–Steinberg dithering spreads the error. */
|
|
642
|
+
function buildImportRows(sample, paletteRgb, dither) {
|
|
643
|
+
const { width, height, data } = sample;
|
|
644
|
+
const work = new Float32Array(width * height * 3);
|
|
645
|
+
for (let i = 0; i < width * height; i += 1) {
|
|
646
|
+
work[i * 3] = data[i * 4];
|
|
647
|
+
work[i * 3 + 1] = data[i * 4 + 1];
|
|
648
|
+
work[i * 3 + 2] = data[i * 4 + 2];
|
|
649
|
+
}
|
|
650
|
+
const rows = [];
|
|
651
|
+
const spread = (x, y, error, dx, dy, weight) => {
|
|
652
|
+
const nx = x + dx;
|
|
653
|
+
const ny = y + dy;
|
|
654
|
+
if (nx < 0 || nx >= width || ny >= height) return;
|
|
655
|
+
const at = ny * width + nx;
|
|
656
|
+
if (data[at * 4 + 3] < 128) return;
|
|
657
|
+
work[at * 3] += error[0] * weight;
|
|
658
|
+
work[at * 3 + 1] += error[1] * weight;
|
|
659
|
+
work[at * 3 + 2] += error[2] * weight;
|
|
660
|
+
};
|
|
661
|
+
for (let y = 0; y < height; y += 1) {
|
|
662
|
+
let line = '';
|
|
663
|
+
for (let x = 0; x < width; x += 1) {
|
|
664
|
+
const at = y * width + x;
|
|
665
|
+
if (data[at * 4 + 3] < 128) {
|
|
666
|
+
line += '.';
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
const color = [work[at * 3], work[at * 3 + 1], work[at * 3 + 2]];
|
|
670
|
+
const index = nearestColorIndex(paletteRgb, color);
|
|
671
|
+
line += charFor(index);
|
|
672
|
+
if (dither) {
|
|
673
|
+
const chosen = paletteRgb[index];
|
|
674
|
+
const error = [color[0] - chosen[0], color[1] - chosen[1], color[2] - chosen[2]];
|
|
675
|
+
spread(x, y, error, 1, 0, 7 / 16);
|
|
676
|
+
spread(x, y, error, -1, 1, 3 / 16);
|
|
677
|
+
spread(x, y, error, 0, 1, 5 / 16);
|
|
678
|
+
spread(x, y, error, 1, 1, 1 / 16);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
rows.push(line);
|
|
682
|
+
}
|
|
683
|
+
return rows;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** PNG data URL of a char grid, shown as the import preview. */
|
|
687
|
+
function previewDataUrl(palette, rows) {
|
|
688
|
+
const height = rows.length;
|
|
689
|
+
const width = height > 0 ? rows[0].length : 0;
|
|
690
|
+
if (width === 0 || height === 0) return '';
|
|
691
|
+
const canvas = document.createElement('canvas');
|
|
692
|
+
canvas.width = width;
|
|
693
|
+
canvas.height = height;
|
|
694
|
+
const context = canvas.getContext('2d');
|
|
695
|
+
if (!context) return '';
|
|
696
|
+
const image = context.createImageData(width, height);
|
|
697
|
+
const rgb = palette.map(hexToRgb);
|
|
698
|
+
for (let y = 0; y < height; y += 1) {
|
|
699
|
+
const row = rows[y];
|
|
700
|
+
for (let x = 0; x < width; x += 1) {
|
|
701
|
+
const index = indexFor(row === undefined ? '.' : row[x]);
|
|
702
|
+
const offset = (y * width + x) * 4;
|
|
703
|
+
if (index < 0 || index >= rgb.length) {
|
|
704
|
+
image.data[offset + 3] = 0;
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
image.data[offset] = rgb[index][0];
|
|
708
|
+
image.data[offset + 1] = rgb[index][1];
|
|
709
|
+
image.data[offset + 2] = rgb[index][2];
|
|
710
|
+
image.data[offset + 3] = 255;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
context.putImageData(image, 0, 0);
|
|
714
|
+
return canvas.toDataURL('image/png');
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Decoded pixels of a picked file, downscaled into the editor's size cap. */
|
|
718
|
+
async function readImagePixels(file) {
|
|
719
|
+
const url = window.URL.createObjectURL(file);
|
|
720
|
+
try {
|
|
721
|
+
const image = await new Promise((resolve, reject) => {
|
|
722
|
+
const element = new window.Image();
|
|
723
|
+
element.onload = () => resolve(element);
|
|
724
|
+
element.onerror = () => reject(new Error('无法解码这张图片(试 PNG / JPG)'));
|
|
725
|
+
element.src = url;
|
|
726
|
+
});
|
|
727
|
+
const sourceW = Math.max(1, Math.round(image.naturalWidth || image.width || 1));
|
|
728
|
+
const sourceH = Math.max(1, Math.round(image.naturalHeight || image.height || 1));
|
|
729
|
+
const ratio = Math.min(1, MAX_SIDE / Math.max(sourceW, sourceH));
|
|
730
|
+
const width = Math.max(1, Math.round(sourceW * ratio));
|
|
731
|
+
const height = Math.max(1, Math.round(sourceH * ratio));
|
|
732
|
+
const canvas = document.createElement('canvas');
|
|
733
|
+
canvas.width = width;
|
|
734
|
+
canvas.height = height;
|
|
735
|
+
const context = canvas.getContext('2d');
|
|
736
|
+
if (!context) throw new Error('浏览器不支持读取图片');
|
|
737
|
+
context.imageSmoothingEnabled = ratio < 1;
|
|
738
|
+
context.drawImage(image, 0, 0, width, height);
|
|
739
|
+
const data = context.getImageData(0, 0, width, height).data;
|
|
740
|
+
return { width, height, sourceW, sourceH, data };
|
|
741
|
+
} finally {
|
|
742
|
+
window.URL.revokeObjectURL(url);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function importName(file) {
|
|
747
|
+
const raw = file && typeof file.name === 'string' ? file.name : '';
|
|
748
|
+
const stem = raw.replace(/\.[^.]+$/, '').trim();
|
|
749
|
+
return (stem || '导入的图片').slice(0, 60);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** Rebuilds palette + rows + preview for the current import options. */
|
|
753
|
+
function buildImportDocument(dialog, currentPalette) {
|
|
754
|
+
const sample = dialog.sample;
|
|
755
|
+
let palette;
|
|
756
|
+
let rgb;
|
|
757
|
+
let note = '';
|
|
758
|
+
if (dialog.mode === 'current' && Array.isArray(currentPalette) && currentPalette.length > 0) {
|
|
759
|
+
palette = currentPalette.slice(0, IMPORT_MAX_COLORS);
|
|
760
|
+
rgb = palette.map(hexToRgb).map(rgbOf);
|
|
761
|
+
} else if (dialog.mode === 'pico8') {
|
|
762
|
+
palette = PRESETS[0].colors.slice(0, IMPORT_MAX_COLORS);
|
|
763
|
+
rgb = palette.map(hexToRgb).map(rgbOf);
|
|
764
|
+
} else {
|
|
765
|
+
const auto = paletteFromSample(sample, IMPORT_MAX_COLORS);
|
|
766
|
+
palette = auto.palette;
|
|
767
|
+
rgb = auto.rgb;
|
|
768
|
+
note = auto.original > palette.length
|
|
769
|
+
? `${auto.original} 种颜色压到 ${palette.length} 种`
|
|
770
|
+
: `${auto.original} 种颜色`;
|
|
771
|
+
}
|
|
772
|
+
if (palette.length === 0) {
|
|
773
|
+
palette = ['#000000'];
|
|
774
|
+
rgb = [[0, 0, 0]];
|
|
775
|
+
}
|
|
776
|
+
const rows = buildImportRows(sample, rgb, dialog.dither === true);
|
|
777
|
+
return {
|
|
778
|
+
...dialog,
|
|
779
|
+
palette,
|
|
780
|
+
rows,
|
|
781
|
+
note,
|
|
782
|
+
preview: previewDataUrl(palette, rows),
|
|
783
|
+
error: '',
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
349
787
|
/* ------------------------------------------------------------------ */
|
|
350
788
|
/* icons */
|
|
351
789
|
/* ------------------------------------------------------------------ */
|
|
@@ -374,6 +812,14 @@ window.__ModuleLoader__.load({
|
|
|
374
812
|
}),
|
|
375
813
|
h('path', { d: 'M13.2 10.4c1 1.2 1.5 2 1.5 2.6a1.5 1.5 0 0 1-3 0c0-.6.5-1.4 1.5-2.6z', fill: 'currentColor' }));
|
|
376
814
|
}
|
|
815
|
+
if (name === 'select') {
|
|
816
|
+
return h('svg', common,
|
|
817
|
+
h('rect', {
|
|
818
|
+
x: 2.5, y: 2.5, width: 11, height: 11, rx: 1,
|
|
819
|
+
fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeDasharray: '3 2',
|
|
820
|
+
}),
|
|
821
|
+
h('path', { d: 'M8 1.2v2.2M8 12.6v2.2M1.2 8h2.2M12.6 8h2.2', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round' }));
|
|
822
|
+
}
|
|
377
823
|
return h('svg', common,
|
|
378
824
|
h('path', {
|
|
379
825
|
d: 'M9.9 2.6a3.4 3.4 0 1 0 3.5 3.4c0-.6-.2-1.2-.5-1.7l1.6-1.6-1.1-1.1-1.6 1.6a3.4 3.4 0 0 0-1.9-.6z',
|
|
@@ -538,6 +984,24 @@ window.__ModuleLoader__.load({
|
|
|
538
984
|
.pxs-status { font-size: 11px; color: var(--dsw-alias-label-secondary, #9aa1ad); min-width: 54px; text-align: right; }
|
|
539
985
|
.pxs-status[data-state="dirty"] { color: var(--dsw-alias-state-warn-primary, #e0a33a); }
|
|
540
986
|
.pxs-status[data-state="error"] { color: var(--dsw-alias-state-error-primary, #e0574f); }
|
|
987
|
+
.pxs-select-status {
|
|
988
|
+
min-width: 0; text-align: left; font-variant-numeric: tabular-nums;
|
|
989
|
+
color: var(--dsw-alias-brand-primary, #7aa2f7);
|
|
990
|
+
}
|
|
991
|
+
.pxs-selection-bar {
|
|
992
|
+
position: absolute; left: 10px; right: 10px; bottom: 10px; z-index: 3;
|
|
993
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
|
|
994
|
+
padding: 6px 8px; border-radius: 9px;
|
|
995
|
+
background: var(--dsw-alias-bg-overlay, rgba(38, 42, 51, .9));
|
|
996
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .2));
|
|
997
|
+
box-shadow: 0 6px 20px rgba(0, 0, 0, .28);
|
|
998
|
+
backdrop-filter: blur(6px);
|
|
999
|
+
}
|
|
1000
|
+
.pxs-selection-bar .pxs-btn { height: 24px; padding: 0 8px; font-size: 11px; }
|
|
1001
|
+
.pxs-selection-label {
|
|
1002
|
+
font-size: 11px; margin-right: auto; color: var(--dsw-alias-label-secondary, #9aa1ad);
|
|
1003
|
+
font-variant-numeric: tabular-nums; white-space: nowrap;
|
|
1004
|
+
}
|
|
541
1005
|
.pxs-notice {
|
|
542
1006
|
font-size: 11px;
|
|
543
1007
|
color: var(--dsw-alias-state-success-primary, #3fa45b);
|
|
@@ -612,6 +1076,42 @@ window.__ModuleLoader__.load({
|
|
|
612
1076
|
}
|
|
613
1077
|
.pxs-size-label { font-size: 12px; font-weight: 600; }
|
|
614
1078
|
.pxs-size-hint { font-size: 10px; color: var(--dsw-alias-label-secondary, #9aa3b2); }
|
|
1079
|
+
.pxs-import-dialog, .pxs-resize-dialog { width: min(460px, calc(100vw - 40px)); }
|
|
1080
|
+
.pxs-scale-chip {
|
|
1081
|
+
flex-direction: row; align-items: center; gap: 0; padding: 3px 8px; border-radius: 999px;
|
|
1082
|
+
font-size: 11px; color: var(--dsw-alias-label-primary, #e8ebf2);
|
|
1083
|
+
background: var(--dsw-alias-bg-layer-1, #1c1f26);
|
|
1084
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .1));
|
|
1085
|
+
}
|
|
1086
|
+
.pxs-scale-chip:hover { border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, .2)); }
|
|
1087
|
+
.pxs-scale-chip[data-active="true"] {
|
|
1088
|
+
border-color: var(--dsw-alias-brand-primary, #4b7bec);
|
|
1089
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b7bec) 18%, transparent);
|
|
1090
|
+
}
|
|
1091
|
+
.pxs-scale-chip:disabled { opacity: .45; cursor: not-allowed; }
|
|
1092
|
+
.pxs-import-preview {
|
|
1093
|
+
display: flex; justify-content: center; padding: 8px; border-radius: 8px;
|
|
1094
|
+
background: var(--dsw-alias-bg-layer-1, #1c1f26);
|
|
1095
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .08));
|
|
1096
|
+
max-height: 190px; overflow: auto;
|
|
1097
|
+
}
|
|
1098
|
+
.pxs-import-preview img {
|
|
1099
|
+
image-rendering: pixelated; max-width: 100%; max-height: 172px;
|
|
1100
|
+
}
|
|
1101
|
+
.pxs-anchor-grid {
|
|
1102
|
+
display: grid; grid-template-columns: repeat(3, 30px); gap: 3px;
|
|
1103
|
+
}
|
|
1104
|
+
.pxs-anchor-chip {
|
|
1105
|
+
width: 30px; height: 26px; border-radius: 6px; cursor: pointer; font: inherit;
|
|
1106
|
+
line-height: 1; color: var(--dsw-alias-label-primary, #e8ebf2);
|
|
1107
|
+
background: var(--dsw-alias-bg-layer-1, #1c1f26);
|
|
1108
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .1));
|
|
1109
|
+
}
|
|
1110
|
+
.pxs-anchor-chip:hover { border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, .24)); }
|
|
1111
|
+
.pxs-anchor-chip[data-active="true"] {
|
|
1112
|
+
border-color: var(--dsw-alias-brand-primary, #4b7bec);
|
|
1113
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b7bec) 18%, transparent);
|
|
1114
|
+
}
|
|
615
1115
|
.pxs-menu {
|
|
616
1116
|
position: fixed; z-index: 40; min-width: 132px; padding: 4px; border-radius: 8px;
|
|
617
1117
|
background: var(--dsw-alias-bg-overlay, #262a33); border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.2));
|
|
@@ -665,6 +1165,11 @@ window.__ModuleLoader__.load({
|
|
|
665
1165
|
const [renameValue, setRenameValue] = useState('');
|
|
666
1166
|
const [saveDialog, setSaveDialog] = useState(null);
|
|
667
1167
|
const [editing, setEditing] = useState(null);
|
|
1168
|
+
const [importDialog, setImportDialog] = useState(null);
|
|
1169
|
+
const [resizeDialog, setResizeDialog] = useState(null);
|
|
1170
|
+
const [selection, setSelection] = useState(null);
|
|
1171
|
+
const [grabMode, setGrabMode] = useState(false);
|
|
1172
|
+
const [allLayersMode, setAllLayersMode] = useState(false);
|
|
668
1173
|
|
|
669
1174
|
const stageRef = useRef(null);
|
|
670
1175
|
const canvasRef = useRef(null);
|
|
@@ -695,10 +1200,22 @@ window.__ModuleLoader__.load({
|
|
|
695
1200
|
// Indirection for the save-as action: the keyboard effect is registered
|
|
696
1201
|
// before the callback is created, so it must not close over the binding.
|
|
697
1202
|
const saveAsRef = useRef(null);
|
|
1203
|
+
const fileRef = useRef(null);
|
|
1204
|
+
/** Live rectangle, read by the pointer handlers and the paint loop. */
|
|
1205
|
+
const selectionRef = useRef(null);
|
|
1206
|
+
/** In-flight selection drag: { mode, pointerId, start, origin }. */
|
|
1207
|
+
const selectDragRef = useRef(null);
|
|
1208
|
+
/** Indirection for the keyboard effect, which is registered before these callbacks exist. */
|
|
1209
|
+
const selectApiRef = useRef({});
|
|
1210
|
+
const grabRef = useRef(false);
|
|
1211
|
+
const allLayersRef = useRef(false);
|
|
698
1212
|
|
|
699
1213
|
if (gridRef.current !== showGrid) gridRef.current = showGrid;
|
|
700
1214
|
sizeRef.current = { left: leftWidth, right: rightWidth };
|
|
701
1215
|
if (editingRef.current !== editing) editingRef.current = editing;
|
|
1216
|
+
if (grabRef.current !== grabMode) grabRef.current = grabMode;
|
|
1217
|
+
if (allLayersRef.current !== allLayersMode) allLayersRef.current = allLayersMode;
|
|
1218
|
+
selectionRef.current = selection;
|
|
702
1219
|
|
|
703
1220
|
docRef.current = doc;
|
|
704
1221
|
toolRef.current = tool;
|
|
@@ -832,10 +1349,47 @@ window.__ModuleLoader__.load({
|
|
|
832
1349
|
context.restore();
|
|
833
1350
|
}
|
|
834
1351
|
|
|
1352
|
+
if (selection) {
|
|
1353
|
+
const boxX = Math.round(pan.x + selection.x * scale);
|
|
1354
|
+
const boxY = Math.round(pan.y + selection.y * scale);
|
|
1355
|
+
const boxW = Math.max(1, Math.round(selection.w * scale));
|
|
1356
|
+
const boxH = Math.max(1, Math.round(selection.h * scale));
|
|
1357
|
+
const floating = selection.float;
|
|
1358
|
+
if (floating && floating.region) {
|
|
1359
|
+
// Floating content preview, drawn over the cells it was lifted from.
|
|
1360
|
+
for (let row = 0; row < floating.region.length; row += 1) {
|
|
1361
|
+
const line = floating.region[row];
|
|
1362
|
+
for (let column = 0; column < line.length; column += 1) {
|
|
1363
|
+
const ch = line[column];
|
|
1364
|
+
if (!ch || ch === '.') continue;
|
|
1365
|
+
const color = current.palette[indexFor(ch)];
|
|
1366
|
+
if (!color) continue;
|
|
1367
|
+
context.fillStyle = color;
|
|
1368
|
+
context.fillRect(
|
|
1369
|
+
Math.round(pan.x + (selection.x + column) * scale),
|
|
1370
|
+
Math.round(pan.y + (selection.y + row) * scale),
|
|
1371
|
+
Math.ceil(scale),
|
|
1372
|
+
Math.ceil(scale),
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
// Marching ants: one dark and one offset light dashed border.
|
|
1378
|
+
context.save();
|
|
1379
|
+
context.lineWidth = 1;
|
|
1380
|
+
context.setLineDash([4, 4]);
|
|
1381
|
+
context.strokeStyle = 'rgba(0,0,0,0.8)';
|
|
1382
|
+
context.strokeRect(boxX + 0.5, boxY + 0.5, boxW - 1, boxH - 1);
|
|
1383
|
+
context.lineDashOffset = 4;
|
|
1384
|
+
context.strokeStyle = 'rgba(255,255,255,0.95)';
|
|
1385
|
+
context.strokeRect(boxX + 0.5, boxY + 0.5, boxW - 1, boxH - 1);
|
|
1386
|
+
context.restore();
|
|
1387
|
+
}
|
|
1388
|
+
|
|
835
1389
|
context.strokeStyle = 'rgba(255,255,255,0.22)';
|
|
836
1390
|
context.lineWidth = 1;
|
|
837
1391
|
context.strokeRect(Math.round(pan.x) + 0.5, Math.round(pan.y) + 0.5, Math.round(docWidth), Math.round(docHeight));
|
|
838
|
-
}, [composeImage]);
|
|
1392
|
+
}, [composeImage, selection]);
|
|
839
1393
|
|
|
840
1394
|
const requestPaint = useCallback(() => {
|
|
841
1395
|
if (frameRef.current !== 0) return;
|
|
@@ -947,10 +1501,7 @@ window.__ModuleLoader__.load({
|
|
|
947
1501
|
if (!current) return;
|
|
948
1502
|
const history = historyRef.current;
|
|
949
1503
|
if (options.snapshot !== false) {
|
|
950
|
-
history.undo.push(
|
|
951
|
-
palette: current.palette.slice(),
|
|
952
|
-
layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
|
|
953
|
-
});
|
|
1504
|
+
history.undo.push(snapshotOf(current));
|
|
954
1505
|
if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
|
|
955
1506
|
history.redo = [];
|
|
956
1507
|
}
|
|
@@ -972,6 +1523,10 @@ window.__ModuleLoader__.load({
|
|
|
972
1523
|
palette: snapshot.palette.slice(),
|
|
973
1524
|
layers: snapshot.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
|
|
974
1525
|
};
|
|
1526
|
+
if (typeof snapshot.w === 'number' && typeof snapshot.h === 'number') {
|
|
1527
|
+
next.w = snapshot.w;
|
|
1528
|
+
next.h = snapshot.h;
|
|
1529
|
+
}
|
|
975
1530
|
docRef.current = next;
|
|
976
1531
|
setDoc(next);
|
|
977
1532
|
if (!next.layers[layerRef.current]) {
|
|
@@ -988,10 +1543,7 @@ window.__ModuleLoader__.load({
|
|
|
988
1543
|
const snapshot = history.undo.pop();
|
|
989
1544
|
if (!snapshot) return;
|
|
990
1545
|
const current = docRef.current;
|
|
991
|
-
history.redo.push(
|
|
992
|
-
palette: current.palette.slice(),
|
|
993
|
-
layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
|
|
994
|
-
});
|
|
1546
|
+
history.redo.push(snapshotOf(current));
|
|
995
1547
|
applyHistory(snapshot);
|
|
996
1548
|
}, [applyHistory]);
|
|
997
1549
|
|
|
@@ -1000,10 +1552,7 @@ window.__ModuleLoader__.load({
|
|
|
1000
1552
|
const snapshot = history.redo.pop();
|
|
1001
1553
|
if (!snapshot) return;
|
|
1002
1554
|
const current = docRef.current;
|
|
1003
|
-
history.undo.push(
|
|
1004
|
-
palette: current.palette.slice(),
|
|
1005
|
-
layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
|
|
1006
|
-
});
|
|
1555
|
+
history.undo.push(snapshotOf(current));
|
|
1007
1556
|
applyHistory(snapshot);
|
|
1008
1557
|
}, [applyHistory]);
|
|
1009
1558
|
|
|
@@ -1067,6 +1616,170 @@ window.__ModuleLoader__.load({
|
|
|
1067
1616
|
}
|
|
1068
1617
|
}, []);
|
|
1069
1618
|
|
|
1619
|
+
const pushHistory = useCallback(() => {
|
|
1620
|
+
const current = docRef.current;
|
|
1621
|
+
if (!current) return;
|
|
1622
|
+
const history = historyRef.current;
|
|
1623
|
+
history.undo.push(snapshotOf(current));
|
|
1624
|
+
if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
|
|
1625
|
+
history.redo = [];
|
|
1626
|
+
}, []);
|
|
1627
|
+
|
|
1628
|
+
const selectionTargets = useCallback((allLayers) => {
|
|
1629
|
+
const current = docRef.current;
|
|
1630
|
+
if (!current) return [];
|
|
1631
|
+
if (allLayers) {
|
|
1632
|
+
return current.layers
|
|
1633
|
+
.map((layer, index) => ({ layer, index }))
|
|
1634
|
+
.filter((entry) => entry.layer && entry.layer.visible !== false);
|
|
1635
|
+
}
|
|
1636
|
+
const layer = current.layers[layerRef.current];
|
|
1637
|
+
return layer ? [{ layer, index: layerRef.current }] : [];
|
|
1638
|
+
}, []);
|
|
1639
|
+
|
|
1640
|
+
const setSelectionRect = useCallback((rect) => {
|
|
1641
|
+
selectionRef.current = rect;
|
|
1642
|
+
setSelection(rect);
|
|
1643
|
+
}, []);
|
|
1644
|
+
|
|
1645
|
+
const selectAll = useCallback(() => {
|
|
1646
|
+
const current = docRef.current;
|
|
1647
|
+
if (!current) return;
|
|
1648
|
+
setTool('select');
|
|
1649
|
+
setSelectionRect({ x: 0, y: 0, w: current.w, h: current.h, float: null });
|
|
1650
|
+
setStatus('select');
|
|
1651
|
+
requestPaint();
|
|
1652
|
+
}, [requestPaint, setSelectionRect]);
|
|
1653
|
+
|
|
1654
|
+
const putBackLifted = useCallback((current, active) => {
|
|
1655
|
+
const float = active.float;
|
|
1656
|
+
if (!float) return current;
|
|
1657
|
+
const layers = current.layers.map((layer, index) => {
|
|
1658
|
+
const item = float.targets.find((entry) => entry.index === index);
|
|
1659
|
+
if (!item) return layer;
|
|
1660
|
+
return {
|
|
1661
|
+
...layer,
|
|
1662
|
+
rows: writeRegion(layer.rows, item.region, float.origin.x, float.origin.y),
|
|
1663
|
+
};
|
|
1664
|
+
});
|
|
1665
|
+
return { ...current, layers };
|
|
1666
|
+
}, []);
|
|
1667
|
+
|
|
1668
|
+
const cancelSelection = useCallback(() => {
|
|
1669
|
+
const current = docRef.current;
|
|
1670
|
+
const active = selectionRef.current;
|
|
1671
|
+
selectionRef.current = null;
|
|
1672
|
+
setSelection(null);
|
|
1673
|
+
if (current && active && active.float) {
|
|
1674
|
+
const next = putBackLifted(current, active);
|
|
1675
|
+
docRef.current = next;
|
|
1676
|
+
setDoc(next);
|
|
1677
|
+
scheduleSave();
|
|
1678
|
+
}
|
|
1679
|
+
requestPaint();
|
|
1680
|
+
}, [putBackLifted, requestPaint, scheduleSave]);
|
|
1681
|
+
|
|
1682
|
+
/** Lifts the selected pixels out of their layers; false when nothing could be lifted. */
|
|
1683
|
+
const liftSelection = useCallback((allLayers) => {
|
|
1684
|
+
const current = docRef.current;
|
|
1685
|
+
const active = selectionRef.current;
|
|
1686
|
+
if (!current || !active || active.float) return false;
|
|
1687
|
+
const rect = clampRectangle(active, current.w, current.h);
|
|
1688
|
+
const targets = selectionTargets(allLayers);
|
|
1689
|
+
if (targets.length === 0) return false;
|
|
1690
|
+
pushHistory();
|
|
1691
|
+
const items = targets.map((entry) => ({ index: entry.index, region: readRegion(entry.layer.rows, rect) }));
|
|
1692
|
+
const layers = current.layers.map((layer, index) => {
|
|
1693
|
+
const item = items.find((entry) => entry.index === index);
|
|
1694
|
+
return item ? { ...layer, rows: emptyRows(layer.rows, rect) } : layer;
|
|
1695
|
+
});
|
|
1696
|
+
const next = { ...current, layers };
|
|
1697
|
+
docRef.current = next;
|
|
1698
|
+
setDoc(next);
|
|
1699
|
+
setSelectionRect({
|
|
1700
|
+
...rect,
|
|
1701
|
+
float: {
|
|
1702
|
+
region: mergedRegion(current.layers, rect),
|
|
1703
|
+
origin: { x: rect.x, y: rect.y },
|
|
1704
|
+
targets: items,
|
|
1705
|
+
},
|
|
1706
|
+
});
|
|
1707
|
+
setStatus('dirty');
|
|
1708
|
+
return true;
|
|
1709
|
+
}, [pushHistory, selectionTargets, setSelectionRect]);
|
|
1710
|
+
|
|
1711
|
+
/** Drops the lifted pixels at the rectangle's current position. */
|
|
1712
|
+
const dropSelection = useCallback(() => {
|
|
1713
|
+
const current = docRef.current;
|
|
1714
|
+
const active = selectionRef.current;
|
|
1715
|
+
if (!current || !active || !active.float) return;
|
|
1716
|
+
const rect = clampRectangle(active, current.w, current.h);
|
|
1717
|
+
const float = active.float;
|
|
1718
|
+
const layers = current.layers.map((layer, index) => {
|
|
1719
|
+
const item = float.targets.find((entry) => entry.index === index);
|
|
1720
|
+
return item ? { ...layer, rows: writeRegion(layer.rows, item.region, rect.x, rect.y) } : layer;
|
|
1721
|
+
});
|
|
1722
|
+
const next = { ...current, layers };
|
|
1723
|
+
docRef.current = next;
|
|
1724
|
+
setDoc(next);
|
|
1725
|
+
setSelectionRect({ ...rect, float: null });
|
|
1726
|
+
setStatus('dirty');
|
|
1727
|
+
scheduleSave();
|
|
1728
|
+
}, [scheduleSave, setSelectionRect]);
|
|
1729
|
+
|
|
1730
|
+
const applySelectionEdit = useCallback((mode, allLayers) => {
|
|
1731
|
+
const current = docRef.current;
|
|
1732
|
+
const active = selectionRef.current;
|
|
1733
|
+
if (!current || !active) return;
|
|
1734
|
+
if (active.float) {
|
|
1735
|
+
const restored = putBackLifted(current, active);
|
|
1736
|
+
docRef.current = restored;
|
|
1737
|
+
setDoc(restored);
|
|
1738
|
+
}
|
|
1739
|
+
const base = docRef.current;
|
|
1740
|
+
const rect = clampRectangle(active, base.w, base.h);
|
|
1741
|
+
const targets = selectionTargets(allLayers);
|
|
1742
|
+
if (targets.length === 0) return;
|
|
1743
|
+
pushHistory();
|
|
1744
|
+
const layers = base.layers.map((layer, index) => {
|
|
1745
|
+
const target = targets.find((entry) => entry.index === index);
|
|
1746
|
+
if (!target) return layer;
|
|
1747
|
+
let rows = layer.rows;
|
|
1748
|
+
if (mode === 'mirror-h') rows = mirrorRows(rows, rect, true);
|
|
1749
|
+
else if (mode === 'mirror-v') rows = mirrorRows(rows, rect, false);
|
|
1750
|
+
else if (mode === 'fill') rows = fillRows(rows, rect, charFor(colorRef.current));
|
|
1751
|
+
else if (mode === 'delete') rows = emptyRows(rows, rect);
|
|
1752
|
+
return { ...layer, rows };
|
|
1753
|
+
});
|
|
1754
|
+
const next = { ...base, layers };
|
|
1755
|
+
docRef.current = next;
|
|
1756
|
+
setDoc(next);
|
|
1757
|
+
setSelectionRect({ ...rect, float: null });
|
|
1758
|
+
setStatus('dirty');
|
|
1759
|
+
scheduleSave();
|
|
1760
|
+
}, [putBackLifted, pushHistory, scheduleSave, selectionTargets, setSelectionRect]);
|
|
1761
|
+
|
|
1762
|
+
/** Arrow-key nudging: 1 cell, 10 with Shift; carries lifted pixels along. */
|
|
1763
|
+
const nudgeSelection = useCallback((dx, dy) => {
|
|
1764
|
+
const current = docRef.current;
|
|
1765
|
+
const active = selectionRef.current;
|
|
1766
|
+
if (!current || !active) return;
|
|
1767
|
+
const rect = moveRectangle(active, active.x + dx, active.y + dy, current.w, current.h);
|
|
1768
|
+
setSelectionRect({ ...rect, float: active.float });
|
|
1769
|
+
setStatus('select');
|
|
1770
|
+
}, [setSelectionRect]);
|
|
1771
|
+
|
|
1772
|
+
// Exposed to the keyboard effect, which is registered before these callbacks exist.
|
|
1773
|
+
selectApiRef.current = {
|
|
1774
|
+
selectAll,
|
|
1775
|
+
cancel: cancelSelection,
|
|
1776
|
+
apply: applySelectionEdit,
|
|
1777
|
+
nudge: nudgeSelection,
|
|
1778
|
+
drop: dropSelection,
|
|
1779
|
+
lift: liftSelection,
|
|
1780
|
+
allLayers: allLayersRef.current,
|
|
1781
|
+
};
|
|
1782
|
+
|
|
1070
1783
|
const onPointerDown = useCallback((event) => {
|
|
1071
1784
|
if (!docRef.current) return;
|
|
1072
1785
|
const wantsPan = event.button === 1 || spaceRef.current || event.altKey;
|
|
@@ -1087,6 +1800,47 @@ window.__ModuleLoader__.load({
|
|
|
1087
1800
|
if (!cell) return;
|
|
1088
1801
|
const current = docRef.current;
|
|
1089
1802
|
|
|
1803
|
+
if (toolRef.current === 'select') {
|
|
1804
|
+
event.preventDefault();
|
|
1805
|
+
const anchor = {
|
|
1806
|
+
x: clamp(cell.x, 0, Math.max(0, current.w - 1)),
|
|
1807
|
+
y: clamp(cell.y, 0, Math.max(0, current.h - 1)),
|
|
1808
|
+
};
|
|
1809
|
+
const active = selectionRef.current;
|
|
1810
|
+
const inside = active ? pointInRectangle(active, cell) : false;
|
|
1811
|
+
const carry = event.shiftKey || grabRef.current;
|
|
1812
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1813
|
+
if (inside && carry && (active.float || liftSelection(event.altKey))) {
|
|
1814
|
+
selectDragRef.current = {
|
|
1815
|
+
mode: 'grab',
|
|
1816
|
+
pointerId: event.pointerId,
|
|
1817
|
+
start: anchor,
|
|
1818
|
+
origin: { x: active.x, y: active.y },
|
|
1819
|
+
};
|
|
1820
|
+
} else if (inside) {
|
|
1821
|
+
// Plain drag slides the frame; the pixels stay where they are.
|
|
1822
|
+
selectDragRef.current = {
|
|
1823
|
+
mode: 'move',
|
|
1824
|
+
pointerId: event.pointerId,
|
|
1825
|
+
start: anchor,
|
|
1826
|
+
origin: { x: active.x, y: active.y },
|
|
1827
|
+
};
|
|
1828
|
+
} else {
|
|
1829
|
+
setSelectionRect({
|
|
1830
|
+
...clampRectangle({ x: anchor.x, y: anchor.y, w: 1, h: 1 }, current.w, current.h),
|
|
1831
|
+
float: null,
|
|
1832
|
+
});
|
|
1833
|
+
selectDragRef.current = {
|
|
1834
|
+
mode: 'create',
|
|
1835
|
+
pointerId: event.pointerId,
|
|
1836
|
+
start: anchor,
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
setStatus('select');
|
|
1840
|
+
requestPaint();
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1090
1844
|
if (toolRef.current === 'picker') {
|
|
1091
1845
|
pickColorAt(cell);
|
|
1092
1846
|
return;
|
|
@@ -1107,10 +1861,7 @@ window.__ModuleLoader__.load({
|
|
|
1107
1861
|
|
|
1108
1862
|
// Real stroke: snapshot once, then paint continuously.
|
|
1109
1863
|
const history = historyRef.current;
|
|
1110
|
-
history.undo.push(
|
|
1111
|
-
palette: current.palette.slice(),
|
|
1112
|
-
layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
|
|
1113
|
-
});
|
|
1864
|
+
history.undo.push(snapshotOf(current));
|
|
1114
1865
|
if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
|
|
1115
1866
|
history.redo = [];
|
|
1116
1867
|
strokeRef.current = { pointerId: event.pointerId, last: cell };
|
|
@@ -1128,6 +1879,35 @@ window.__ModuleLoader__.load({
|
|
|
1128
1879
|
requestPaint();
|
|
1129
1880
|
return;
|
|
1130
1881
|
}
|
|
1882
|
+
const selectDrag = selectDragRef.current;
|
|
1883
|
+
if (selectDrag && selectDrag.pointerId === event.pointerId) {
|
|
1884
|
+
const current = docRef.current;
|
|
1885
|
+
const cell = cellFromEvent(event);
|
|
1886
|
+
if (!current || !cell) return;
|
|
1887
|
+
const anchor = {
|
|
1888
|
+
x: clamp(cell.x, 0, Math.max(0, current.w - 1)),
|
|
1889
|
+
y: clamp(cell.y, 0, Math.max(0, current.h - 1)),
|
|
1890
|
+
};
|
|
1891
|
+
if (selectDrag.mode === 'create') {
|
|
1892
|
+
setSelectionRect({
|
|
1893
|
+
...clampRectangle(selectionBetween(selectDrag.start, anchor), current.w, current.h),
|
|
1894
|
+
float: null,
|
|
1895
|
+
});
|
|
1896
|
+
} else {
|
|
1897
|
+
const active = selectionRef.current;
|
|
1898
|
+
if (!active) return;
|
|
1899
|
+
const rect = moveRectangle(
|
|
1900
|
+
{ x: 0, y: 0, w: active.w, h: active.h },
|
|
1901
|
+
selectDrag.origin.x + (anchor.x - selectDrag.start.x),
|
|
1902
|
+
selectDrag.origin.y + (anchor.y - selectDrag.start.y),
|
|
1903
|
+
current.w,
|
|
1904
|
+
current.h,
|
|
1905
|
+
);
|
|
1906
|
+
setSelectionRect({ ...rect, float: active.float });
|
|
1907
|
+
}
|
|
1908
|
+
requestPaint();
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1131
1911
|
const stroke = strokeRef.current;
|
|
1132
1912
|
if (!stroke || stroke.pointerId !== event.pointerId) return;
|
|
1133
1913
|
const cell = cellFromEvent(event);
|
|
@@ -1138,6 +1918,18 @@ window.__ModuleLoader__.load({
|
|
|
1138
1918
|
}, [cellFromEvent, requestPaint, strokeAt]);
|
|
1139
1919
|
|
|
1140
1920
|
const onPointerUp = useCallback((event) => {
|
|
1921
|
+
const selectDrag = selectDragRef.current;
|
|
1922
|
+
if (selectDrag && selectDrag.pointerId === event.pointerId) {
|
|
1923
|
+
selectDragRef.current = null;
|
|
1924
|
+
if (selectDrag.mode === 'grab') dropSelection();
|
|
1925
|
+
const active = selectionRef.current;
|
|
1926
|
+
if (active && (active.w < 1 || active.h < 1)) {
|
|
1927
|
+
selectionRef.current = null;
|
|
1928
|
+
setSelection(null);
|
|
1929
|
+
}
|
|
1930
|
+
requestPaint();
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1141
1933
|
if (panDrawRef.current && panDrawRef.current.pointerId === event.pointerId) {
|
|
1142
1934
|
panDrawRef.current = null;
|
|
1143
1935
|
requestPaint();
|
|
@@ -1210,6 +2002,11 @@ window.__ModuleLoader__.load({
|
|
|
1210
2002
|
return;
|
|
1211
2003
|
}
|
|
1212
2004
|
const meta = event.metaKey || event.ctrlKey;
|
|
2005
|
+
if (meta && event.key.toLowerCase() === 'a') {
|
|
2006
|
+
event.preventDefault();
|
|
2007
|
+
selectApiRef.current.selectAll();
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
1213
2010
|
if (meta && event.key.toLowerCase() === 'z') {
|
|
1214
2011
|
event.preventDefault();
|
|
1215
2012
|
if (event.shiftKey) redo();
|
|
@@ -1233,10 +2030,45 @@ window.__ModuleLoader__.load({
|
|
|
1233
2030
|
}
|
|
1234
2031
|
if (meta) return;
|
|
1235
2032
|
const key = event.key.toLowerCase();
|
|
2033
|
+
if (key === 'escape') {
|
|
2034
|
+
selectApiRef.current.cancel();
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
if (selectionRef.current) {
|
|
2038
|
+
if (key === 'delete' || key === 'backspace') {
|
|
2039
|
+
event.preventDefault();
|
|
2040
|
+
selectApiRef.current.apply('delete', selectApiRef.current.allLayers);
|
|
2041
|
+
return;
|
|
2042
|
+
}
|
|
2043
|
+
if (key.indexOf('arrow') === 0) {
|
|
2044
|
+
event.preventDefault();
|
|
2045
|
+
const step = event.shiftKey ? 10 : 1;
|
|
2046
|
+
const dx = key === 'arrowleft' ? -step : key === 'arrowright' ? step : 0;
|
|
2047
|
+
const dy = key === 'arrowup' ? -step : key === 'arrowdown' ? step : 0;
|
|
2048
|
+
selectApiRef.current.nudge(dx, dy);
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
if (event.shiftKey && key === 'h') {
|
|
2052
|
+
event.preventDefault();
|
|
2053
|
+
selectApiRef.current.apply('mirror-h', selectApiRef.current.allLayers);
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
if (event.shiftKey && key === 'v') {
|
|
2057
|
+
event.preventDefault();
|
|
2058
|
+
selectApiRef.current.apply('mirror-v', selectApiRef.current.allLayers);
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
if (event.shiftKey && key === 'f') {
|
|
2062
|
+
event.preventDefault();
|
|
2063
|
+
selectApiRef.current.apply('fill', selectApiRef.current.allLayers);
|
|
2064
|
+
return;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
1236
2067
|
if (key === 'b') setTool('pencil');
|
|
1237
2068
|
else if (key === 'e') setTool('eraser');
|
|
1238
2069
|
else if (key === 'g') setTool('bucket');
|
|
1239
2070
|
else if (key === 'i') setTool('picker');
|
|
2071
|
+
else if (key === 'm') setTool('select');
|
|
1240
2072
|
else if (key === 'f') fitToView();
|
|
1241
2073
|
else if (key === 'h') {
|
|
1242
2074
|
const next = !gridRef.current;
|
|
@@ -1410,6 +2242,7 @@ window.__ModuleLoader__.load({
|
|
|
1410
2242
|
root: '',
|
|
1411
2243
|
dirs: [],
|
|
1412
2244
|
name,
|
|
2245
|
+
scale: clampExportScale(readStoredNumber('pxs:exportScale', 1), current.w, current.h),
|
|
1413
2246
|
loading: true,
|
|
1414
2247
|
busy: false,
|
|
1415
2248
|
error: '',
|
|
@@ -1423,15 +2256,17 @@ window.__ModuleLoader__.load({
|
|
|
1423
2256
|
if (!current || !dialog || dialog.busy) return;
|
|
1424
2257
|
if (typeof dialog.path !== 'string' || dialog.path === '') return;
|
|
1425
2258
|
const target = joinPath(dialog.path, ensurePngName(dialog.name));
|
|
2259
|
+
const scale = clampExportScale(dialog.scale, current.w, current.h);
|
|
1426
2260
|
setSaveDialog((value) => (value ? { ...value, busy: true, error: '' } : value));
|
|
1427
2261
|
try {
|
|
1428
|
-
const saved = await callApi({ op: 'exportTo', doc: current, path: target });
|
|
2262
|
+
const saved = await callApi({ op: 'exportTo', doc: current, path: target, scale });
|
|
1429
2263
|
const next = { ...current, filePath: typeof saved.path === 'string' ? saved.path : target };
|
|
1430
2264
|
docRef.current = next;
|
|
1431
2265
|
setDoc(next);
|
|
1432
2266
|
setSaveDialog(null);
|
|
1433
2267
|
setError('');
|
|
1434
|
-
|
|
2268
|
+
storeValue('pxs:exportScale', String(scale));
|
|
2269
|
+
setNotice(`已保存 ${next.filePath}${scale > 1 ? `(${scale}×)` : ''}`);
|
|
1435
2270
|
const stored = await callApi({ op: 'save', doc: next });
|
|
1436
2271
|
if (stored && stored.doc) {
|
|
1437
2272
|
docRef.current = stored.doc;
|
|
@@ -1455,8 +2290,10 @@ window.__ModuleLoader__.load({
|
|
|
1455
2290
|
return;
|
|
1456
2291
|
}
|
|
1457
2292
|
try {
|
|
1458
|
-
const
|
|
1459
|
-
|
|
2293
|
+
const scale = clampExportScale(readStoredNumber('pxs:exportScale', 1), current.w, current.h);
|
|
2294
|
+
const saved = await callApi({ op: 'exportTo', doc: current, path: filePath, scale });
|
|
2295
|
+
const suffix = scale > 1 ? `(${scale}×)` : '';
|
|
2296
|
+
setNotice(`${saved.overwritten ? `已覆盖 ${saved.path}` : `已保存 ${saved.path}`}${suffix}`);
|
|
1460
2297
|
setError('');
|
|
1461
2298
|
} catch (cause) {
|
|
1462
2299
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
@@ -1472,20 +2309,130 @@ window.__ModuleLoader__.load({
|
|
|
1472
2309
|
saveAsRef.current = saveAsPng;
|
|
1473
2310
|
|
|
1474
2311
|
/* -------------------------------------------------------------- */
|
|
1475
|
-
/*
|
|
2312
|
+
/* import a picture */
|
|
1476
2313
|
/* -------------------------------------------------------------- */
|
|
1477
2314
|
|
|
1478
|
-
const
|
|
2315
|
+
const openImportDialog = useCallback((file) => {
|
|
2316
|
+
if (!file) return;
|
|
2317
|
+
void (async () => {
|
|
2318
|
+
try {
|
|
2319
|
+
const sample = await readImagePixels(file);
|
|
2320
|
+
const current = docRef.current;
|
|
2321
|
+
setError('');
|
|
2322
|
+
setImportDialog(buildImportDocument({
|
|
2323
|
+
sample,
|
|
2324
|
+
name: importName(file),
|
|
2325
|
+
mode: 'auto',
|
|
2326
|
+
dither: false,
|
|
2327
|
+
sourceW: sample.sourceW,
|
|
2328
|
+
sourceH: sample.sourceH,
|
|
2329
|
+
width: sample.width,
|
|
2330
|
+
height: sample.height,
|
|
2331
|
+
busy: false,
|
|
2332
|
+
}, current ? current.palette : null));
|
|
2333
|
+
} catch (cause) {
|
|
2334
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
2335
|
+
}
|
|
2336
|
+
})();
|
|
2337
|
+
}, []);
|
|
2338
|
+
|
|
2339
|
+
const retuneImport = useCallback((patch) => {
|
|
2340
|
+
const current = docRef.current;
|
|
2341
|
+
setImportDialog((value) => (value
|
|
2342
|
+
? buildImportDocument({ ...value, ...patch }, current ? current.palette : null)
|
|
2343
|
+
: value));
|
|
2344
|
+
}, []);
|
|
2345
|
+
|
|
2346
|
+
const confirmImport = useCallback(() => {
|
|
2347
|
+
const dialog = importDialog;
|
|
2348
|
+
if (!dialog || dialog.busy || !Array.isArray(dialog.rows)) return;
|
|
2349
|
+
setImportDialog((value) => (value ? { ...value, busy: true, error: '' } : value));
|
|
2350
|
+
void (async () => {
|
|
2351
|
+
try {
|
|
2352
|
+
const width = dialog.width;
|
|
2353
|
+
const height = dialog.height;
|
|
2354
|
+
const name = String(dialog.name || '').trim().slice(0, 60) || `${width}×${height} 导入`;
|
|
2355
|
+
const created = await callApi({ op: 'create', name, w: width, h: height, palette: dialog.palette });
|
|
2356
|
+
// create ignores layers, so the pixel grid goes in through save.
|
|
2357
|
+
const saved = await callApi({
|
|
2358
|
+
op: 'save',
|
|
2359
|
+
doc: {
|
|
2360
|
+
...created.doc,
|
|
2361
|
+
layers: [{ name: '图层 1', visible: true, rows: dialog.rows }],
|
|
2362
|
+
},
|
|
2363
|
+
});
|
|
2364
|
+
const next = saved && saved.doc ? saved.doc : created.doc;
|
|
2365
|
+
docRef.current = next;
|
|
2366
|
+
setDoc(next);
|
|
2367
|
+
layerRef.current = 0;
|
|
2368
|
+
setLayerIndex(0);
|
|
2369
|
+
setColorIndex(0);
|
|
2370
|
+
editSessionRef.current = null;
|
|
2371
|
+
setEditing(null);
|
|
2372
|
+
historyRef.current = { undo: [], redo: [] };
|
|
2373
|
+
setImportDialog(null);
|
|
2374
|
+
setStatus('clean');
|
|
2375
|
+
setNotice(`已导入「${name}」${width}×${height}`);
|
|
2376
|
+
await refreshDocs();
|
|
2377
|
+
window.requestAnimationFrame(() => fitToView());
|
|
2378
|
+
} catch (cause) {
|
|
2379
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
2380
|
+
setImportDialog((value) => (value ? { ...value, busy: false, error: message } : value));
|
|
2381
|
+
}
|
|
2382
|
+
})();
|
|
2383
|
+
}, [fitToView, importDialog, refreshDocs]);
|
|
2384
|
+
|
|
2385
|
+
/* -------------------------------------------------------------- */
|
|
2386
|
+
/* canvas size / crop */
|
|
2387
|
+
/* -------------------------------------------------------------- */
|
|
2388
|
+
|
|
2389
|
+
const openResizeDialog = useCallback(() => {
|
|
1479
2390
|
const current = docRef.current;
|
|
1480
2391
|
if (!current) return;
|
|
2392
|
+
setResizeDialog({ w: current.w, h: current.h, anchor: 'c', error: '' });
|
|
2393
|
+
}, []);
|
|
2394
|
+
|
|
2395
|
+
const confirmResize = useCallback(() => {
|
|
2396
|
+
const current = docRef.current;
|
|
2397
|
+
const dialog = resizeDialog;
|
|
2398
|
+
if (!current || !dialog) return;
|
|
2399
|
+
const width = Math.round(Number(dialog.w));
|
|
2400
|
+
const height = Math.round(Number(dialog.h));
|
|
2401
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) {
|
|
2402
|
+
setResizeDialog({ ...dialog, error: '宽高要填 1 以上的整数' });
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
if (width > MAX_SIDE || height > MAX_SIDE) {
|
|
2406
|
+
setResizeDialog({ ...dialog, error: `最大 ${MAX_SIDE}×${MAX_SIDE}` });
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
setResizeDialog(null);
|
|
2410
|
+
if (width === current.w && height === current.h) {
|
|
2411
|
+
setNotice('画布尺寸没有变化');
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
1481
2414
|
const history = historyRef.current;
|
|
1482
|
-
history.undo.push(
|
|
1483
|
-
palette: current.palette.slice(),
|
|
1484
|
-
layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
|
|
1485
|
-
});
|
|
2415
|
+
history.undo.push(snapshotOf(current));
|
|
1486
2416
|
if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
|
|
1487
2417
|
history.redo = [];
|
|
1488
|
-
|
|
2418
|
+
const layers = current.layers.map((layer) => ({
|
|
2419
|
+
...layer,
|
|
2420
|
+
rows: resizeRows(layer.rows, current.w, current.h, width, height, dialog.anchor),
|
|
2421
|
+
}));
|
|
2422
|
+
const next = { ...current, w: width, h: height, layers };
|
|
2423
|
+
docRef.current = next;
|
|
2424
|
+
setDoc(next);
|
|
2425
|
+
scheduleSave();
|
|
2426
|
+
setNotice(`画布已调整为 ${width}×${height}`);
|
|
2427
|
+
window.requestAnimationFrame(() => {
|
|
2428
|
+
fitToView();
|
|
2429
|
+
requestPaint();
|
|
2430
|
+
});
|
|
2431
|
+
}, [fitToView, requestPaint, resizeDialog, scheduleSave]);
|
|
2432
|
+
|
|
2433
|
+
/* -------------------------------------------------------------- */
|
|
2434
|
+
/* palette actions */
|
|
2435
|
+
/* -------------------------------------------------------------- */
|
|
1489
2436
|
|
|
1490
2437
|
const updatePalette = useCallback((palette, options = {}) => {
|
|
1491
2438
|
const current = docRef.current;
|
|
@@ -1691,10 +2638,7 @@ window.__ModuleLoader__.load({
|
|
|
1691
2638
|
const current = docRef.current;
|
|
1692
2639
|
if (!current) return null;
|
|
1693
2640
|
const history = historyRef.current;
|
|
1694
|
-
history.undo.push(
|
|
1695
|
-
palette: current.palette.slice(),
|
|
1696
|
-
layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
|
|
1697
|
-
});
|
|
2641
|
+
history.undo.push(snapshotOf(current));
|
|
1698
2642
|
if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
|
|
1699
2643
|
history.redo = [];
|
|
1700
2644
|
return current;
|
|
@@ -1896,6 +2840,10 @@ window.__ModuleLoader__.load({
|
|
|
1896
2840
|
: status === 'error' ? '保存失败'
|
|
1897
2841
|
: '已保存';
|
|
1898
2842
|
|
|
2843
|
+
const selectionText = selection
|
|
2844
|
+
? `选区 ${selection.w}×${selection.h} @ (${selection.x},${selection.y})${selection.float ? ' · 浮动' : ''}${allLayersMode ? ' · 全部图层' : ''}`
|
|
2845
|
+
: '';
|
|
2846
|
+
|
|
1899
2847
|
const currentLayer = doc ? doc.layers[layerIndex] : null;
|
|
1900
2848
|
|
|
1901
2849
|
const swatchNodes = doc ? doc.palette.map((color, index) => h('button', {
|
|
@@ -1949,6 +2897,31 @@ window.__ModuleLoader__.load({
|
|
|
1949
2897
|
onClick: openNewDialog,
|
|
1950
2898
|
title: '新建作品(可选名字与尺寸)',
|
|
1951
2899
|
}, '新建'),
|
|
2900
|
+
h('button', {
|
|
2901
|
+
className: 'pxs-btn',
|
|
2902
|
+
onClick: () => {
|
|
2903
|
+
if (fileRef.current) fileRef.current.click();
|
|
2904
|
+
},
|
|
2905
|
+
title: '打开图片,转成像素画导入新作品',
|
|
2906
|
+
}, '打开图片'),
|
|
2907
|
+
h('input', {
|
|
2908
|
+
ref: fileRef,
|
|
2909
|
+
type: 'file',
|
|
2910
|
+
accept: 'image/png,image/jpeg,image/gif,image/webp,image/bmp',
|
|
2911
|
+
style: { display: 'none' },
|
|
2912
|
+
onChange: (event) => {
|
|
2913
|
+
const input = event.target;
|
|
2914
|
+
const picked = input.files && input.files[0];
|
|
2915
|
+
input.value = '';
|
|
2916
|
+
if (picked) openImportDialog(picked);
|
|
2917
|
+
},
|
|
2918
|
+
}),
|
|
2919
|
+
h('button', {
|
|
2920
|
+
className: 'pxs-btn',
|
|
2921
|
+
disabled: !doc,
|
|
2922
|
+
onClick: openResizeDialog,
|
|
2923
|
+
title: '调整画布尺寸 / 裁剪(可撤销)',
|
|
2924
|
+
}, '尺寸'),
|
|
1952
2925
|
h('span', { className: 'pxs-spacer' }),
|
|
1953
2926
|
h('button', {
|
|
1954
2927
|
className: 'pxs-btn pxs-btn-icon',
|
|
@@ -2019,6 +2992,7 @@ window.__ModuleLoader__.load({
|
|
|
2019
2992
|
title: '删除当前作品',
|
|
2020
2993
|
}, '删除'),
|
|
2021
2994
|
h('span', { className: 'pxs-status', 'data-state': status }, statusText),
|
|
2995
|
+
selection ? h('span', { className: 'pxs-status pxs-select-status', title: '选区:拖动移框,Shift+拖动带内容,方向键微调,Esc 取消' }, selectionText) : null,
|
|
2022
2996
|
notice ? h('span', { className: 'pxs-notice', title: notice }, notice) : null);
|
|
2023
2997
|
|
|
2024
2998
|
const errorNode = error ? h('div', { className: 'pxs-error' },
|
|
@@ -2160,7 +3134,10 @@ window.__ModuleLoader__.load({
|
|
|
2160
3134
|
h('div', null, h('kbd', null, 'G'), '填充 ', h('kbd', null, 'I'), '吸管'),
|
|
2161
3135
|
h('div', null, h('kbd', null, '滚轮'), '缩放 ', h('kbd', null, '空格'), '+ 拖动'),
|
|
2162
3136
|
h('div', null, h('kbd', null, 'F'), '适应窗口 ', h('kbd', null, '[ ]'), '笔刷'),
|
|
2163
|
-
h('div', null, h('kbd', null, 'H'), '格线开/关 ', h('kbd', null, 'Ctrl+S'), '保存')
|
|
3137
|
+
h('div', null, h('kbd', null, 'H'), '格线开/关 ', h('kbd', null, 'Ctrl+S'), '保存'),
|
|
3138
|
+
h('div', null, h('kbd', null, 'M'), '选区 ', h('kbd', null, 'Ctrl+A'), '全选'),
|
|
3139
|
+
h('div', null, h('kbd', null, 'Shift'), '+ 拖动', ' 移内容'),
|
|
3140
|
+
h('div', null, h('kbd', null, 'Del'), '删除 ', h('kbd', null, 'Esc'), '取消')));
|
|
2164
3141
|
|
|
2165
3142
|
const leftPanel = h('div', { className: 'pxs-panel pxs-panel-left' },
|
|
2166
3143
|
toolsSection, brushSection, layerSection, canvasSection, keysSection);
|
|
@@ -2172,7 +3149,10 @@ window.__ModuleLoader__.load({
|
|
|
2172
3149
|
onPointerMove,
|
|
2173
3150
|
onPointerUp,
|
|
2174
3151
|
onPointerCancel: onPointerUp,
|
|
2175
|
-
onContextMenu: (event) =>
|
|
3152
|
+
onContextMenu: (event) => {
|
|
3153
|
+
event.preventDefault();
|
|
3154
|
+
if (selectionRef.current) selectApiRef.current.cancel();
|
|
3155
|
+
},
|
|
2176
3156
|
onDragStart: (event) => event.preventDefault(),
|
|
2177
3157
|
});
|
|
2178
3158
|
|
|
@@ -2188,8 +3168,49 @@ window.__ModuleLoader__.load({
|
|
|
2188
3168
|
|
|
2189
3169
|
const loadingNode = loading ? h('div', { className: 'pxs-empty' }, h('p', null, '加载作品中…')) : null;
|
|
2190
3170
|
|
|
3171
|
+
const selectionBar = selection ? h('div', { className: 'pxs-selection-bar' },
|
|
3172
|
+
h('span', { className: 'pxs-selection-label' },
|
|
3173
|
+
`${selection.w}×${selection.h} @ ${selection.x},${selection.y}`),
|
|
3174
|
+
h('button', {
|
|
3175
|
+
className: 'pxs-btn',
|
|
3176
|
+
onClick: () => selectApiRef.current.apply('mirror-h', allLayersRef.current),
|
|
3177
|
+
title: '水平镜像 (Shift+H)',
|
|
3178
|
+
}, '⇆ 水平镜像'),
|
|
3179
|
+
h('button', {
|
|
3180
|
+
className: 'pxs-btn',
|
|
3181
|
+
onClick: () => selectApiRef.current.apply('mirror-v', allLayersRef.current),
|
|
3182
|
+
title: '垂直镜像 (Shift+V)',
|
|
3183
|
+
}, '⇅ 垂直镜像'),
|
|
3184
|
+
h('button', {
|
|
3185
|
+
className: 'pxs-btn',
|
|
3186
|
+
onClick: () => selectApiRef.current.apply('fill', allLayersRef.current),
|
|
3187
|
+
title: '用当前颜色填充 (Shift+F)',
|
|
3188
|
+
}, '▣ 填充'),
|
|
3189
|
+
h('button', {
|
|
3190
|
+
className: 'pxs-btn',
|
|
3191
|
+
onClick: () => selectApiRef.current.apply('delete', allLayersRef.current),
|
|
3192
|
+
title: '删除选区内容 (Delete)',
|
|
3193
|
+
}, '⌫ 删除'),
|
|
3194
|
+
h('button', {
|
|
3195
|
+
className: 'pxs-btn',
|
|
3196
|
+
'data-active': grabMode,
|
|
3197
|
+
title: '抓着像素拖动(也可以 Shift+拖动)',
|
|
3198
|
+
onClick: () => setGrabMode((value) => !value),
|
|
3199
|
+
}, grabMode ? '✱ 抓着:开' : '✱ 抓着像素'),
|
|
3200
|
+
h('button', {
|
|
3201
|
+
className: 'pxs-btn',
|
|
3202
|
+
'data-active': allLayersMode,
|
|
3203
|
+
title: '镜像 / 填充 / 删除作用于所有可见图层(也可以按住 Alt)',
|
|
3204
|
+
onClick: () => setAllLayersMode((value) => !value),
|
|
3205
|
+
}, allLayersMode ? '多层:开' : '多层'),
|
|
3206
|
+
h('button', {
|
|
3207
|
+
className: 'pxs-btn',
|
|
3208
|
+
onClick: () => selectApiRef.current.cancel(),
|
|
3209
|
+
title: '取消选区 (Esc)',
|
|
3210
|
+
}, '✕')) : null;
|
|
3211
|
+
|
|
2191
3212
|
const stageNode = h('div', { className: 'pxs-stage', ref: stageRef },
|
|
2192
|
-
canvasNode, emptyNode, loadingNode);
|
|
3213
|
+
canvasNode, emptyNode, loadingNode, selectionBar);
|
|
2193
3214
|
|
|
2194
3215
|
const colorSection = h('div', { className: 'pxs-section' },
|
|
2195
3216
|
h('p', { className: 'pxs-title' }, '当前颜色'),
|
|
@@ -2396,6 +3417,25 @@ window.__ModuleLoader__.load({
|
|
|
2396
3417
|
}
|
|
2397
3418
|
},
|
|
2398
3419
|
})),
|
|
3420
|
+
h('div', { className: 'pxs-dialog-row', style: { flexWrap: 'wrap' } },
|
|
3421
|
+
h('span', { className: 'pxs-dialog-label' }, '倍率'),
|
|
3422
|
+
EXPORT_SCALES.map((value) => {
|
|
3423
|
+
const ceiling = maxScaleFor(doc ? doc.w : 1, doc ? doc.h : 1);
|
|
3424
|
+
const allowed = value <= ceiling;
|
|
3425
|
+
return h('button', {
|
|
3426
|
+
key: value,
|
|
3427
|
+
className: 'pxs-scale-chip',
|
|
3428
|
+
'data-active': Number(saveDialog.scale) === value ? 'true' : 'false',
|
|
3429
|
+
disabled: !allowed,
|
|
3430
|
+
title: allowed
|
|
3431
|
+
? `${value}× → ${(doc ? doc.w : 0) * value}×${(doc ? doc.h : 0) * value}`
|
|
3432
|
+
: '超出单张 PNG 上限',
|
|
3433
|
+
onClick: () => setSaveDialog((current) => (current ? { ...current, scale: value } : current)),
|
|
3434
|
+
}, `${value}×`);
|
|
3435
|
+
}),
|
|
3436
|
+
h('span', { className: 'pxs-hint' }, doc
|
|
3437
|
+
? `${saveDialog.scale}× = ${doc.w * saveDialog.scale}×${doc.h * saveDialog.scale}`
|
|
3438
|
+
: '')),
|
|
2399
3439
|
saveDialog.error ? h('div', { className: 'pxs-dialog-error' }, saveDialog.error) : null,
|
|
2400
3440
|
h('div', { className: 'pxs-dialog-actions' },
|
|
2401
3441
|
h('button', { className: 'pxs-btn', onClick: () => setSaveDialog(null) }, '取消'),
|
|
@@ -2490,6 +3530,139 @@ window.__ModuleLoader__.load({
|
|
|
2490
3530
|
onClick: confirmNewDoc,
|
|
2491
3531
|
}, creating ? '创建中…' : '创建')))) : null;
|
|
2492
3532
|
|
|
3533
|
+
const importDialogNode = importDialog ? h('div', {
|
|
3534
|
+
className: 'pxs-modal',
|
|
3535
|
+
onPointerDown: (event) => {
|
|
3536
|
+
if (event.target === event.currentTarget && !importDialog.busy) setImportDialog(null);
|
|
3537
|
+
},
|
|
3538
|
+
},
|
|
3539
|
+
h('div', { className: 'pxs-dialog pxs-import-dialog', role: 'dialog', 'aria-label': '打开图片' },
|
|
3540
|
+
h('div', { className: 'pxs-dialog-head' },
|
|
3541
|
+
h('strong', null, '打开图片'),
|
|
3542
|
+
h('button', {
|
|
3543
|
+
className: 'pxs-dialog-close',
|
|
3544
|
+
disabled: importDialog.busy,
|
|
3545
|
+
onClick: () => setImportDialog(null),
|
|
3546
|
+
}, '×')),
|
|
3547
|
+
h('div', { className: 'pxs-dialog-row' },
|
|
3548
|
+
h('span', { className: 'pxs-dialog-label' }, '名字'),
|
|
3549
|
+
h('input', {
|
|
3550
|
+
className: 'pxs-input',
|
|
3551
|
+
style: { flex: 1 },
|
|
3552
|
+
maxLength: 60,
|
|
3553
|
+
value: importDialog.name,
|
|
3554
|
+
onChange: (event) => {
|
|
3555
|
+
const value = event.target.value;
|
|
3556
|
+
setImportDialog((current) => (current ? { ...current, name: value } : current));
|
|
3557
|
+
},
|
|
3558
|
+
})),
|
|
3559
|
+
h('div', { className: 'pxs-dialog-row' },
|
|
3560
|
+
h('span', { className: 'pxs-dialog-label' }, '原图'),
|
|
3561
|
+
h('span', { className: 'pxs-hint' },
|
|
3562
|
+
`${importDialog.sourceW}×${importDialog.sourceH} → ${importDialog.width}×${importDialog.height}`
|
|
3563
|
+
+ ` · ${importDialog.palette.length} 色`
|
|
3564
|
+
+ (importDialog.note ? ` · ${importDialog.note}` : ''))),
|
|
3565
|
+
h('div', { className: 'pxs-dialog-row', style: { flexWrap: 'wrap' } },
|
|
3566
|
+
h('span', { className: 'pxs-dialog-label' }, '配色'),
|
|
3567
|
+
[
|
|
3568
|
+
{ id: 'auto', label: '自动提取' },
|
|
3569
|
+
{ id: 'current', label: '当前调色板', disabled: !(doc && doc.palette.length > 0) },
|
|
3570
|
+
{ id: 'pico8', label: 'PICO-8' },
|
|
3571
|
+
].map((mode) => h('button', {
|
|
3572
|
+
key: mode.id,
|
|
3573
|
+
className: 'pxs-scale-chip',
|
|
3574
|
+
'data-active': importDialog.mode === mode.id ? 'true' : 'false',
|
|
3575
|
+
disabled: mode.disabled === true || importDialog.busy,
|
|
3576
|
+
onClick: () => retuneImport({ mode: mode.id }),
|
|
3577
|
+
}, mode.label)),
|
|
3578
|
+
h('button', {
|
|
3579
|
+
className: 'pxs-scale-chip',
|
|
3580
|
+
'data-active': importDialog.dither ? 'true' : 'false',
|
|
3581
|
+
disabled: importDialog.busy,
|
|
3582
|
+
title: '误差扩散抖动,渐变更自然但像素更碎',
|
|
3583
|
+
onClick: () => retuneImport({ dither: !importDialog.dither }),
|
|
3584
|
+
}, '抖动')),
|
|
3585
|
+
importDialog.preview
|
|
3586
|
+
? h('div', { className: 'pxs-import-preview' },
|
|
3587
|
+
h('img', { src: importDialog.preview, alt: '导入预览' }))
|
|
3588
|
+
: null,
|
|
3589
|
+
importDialog.error ? h('div', { className: 'pxs-dialog-error' }, importDialog.error) : null,
|
|
3590
|
+
h('div', { className: 'pxs-dialog-actions' },
|
|
3591
|
+
h('button', {
|
|
3592
|
+
className: 'pxs-btn',
|
|
3593
|
+
disabled: importDialog.busy,
|
|
3594
|
+
onClick: () => setImportDialog(null),
|
|
3595
|
+
}, '取消'),
|
|
3596
|
+
h('button', {
|
|
3597
|
+
className: 'pxs-btn pxs-btn-primary',
|
|
3598
|
+
disabled: importDialog.busy || !Array.isArray(importDialog.rows),
|
|
3599
|
+
onClick: confirmImport,
|
|
3600
|
+
}, importDialog.busy ? '导入中…' : '导入')))) : null;
|
|
3601
|
+
|
|
3602
|
+
const resizeDialogNode = resizeDialog ? h('div', {
|
|
3603
|
+
className: 'pxs-modal',
|
|
3604
|
+
onPointerDown: (event) => {
|
|
3605
|
+
if (event.target === event.currentTarget) setResizeDialog(null);
|
|
3606
|
+
},
|
|
3607
|
+
},
|
|
3608
|
+
h('div', { className: 'pxs-dialog pxs-resize-dialog', role: 'dialog', 'aria-label': '画布尺寸' },
|
|
3609
|
+
h('div', { className: 'pxs-dialog-head' },
|
|
3610
|
+
h('strong', null, '画布尺寸 / 裁剪'),
|
|
3611
|
+
h('button', {
|
|
3612
|
+
className: 'pxs-dialog-close',
|
|
3613
|
+
onClick: () => setResizeDialog(null),
|
|
3614
|
+
}, '×')),
|
|
3615
|
+
h('div', { className: 'pxs-dialog-row' },
|
|
3616
|
+
h('span', { className: 'pxs-dialog-label' }, '当前'),
|
|
3617
|
+
h('span', { className: 'pxs-hint' },
|
|
3618
|
+
doc ? `${doc.w}×${doc.h} · 目标 ${Math.round(Number(resizeDialog.w) || 0)}×${Math.round(Number(resizeDialog.h) || 0)}` : '')),
|
|
3619
|
+
h('div', { className: 'pxs-dialog-row' },
|
|
3620
|
+
h('span', { className: 'pxs-dialog-label' }, '宽'),
|
|
3621
|
+
h('input', {
|
|
3622
|
+
className: 'pxs-input',
|
|
3623
|
+
style: { width: 84 },
|
|
3624
|
+
type: 'number',
|
|
3625
|
+
min: 1,
|
|
3626
|
+
max: MAX_SIDE,
|
|
3627
|
+
value: resizeDialog.w,
|
|
3628
|
+
onChange: (event) => {
|
|
3629
|
+
const value = event.target.value;
|
|
3630
|
+
setResizeDialog((current) => (current ? { ...current, w: value } : current));
|
|
3631
|
+
},
|
|
3632
|
+
}),
|
|
3633
|
+
h('span', { className: 'pxs-dialog-label' }, '高'),
|
|
3634
|
+
h('input', {
|
|
3635
|
+
className: 'pxs-input',
|
|
3636
|
+
style: { width: 84 },
|
|
3637
|
+
type: 'number',
|
|
3638
|
+
min: 1,
|
|
3639
|
+
max: MAX_SIDE,
|
|
3640
|
+
value: resizeDialog.h,
|
|
3641
|
+
onChange: (event) => {
|
|
3642
|
+
const value = event.target.value;
|
|
3643
|
+
setResizeDialog((current) => (current ? { ...current, h: value } : current));
|
|
3644
|
+
},
|
|
3645
|
+
}),
|
|
3646
|
+
h('span', { className: 'pxs-hint' }, `最大 ${MAX_SIDE}`)),
|
|
3647
|
+
h('div', { className: 'pxs-dialog-row' },
|
|
3648
|
+
h('span', { className: 'pxs-dialog-label' }, '锚点'),
|
|
3649
|
+
h('div', { className: 'pxs-anchor-grid' },
|
|
3650
|
+
ANCHORS.map((anchor) => h('button', {
|
|
3651
|
+
key: anchor.id,
|
|
3652
|
+
className: 'pxs-anchor-chip',
|
|
3653
|
+
'data-active': resizeDialog.anchor === anchor.id ? 'true' : 'false',
|
|
3654
|
+
title: `以${anchor.label}为基准保留画面`,
|
|
3655
|
+
onClick: () => setResizeDialog((current) => (current ? { ...current, anchor: anchor.id } : current)),
|
|
3656
|
+
}, anchor.label))),
|
|
3657
|
+
h('span', { className: 'pxs-hint' }, '扩边补透明,裁掉的部分丢弃')),
|
|
3658
|
+
resizeDialog.error ? h('div', { className: 'pxs-dialog-error' }, resizeDialog.error) : null,
|
|
3659
|
+
h('div', { className: 'pxs-dialog-actions' },
|
|
3660
|
+
h('button', { className: 'pxs-btn', onClick: () => setResizeDialog(null) }, '取消'),
|
|
3661
|
+
h('button', {
|
|
3662
|
+
className: 'pxs-btn pxs-btn-primary',
|
|
3663
|
+
onClick: confirmResize,
|
|
3664
|
+
}, '应用')))) : null;
|
|
3665
|
+
|
|
2493
3666
|
return h('div', { className: 'pxs-root' },
|
|
2494
3667
|
h('style', null, CSS),
|
|
2495
3668
|
barNode,
|
|
@@ -2517,7 +3690,9 @@ window.__ModuleLoader__.load({
|
|
|
2517
3690
|
rightPanel),
|
|
2518
3691
|
menuNode,
|
|
2519
3692
|
dialogNode,
|
|
2520
|
-
newDialogNode
|
|
3693
|
+
newDialogNode,
|
|
3694
|
+
importDialogNode,
|
|
3695
|
+
resizeDialogNode);
|
|
2521
3696
|
}
|
|
2522
3697
|
|
|
2523
3698
|
/**
|
|
@@ -2575,6 +3750,23 @@ window.__ModuleLoader__.load({
|
|
|
2575
3750
|
}
|
|
2576
3751
|
}
|
|
2577
3752
|
|
|
3753
|
+
// Test hook: tools/select-test.cjs drives the pure selection helpers.
|
|
3754
|
+
if (window.__PXS_TEST__) {
|
|
3755
|
+
window.__PXS_TEST__.selection = {
|
|
3756
|
+
selectionBetween,
|
|
3757
|
+
clampRectangle,
|
|
3758
|
+
moveRectangle,
|
|
3759
|
+
pointInRectangle,
|
|
3760
|
+
readRegion,
|
|
3761
|
+
writeRegion,
|
|
3762
|
+
blankRegion,
|
|
3763
|
+
fillRows,
|
|
3764
|
+
emptyRows,
|
|
3765
|
+
mirrorRows,
|
|
3766
|
+
mergedRegion,
|
|
3767
|
+
};
|
|
3768
|
+
}
|
|
3769
|
+
|
|
2578
3770
|
return {
|
|
2579
3771
|
inject: ['slots'],
|
|
2580
3772
|
apply(ctx) {
|