@muxiuesd/dsh-pixel-studio 0.1.1 → 0.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/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # 更新记录
2
+
3
+ ## 0.2.0
4
+
5
+ - 新增「打开图片」:把 PNG / JPEG / GIF / WebP / BMP 直接转成像素画,自动缩到 256 以内,可选自动提取配色(中位切分)/ 当前调色板 / PICO-8,可开 Floyd–Steinberg 抖动,导入前先看预览。
6
+ - 「另存为」对话框新增导出倍率(1× / 2× / 3× / 4× / 6× / 8× / 12× / 16×,按单张 PNG 上限自动禁用超出的档位),选择会被记住;普通导出同样使用记住的倍率。
7
+ - 新增「尺寸」:调整画布宽高(1–256),9 宫格锚点决定裁剪或扩边的方向,扩边补透明,支持撤销。
8
+ - 撤销历史现在也记录画布尺寸,尺寸调整和绘制可以一起回退。
9
+
10
+ ## 0.1.1
11
+
12
+ - 首个发布版本:三栏编辑器、铅笔 / 橡皮 / 油漆桶 / 吸管、1–8 方形笔刷、整数倍缩放、图层、36 色调色板(改色不动画布)、11 个新建尺寸预设、导出 1× PNG 到会话目录、`Ctrl+S` / `Ctrl+Shift+S`。
package/README.md CHANGED
@@ -23,7 +23,9 @@ dsh plugin --profile web add /绝对路径/dsh-pixel
23
23
  - **图层**:最多 8 层,新建/复制/合并/上移/下移/删除/改名/显隐,全部可撤销。
24
24
  - **调色板**:最多 36 色,内置 PICO-8 等预设板;改某个槽位的颜色**不会改动已经画好的像素**(画布上的旧颜色会先由一个空闲槽位接手),也可以用系统取色器可视化取色。
25
25
  - **作品**:新建时填名字和尺寸(1–256),另有 11 个常用尺寸预设;自动保存。
26
- - **导出**:首次导出选择保存位置(默认从当前会话目录开始),之后导出直接覆盖那个文件;`另存为` 随时换位置。
26
+ - **打开图片**:选一张喜欢的 PNG / JPEG / GIF / WebP / BMP,自动缩到 256 以内并按需提取配色(也可沿用当前调色板或 PICO-8),可开抖动,生成一个新作品继续画。
27
+ - **画布尺寸 / 裁剪**:`尺寸` 里改宽高(1–256),9 宫格锚点决定裁掉哪边或从哪边扩边,改完可以撤销。
28
+ - **导出**:首次导出选择保存位置(默认从当前会话目录开始),之后导出直接覆盖那个文件;`另存为` 随时换位置,并可挑 1×–16× 的导出倍率(会记住上次的选择)。
27
29
 
28
30
  ## 快捷键
29
31
 
package/client.js CHANGED
@@ -65,6 +65,18 @@ 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' },
@@ -346,6 +358,323 @@ window.__ModuleLoader__.load({
346
358
  return /\.png$/i.test(trimmed) ? trimmed : `${trimmed}.png`;
347
359
  }
348
360
 
361
+ /* ------------------------------------------------------------------ */
362
+ /* canvas resize */
363
+ /* ------------------------------------------------------------------ */
364
+
365
+ /** Undo snapshot; width/height ride along so a canvas resize can be undone. */
366
+ function snapshotOf(doc) {
367
+ return {
368
+ palette: doc.palette.slice(),
369
+ layers: doc.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
370
+ w: doc.w,
371
+ h: doc.h,
372
+ };
373
+ }
374
+
375
+ /** Offset that keeps existing pixels at the chosen anchor when the canvas changes size. */
376
+ function anchorOffsets(anchor, oldWidth, oldHeight, newWidth, newHeight) {
377
+ const id = typeof anchor === 'string' && anchor.length > 0 ? anchor : 'c';
378
+ const dx = id.includes('w') ? 0
379
+ : id.includes('e') ? newWidth - oldWidth
380
+ : Math.round((newWidth - oldWidth) / 2);
381
+ const dy = id.includes('n') ? 0
382
+ : id.includes('s') ? newHeight - oldHeight
383
+ : Math.round((newHeight - oldHeight) / 2);
384
+ return [dx, dy];
385
+ }
386
+
387
+ /** Re-frames one layer onto a new canvas: grown areas become transparent, cropped pixels go away. */
388
+ function resizeRows(rows, oldWidth, oldHeight, newWidth, newHeight, anchor) {
389
+ const [dx, dy] = anchorOffsets(anchor, oldWidth, oldHeight, newWidth, newHeight);
390
+ const next = [];
391
+ for (let y = 0; y < newHeight; y += 1) {
392
+ const sourceY = y - dy;
393
+ const row = sourceY >= 0 && sourceY < oldHeight ? String(rows[sourceY] === undefined ? '' : rows[sourceY]) : '';
394
+ let line = '';
395
+ for (let x = 0; x < newWidth; x += 1) {
396
+ const sourceX = x - dx;
397
+ const ch = sourceX >= 0 && sourceX < oldWidth ? row[sourceX] : undefined;
398
+ line += ch === undefined || ch === '' ? '.' : ch;
399
+ }
400
+ next.push(line);
401
+ }
402
+ return next;
403
+ }
404
+
405
+ /* ------------------------------------------------------------------ */
406
+ /* image import */
407
+ /* ------------------------------------------------------------------ */
408
+
409
+ /** Largest integer magnification whose PNG stays under the host's pixel cap. */
410
+ function maxScaleFor(width, height) {
411
+ return Math.max(1, Math.floor(Math.sqrt(MAX_EXPORT_PIXELS / Math.max(1, width * height))));
412
+ }
413
+
414
+ /** Remembered export magnification, clamped to what the host can encode for this canvas. */
415
+ function clampExportScale(value, width, height) {
416
+ const wanted = Math.round(Number(value) || 1);
417
+ if (!Number.isFinite(wanted) || wanted < 1) return 1;
418
+ const ceiling = Math.min(64, maxScaleFor(width, height));
419
+ return wanted > ceiling ? ceiling : wanted;
420
+ }
421
+
422
+ function clampByte(value) {
423
+ const number = Math.round(Number(value) || 0);
424
+ if (number < 0) return 0;
425
+ return number > 255 ? 255 : number;
426
+ }
427
+
428
+ function hex2(value) {
429
+ return clampByte(value).toString(16).padStart(2, '0');
430
+ }
431
+
432
+ function rgbOf(color) {
433
+ return [Number(color[0]) || 0, Number(color[1]) || 0, Number(color[2]) || 0];
434
+ }
435
+
436
+ function colorDistanceSq(a, b) {
437
+ const dr = a[0] - b[0];
438
+ const dg = a[1] - b[1];
439
+ const db = a[2] - b[2];
440
+ return dr * dr + dg * dg + db * db;
441
+ }
442
+
443
+ function nearestColorIndex(paletteRgb, color) {
444
+ let best = 0;
445
+ let bestDistance = Infinity;
446
+ for (let i = 0; i < paletteRgb.length; i += 1) {
447
+ const distance = colorDistanceSq(paletteRgb[i], color);
448
+ if (distance < bestDistance) {
449
+ bestDistance = distance;
450
+ best = i;
451
+ }
452
+ }
453
+ return best;
454
+ }
455
+
456
+ /** RGB extent of a colour box: [ranges, channel with the widest range]. */
457
+ function boxExtent(box) {
458
+ let minR = 255; let minG = 255; let minB = 255;
459
+ let maxR = 0; let maxG = 0; let maxB = 0;
460
+ for (const color of box) {
461
+ if (color[0] < minR) minR = color[0];
462
+ if (color[1] < minG) minG = color[1];
463
+ if (color[2] < minB) minB = color[2];
464
+ if (color[0] > maxR) maxR = color[0];
465
+ if (color[1] > maxG) maxG = color[1];
466
+ if (color[2] > maxB) maxB = color[2];
467
+ }
468
+ const ranges = [maxR - minR, maxG - minG, maxB - minB];
469
+ let channel = 0;
470
+ if (ranges[1] > ranges[channel]) channel = 1;
471
+ if (ranges[2] > ranges[channel]) channel = 2;
472
+ return { ranges, channel, widest: ranges[channel] };
473
+ }
474
+
475
+ function averageColor(box) {
476
+ let r = 0; let g = 0; let b = 0;
477
+ for (const color of box) {
478
+ r += color[0]; g += color[1]; b += color[2];
479
+ }
480
+ const count = box.length === 0 ? 1 : box.length;
481
+ return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
482
+ }
483
+
484
+ /** Median cut: fold thousands of photo colours into at most `limit` representative ones. */
485
+ function quantizeColors(colors, limit) {
486
+ if (colors.length <= limit) return colors.slice();
487
+ const boxes = [colors.slice()];
488
+ while (boxes.length < limit) {
489
+ let index = -1;
490
+ let score = -1;
491
+ for (let i = 0; i < boxes.length; i += 1) {
492
+ const box = boxes[i];
493
+ if (box.length < 2) continue;
494
+ const extent = boxExtent(box);
495
+ const weight = extent.widest * Math.min(box.length, 256);
496
+ if (weight > score) {
497
+ score = weight;
498
+ index = i;
499
+ }
500
+ }
501
+ if (index === -1) break;
502
+ const box = boxes[index];
503
+ const channel = boxExtent(box).channel;
504
+ const sorted = box.slice().sort((a, b) => a[channel] - b[channel]);
505
+ const middle = Math.max(1, Math.floor(sorted.length / 2));
506
+ boxes.splice(index, 1, sorted.slice(0, middle), sorted.slice(middle));
507
+ }
508
+ return boxes.map(averageColor);
509
+ }
510
+
511
+ /** Most common opaque colours of a sampled image, as hex strings plus RGB triples. */
512
+ function paletteFromSample(sample, limit) {
513
+ const counts = new Map();
514
+ const total = sample.width * sample.height;
515
+ for (let i = 0; i < total; i += 1) {
516
+ if (sample.data[i * 4 + 3] < 128) continue;
517
+ const key = (sample.data[i * 4] << 16) | (sample.data[i * 4 + 1] << 8) | sample.data[i * 4 + 2];
518
+ counts.set(key, (counts.get(key) || 0) + 1);
519
+ }
520
+ const ordered = Array.from(counts.entries())
521
+ .sort((a, b) => b[1] - a[1])
522
+ .map(([key]) => [Number((key >> 16) & 255), Number((key >> 8) & 255), Number(key & 255)]);
523
+ const limited = ordered.length > limit ? quantizeColors(ordered, limit) : ordered;
524
+ const rgb = limited.map((color) => [color[0], color[1], color[2]]);
525
+ return {
526
+ palette: rgb.map(([r, g, b]) => `#${hex2(r)}${hex2(g)}${hex2(b)}`),
527
+ rgb,
528
+ original: ordered.length,
529
+ };
530
+ }
531
+
532
+ /** Maps sampled pixels onto the palette; optional Floyd–Steinberg dithering spreads the error. */
533
+ function buildImportRows(sample, paletteRgb, dither) {
534
+ const { width, height, data } = sample;
535
+ const work = new Float32Array(width * height * 3);
536
+ for (let i = 0; i < width * height; i += 1) {
537
+ work[i * 3] = data[i * 4];
538
+ work[i * 3 + 1] = data[i * 4 + 1];
539
+ work[i * 3 + 2] = data[i * 4 + 2];
540
+ }
541
+ const rows = [];
542
+ const spread = (x, y, error, dx, dy, weight) => {
543
+ const nx = x + dx;
544
+ const ny = y + dy;
545
+ if (nx < 0 || nx >= width || ny >= height) return;
546
+ const at = ny * width + nx;
547
+ if (data[at * 4 + 3] < 128) return;
548
+ work[at * 3] += error[0] * weight;
549
+ work[at * 3 + 1] += error[1] * weight;
550
+ work[at * 3 + 2] += error[2] * weight;
551
+ };
552
+ for (let y = 0; y < height; y += 1) {
553
+ let line = '';
554
+ for (let x = 0; x < width; x += 1) {
555
+ const at = y * width + x;
556
+ if (data[at * 4 + 3] < 128) {
557
+ line += '.';
558
+ continue;
559
+ }
560
+ const color = [work[at * 3], work[at * 3 + 1], work[at * 3 + 2]];
561
+ const index = nearestColorIndex(paletteRgb, color);
562
+ line += charFor(index);
563
+ if (dither) {
564
+ const chosen = paletteRgb[index];
565
+ const error = [color[0] - chosen[0], color[1] - chosen[1], color[2] - chosen[2]];
566
+ spread(x, y, error, 1, 0, 7 / 16);
567
+ spread(x, y, error, -1, 1, 3 / 16);
568
+ spread(x, y, error, 0, 1, 5 / 16);
569
+ spread(x, y, error, 1, 1, 1 / 16);
570
+ }
571
+ }
572
+ rows.push(line);
573
+ }
574
+ return rows;
575
+ }
576
+
577
+ /** PNG data URL of a char grid, shown as the import preview. */
578
+ function previewDataUrl(palette, rows) {
579
+ const height = rows.length;
580
+ const width = height > 0 ? rows[0].length : 0;
581
+ if (width === 0 || height === 0) return '';
582
+ const canvas = document.createElement('canvas');
583
+ canvas.width = width;
584
+ canvas.height = height;
585
+ const context = canvas.getContext('2d');
586
+ if (!context) return '';
587
+ const image = context.createImageData(width, height);
588
+ const rgb = palette.map(hexToRgb);
589
+ for (let y = 0; y < height; y += 1) {
590
+ const row = rows[y];
591
+ for (let x = 0; x < width; x += 1) {
592
+ const index = indexFor(row === undefined ? '.' : row[x]);
593
+ const offset = (y * width + x) * 4;
594
+ if (index < 0 || index >= rgb.length) {
595
+ image.data[offset + 3] = 0;
596
+ continue;
597
+ }
598
+ image.data[offset] = rgb[index][0];
599
+ image.data[offset + 1] = rgb[index][1];
600
+ image.data[offset + 2] = rgb[index][2];
601
+ image.data[offset + 3] = 255;
602
+ }
603
+ }
604
+ context.putImageData(image, 0, 0);
605
+ return canvas.toDataURL('image/png');
606
+ }
607
+
608
+ /** Decoded pixels of a picked file, downscaled into the editor's size cap. */
609
+ async function readImagePixels(file) {
610
+ const url = window.URL.createObjectURL(file);
611
+ try {
612
+ const image = await new Promise((resolve, reject) => {
613
+ const element = new window.Image();
614
+ element.onload = () => resolve(element);
615
+ element.onerror = () => reject(new Error('无法解码这张图片(试 PNG / JPG)'));
616
+ element.src = url;
617
+ });
618
+ const sourceW = Math.max(1, Math.round(image.naturalWidth || image.width || 1));
619
+ const sourceH = Math.max(1, Math.round(image.naturalHeight || image.height || 1));
620
+ const ratio = Math.min(1, MAX_SIDE / Math.max(sourceW, sourceH));
621
+ const width = Math.max(1, Math.round(sourceW * ratio));
622
+ const height = Math.max(1, Math.round(sourceH * ratio));
623
+ const canvas = document.createElement('canvas');
624
+ canvas.width = width;
625
+ canvas.height = height;
626
+ const context = canvas.getContext('2d');
627
+ if (!context) throw new Error('浏览器不支持读取图片');
628
+ context.imageSmoothingEnabled = ratio < 1;
629
+ context.drawImage(image, 0, 0, width, height);
630
+ const data = context.getImageData(0, 0, width, height).data;
631
+ return { width, height, sourceW, sourceH, data };
632
+ } finally {
633
+ window.URL.revokeObjectURL(url);
634
+ }
635
+ }
636
+
637
+ function importName(file) {
638
+ const raw = file && typeof file.name === 'string' ? file.name : '';
639
+ const stem = raw.replace(/\.[^.]+$/, '').trim();
640
+ return (stem || '导入的图片').slice(0, 60);
641
+ }
642
+
643
+ /** Rebuilds palette + rows + preview for the current import options. */
644
+ function buildImportDocument(dialog, currentPalette) {
645
+ const sample = dialog.sample;
646
+ let palette;
647
+ let rgb;
648
+ let note = '';
649
+ if (dialog.mode === 'current' && Array.isArray(currentPalette) && currentPalette.length > 0) {
650
+ palette = currentPalette.slice(0, IMPORT_MAX_COLORS);
651
+ rgb = palette.map(hexToRgb).map(rgbOf);
652
+ } else if (dialog.mode === 'pico8') {
653
+ palette = PRESETS[0].colors.slice(0, IMPORT_MAX_COLORS);
654
+ rgb = palette.map(hexToRgb).map(rgbOf);
655
+ } else {
656
+ const auto = paletteFromSample(sample, IMPORT_MAX_COLORS);
657
+ palette = auto.palette;
658
+ rgb = auto.rgb;
659
+ note = auto.original > palette.length
660
+ ? `${auto.original} 种颜色压到 ${palette.length} 种`
661
+ : `${auto.original} 种颜色`;
662
+ }
663
+ if (palette.length === 0) {
664
+ palette = ['#000000'];
665
+ rgb = [[0, 0, 0]];
666
+ }
667
+ const rows = buildImportRows(sample, rgb, dialog.dither === true);
668
+ return {
669
+ ...dialog,
670
+ palette,
671
+ rows,
672
+ note,
673
+ preview: previewDataUrl(palette, rows),
674
+ error: '',
675
+ };
676
+ }
677
+
349
678
  /* ------------------------------------------------------------------ */
350
679
  /* icons */
351
680
  /* ------------------------------------------------------------------ */
@@ -612,6 +941,42 @@ window.__ModuleLoader__.load({
612
941
  }
613
942
  .pxs-size-label { font-size: 12px; font-weight: 600; }
614
943
  .pxs-size-hint { font-size: 10px; color: var(--dsw-alias-label-secondary, #9aa3b2); }
944
+ .pxs-import-dialog, .pxs-resize-dialog { width: min(460px, calc(100vw - 40px)); }
945
+ .pxs-scale-chip {
946
+ flex-direction: row; align-items: center; gap: 0; padding: 3px 8px; border-radius: 999px;
947
+ font-size: 11px; color: var(--dsw-alias-label-primary, #e8ebf2);
948
+ background: var(--dsw-alias-bg-layer-1, #1c1f26);
949
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .1));
950
+ }
951
+ .pxs-scale-chip:hover { border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, .2)); }
952
+ .pxs-scale-chip[data-active="true"] {
953
+ border-color: var(--dsw-alias-brand-primary, #4b7bec);
954
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b7bec) 18%, transparent);
955
+ }
956
+ .pxs-scale-chip:disabled { opacity: .45; cursor: not-allowed; }
957
+ .pxs-import-preview {
958
+ display: flex; justify-content: center; padding: 8px; border-radius: 8px;
959
+ background: var(--dsw-alias-bg-layer-1, #1c1f26);
960
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .08));
961
+ max-height: 190px; overflow: auto;
962
+ }
963
+ .pxs-import-preview img {
964
+ image-rendering: pixelated; max-width: 100%; max-height: 172px;
965
+ }
966
+ .pxs-anchor-grid {
967
+ display: grid; grid-template-columns: repeat(3, 30px); gap: 3px;
968
+ }
969
+ .pxs-anchor-chip {
970
+ width: 30px; height: 26px; border-radius: 6px; cursor: pointer; font: inherit;
971
+ line-height: 1; color: var(--dsw-alias-label-primary, #e8ebf2);
972
+ background: var(--dsw-alias-bg-layer-1, #1c1f26);
973
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .1));
974
+ }
975
+ .pxs-anchor-chip:hover { border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, .24)); }
976
+ .pxs-anchor-chip[data-active="true"] {
977
+ border-color: var(--dsw-alias-brand-primary, #4b7bec);
978
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b7bec) 18%, transparent);
979
+ }
615
980
  .pxs-menu {
616
981
  position: fixed; z-index: 40; min-width: 132px; padding: 4px; border-radius: 8px;
617
982
  background: var(--dsw-alias-bg-overlay, #262a33); border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.2));
@@ -665,6 +1030,8 @@ window.__ModuleLoader__.load({
665
1030
  const [renameValue, setRenameValue] = useState('');
666
1031
  const [saveDialog, setSaveDialog] = useState(null);
667
1032
  const [editing, setEditing] = useState(null);
1033
+ const [importDialog, setImportDialog] = useState(null);
1034
+ const [resizeDialog, setResizeDialog] = useState(null);
668
1035
 
669
1036
  const stageRef = useRef(null);
670
1037
  const canvasRef = useRef(null);
@@ -695,6 +1062,7 @@ window.__ModuleLoader__.load({
695
1062
  // Indirection for the save-as action: the keyboard effect is registered
696
1063
  // before the callback is created, so it must not close over the binding.
697
1064
  const saveAsRef = useRef(null);
1065
+ const fileRef = useRef(null);
698
1066
 
699
1067
  if (gridRef.current !== showGrid) gridRef.current = showGrid;
700
1068
  sizeRef.current = { left: leftWidth, right: rightWidth };
@@ -947,10 +1315,7 @@ window.__ModuleLoader__.load({
947
1315
  if (!current) return;
948
1316
  const history = historyRef.current;
949
1317
  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
- });
1318
+ history.undo.push(snapshotOf(current));
954
1319
  if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
955
1320
  history.redo = [];
956
1321
  }
@@ -972,6 +1337,10 @@ window.__ModuleLoader__.load({
972
1337
  palette: snapshot.palette.slice(),
973
1338
  layers: snapshot.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
974
1339
  };
1340
+ if (typeof snapshot.w === 'number' && typeof snapshot.h === 'number') {
1341
+ next.w = snapshot.w;
1342
+ next.h = snapshot.h;
1343
+ }
975
1344
  docRef.current = next;
976
1345
  setDoc(next);
977
1346
  if (!next.layers[layerRef.current]) {
@@ -988,10 +1357,7 @@ window.__ModuleLoader__.load({
988
1357
  const snapshot = history.undo.pop();
989
1358
  if (!snapshot) return;
990
1359
  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
- });
1360
+ history.redo.push(snapshotOf(current));
995
1361
  applyHistory(snapshot);
996
1362
  }, [applyHistory]);
997
1363
 
@@ -1000,10 +1366,7 @@ window.__ModuleLoader__.load({
1000
1366
  const snapshot = history.redo.pop();
1001
1367
  if (!snapshot) return;
1002
1368
  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
- });
1369
+ history.undo.push(snapshotOf(current));
1007
1370
  applyHistory(snapshot);
1008
1371
  }, [applyHistory]);
1009
1372
 
@@ -1107,10 +1470,7 @@ window.__ModuleLoader__.load({
1107
1470
 
1108
1471
  // Real stroke: snapshot once, then paint continuously.
1109
1472
  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
- });
1473
+ history.undo.push(snapshotOf(current));
1114
1474
  if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
1115
1475
  history.redo = [];
1116
1476
  strokeRef.current = { pointerId: event.pointerId, last: cell };
@@ -1410,6 +1770,7 @@ window.__ModuleLoader__.load({
1410
1770
  root: '',
1411
1771
  dirs: [],
1412
1772
  name,
1773
+ scale: clampExportScale(readStoredNumber('pxs:exportScale', 1), current.w, current.h),
1413
1774
  loading: true,
1414
1775
  busy: false,
1415
1776
  error: '',
@@ -1423,15 +1784,17 @@ window.__ModuleLoader__.load({
1423
1784
  if (!current || !dialog || dialog.busy) return;
1424
1785
  if (typeof dialog.path !== 'string' || dialog.path === '') return;
1425
1786
  const target = joinPath(dialog.path, ensurePngName(dialog.name));
1787
+ const scale = clampExportScale(dialog.scale, current.w, current.h);
1426
1788
  setSaveDialog((value) => (value ? { ...value, busy: true, error: '' } : value));
1427
1789
  try {
1428
- const saved = await callApi({ op: 'exportTo', doc: current, path: target });
1790
+ const saved = await callApi({ op: 'exportTo', doc: current, path: target, scale });
1429
1791
  const next = { ...current, filePath: typeof saved.path === 'string' ? saved.path : target };
1430
1792
  docRef.current = next;
1431
1793
  setDoc(next);
1432
1794
  setSaveDialog(null);
1433
1795
  setError('');
1434
- setNotice(`已保存 ${next.filePath}`);
1796
+ storeValue('pxs:exportScale', String(scale));
1797
+ setNotice(`已保存 ${next.filePath}${scale > 1 ? `(${scale}×)` : ''}`);
1435
1798
  const stored = await callApi({ op: 'save', doc: next });
1436
1799
  if (stored && stored.doc) {
1437
1800
  docRef.current = stored.doc;
@@ -1455,8 +1818,10 @@ window.__ModuleLoader__.load({
1455
1818
  return;
1456
1819
  }
1457
1820
  try {
1458
- const saved = await callApi({ op: 'exportTo', doc: current, path: filePath });
1459
- setNotice(saved.overwritten ? `已覆盖 ${saved.path}` : `已保存 ${saved.path}`);
1821
+ const scale = clampExportScale(readStoredNumber('pxs:exportScale', 1), current.w, current.h);
1822
+ const saved = await callApi({ op: 'exportTo', doc: current, path: filePath, scale });
1823
+ const suffix = scale > 1 ? `(${scale}×)` : '';
1824
+ setNotice(`${saved.overwritten ? `已覆盖 ${saved.path}` : `已保存 ${saved.path}`}${suffix}`);
1460
1825
  setError('');
1461
1826
  } catch (cause) {
1462
1827
  const message = cause instanceof Error ? cause.message : String(cause);
@@ -1471,6 +1836,128 @@ window.__ModuleLoader__.load({
1471
1836
  }, [openSaveDialog]);
1472
1837
  saveAsRef.current = saveAsPng;
1473
1838
 
1839
+ /* -------------------------------------------------------------- */
1840
+ /* import a picture */
1841
+ /* -------------------------------------------------------------- */
1842
+
1843
+ const openImportDialog = useCallback((file) => {
1844
+ if (!file) return;
1845
+ void (async () => {
1846
+ try {
1847
+ const sample = await readImagePixels(file);
1848
+ const current = docRef.current;
1849
+ setError('');
1850
+ setImportDialog(buildImportDocument({
1851
+ sample,
1852
+ name: importName(file),
1853
+ mode: 'auto',
1854
+ dither: false,
1855
+ sourceW: sample.sourceW,
1856
+ sourceH: sample.sourceH,
1857
+ width: sample.width,
1858
+ height: sample.height,
1859
+ busy: false,
1860
+ }, current ? current.palette : null));
1861
+ } catch (cause) {
1862
+ setError(cause instanceof Error ? cause.message : String(cause));
1863
+ }
1864
+ })();
1865
+ }, []);
1866
+
1867
+ const retuneImport = useCallback((patch) => {
1868
+ const current = docRef.current;
1869
+ setImportDialog((value) => (value
1870
+ ? buildImportDocument({ ...value, ...patch }, current ? current.palette : null)
1871
+ : value));
1872
+ }, []);
1873
+
1874
+ const confirmImport = useCallback(() => {
1875
+ const dialog = importDialog;
1876
+ if (!dialog || dialog.busy || !Array.isArray(dialog.rows)) return;
1877
+ setImportDialog((value) => (value ? { ...value, busy: true, error: '' } : value));
1878
+ void (async () => {
1879
+ try {
1880
+ const width = dialog.width;
1881
+ const height = dialog.height;
1882
+ const name = String(dialog.name || '').trim().slice(0, 60) || `${width}×${height} 导入`;
1883
+ const created = await callApi({ op: 'create', name, w: width, h: height, palette: dialog.palette });
1884
+ // create ignores layers, so the pixel grid goes in through save.
1885
+ const saved = await callApi({
1886
+ op: 'save',
1887
+ doc: {
1888
+ ...created.doc,
1889
+ layers: [{ name: '图层 1', visible: true, rows: dialog.rows }],
1890
+ },
1891
+ });
1892
+ const next = saved && saved.doc ? saved.doc : created.doc;
1893
+ docRef.current = next;
1894
+ setDoc(next);
1895
+ layerRef.current = 0;
1896
+ setLayerIndex(0);
1897
+ setColorIndex(0);
1898
+ editSessionRef.current = null;
1899
+ setEditing(null);
1900
+ historyRef.current = { undo: [], redo: [] };
1901
+ setImportDialog(null);
1902
+ setStatus('clean');
1903
+ setNotice(`已导入「${name}」${width}×${height}`);
1904
+ await refreshDocs();
1905
+ window.requestAnimationFrame(() => fitToView());
1906
+ } catch (cause) {
1907
+ const message = cause instanceof Error ? cause.message : String(cause);
1908
+ setImportDialog((value) => (value ? { ...value, busy: false, error: message } : value));
1909
+ }
1910
+ })();
1911
+ }, [fitToView, importDialog, refreshDocs]);
1912
+
1913
+ /* -------------------------------------------------------------- */
1914
+ /* canvas size / crop */
1915
+ /* -------------------------------------------------------------- */
1916
+
1917
+ const openResizeDialog = useCallback(() => {
1918
+ const current = docRef.current;
1919
+ if (!current) return;
1920
+ setResizeDialog({ w: current.w, h: current.h, anchor: 'c', error: '' });
1921
+ }, []);
1922
+
1923
+ const confirmResize = useCallback(() => {
1924
+ const current = docRef.current;
1925
+ const dialog = resizeDialog;
1926
+ if (!current || !dialog) return;
1927
+ const width = Math.round(Number(dialog.w));
1928
+ const height = Math.round(Number(dialog.h));
1929
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) {
1930
+ setResizeDialog({ ...dialog, error: '宽高要填 1 以上的整数' });
1931
+ return;
1932
+ }
1933
+ if (width > MAX_SIDE || height > MAX_SIDE) {
1934
+ setResizeDialog({ ...dialog, error: `最大 ${MAX_SIDE}×${MAX_SIDE}` });
1935
+ return;
1936
+ }
1937
+ setResizeDialog(null);
1938
+ if (width === current.w && height === current.h) {
1939
+ setNotice('画布尺寸没有变化');
1940
+ return;
1941
+ }
1942
+ const history = historyRef.current;
1943
+ history.undo.push(snapshotOf(current));
1944
+ if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
1945
+ history.redo = [];
1946
+ const layers = current.layers.map((layer) => ({
1947
+ ...layer,
1948
+ rows: resizeRows(layer.rows, current.w, current.h, width, height, dialog.anchor),
1949
+ }));
1950
+ const next = { ...current, w: width, h: height, layers };
1951
+ docRef.current = next;
1952
+ setDoc(next);
1953
+ scheduleSave();
1954
+ setNotice(`画布已调整为 ${width}×${height}`);
1955
+ window.requestAnimationFrame(() => {
1956
+ fitToView();
1957
+ requestPaint();
1958
+ });
1959
+ }, [fitToView, requestPaint, resizeDialog, scheduleSave]);
1960
+
1474
1961
  /* -------------------------------------------------------------- */
1475
1962
  /* palette actions */
1476
1963
  /* -------------------------------------------------------------- */
@@ -1479,10 +1966,7 @@ window.__ModuleLoader__.load({
1479
1966
  const current = docRef.current;
1480
1967
  if (!current) return;
1481
1968
  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
- });
1969
+ history.undo.push(snapshotOf(current));
1486
1970
  if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
1487
1971
  history.redo = [];
1488
1972
  }, []);
@@ -1691,10 +2175,7 @@ window.__ModuleLoader__.load({
1691
2175
  const current = docRef.current;
1692
2176
  if (!current) return null;
1693
2177
  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
- });
2178
+ history.undo.push(snapshotOf(current));
1698
2179
  if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
1699
2180
  history.redo = [];
1700
2181
  return current;
@@ -1949,6 +2430,31 @@ window.__ModuleLoader__.load({
1949
2430
  onClick: openNewDialog,
1950
2431
  title: '新建作品(可选名字与尺寸)',
1951
2432
  }, '新建'),
2433
+ h('button', {
2434
+ className: 'pxs-btn',
2435
+ onClick: () => {
2436
+ if (fileRef.current) fileRef.current.click();
2437
+ },
2438
+ title: '打开图片,转成像素画导入新作品',
2439
+ }, '打开图片'),
2440
+ h('input', {
2441
+ ref: fileRef,
2442
+ type: 'file',
2443
+ accept: 'image/png,image/jpeg,image/gif,image/webp,image/bmp',
2444
+ style: { display: 'none' },
2445
+ onChange: (event) => {
2446
+ const input = event.target;
2447
+ const picked = input.files && input.files[0];
2448
+ input.value = '';
2449
+ if (picked) openImportDialog(picked);
2450
+ },
2451
+ }),
2452
+ h('button', {
2453
+ className: 'pxs-btn',
2454
+ disabled: !doc,
2455
+ onClick: openResizeDialog,
2456
+ title: '调整画布尺寸 / 裁剪(可撤销)',
2457
+ }, '尺寸'),
1952
2458
  h('span', { className: 'pxs-spacer' }),
1953
2459
  h('button', {
1954
2460
  className: 'pxs-btn pxs-btn-icon',
@@ -2396,6 +2902,25 @@ window.__ModuleLoader__.load({
2396
2902
  }
2397
2903
  },
2398
2904
  })),
2905
+ h('div', { className: 'pxs-dialog-row', style: { flexWrap: 'wrap' } },
2906
+ h('span', { className: 'pxs-dialog-label' }, '倍率'),
2907
+ EXPORT_SCALES.map((value) => {
2908
+ const ceiling = maxScaleFor(doc ? doc.w : 1, doc ? doc.h : 1);
2909
+ const allowed = value <= ceiling;
2910
+ return h('button', {
2911
+ key: value,
2912
+ className: 'pxs-scale-chip',
2913
+ 'data-active': Number(saveDialog.scale) === value ? 'true' : 'false',
2914
+ disabled: !allowed,
2915
+ title: allowed
2916
+ ? `${value}× → ${(doc ? doc.w : 0) * value}×${(doc ? doc.h : 0) * value}`
2917
+ : '超出单张 PNG 上限',
2918
+ onClick: () => setSaveDialog((current) => (current ? { ...current, scale: value } : current)),
2919
+ }, `${value}×`);
2920
+ }),
2921
+ h('span', { className: 'pxs-hint' }, doc
2922
+ ? `${saveDialog.scale}× = ${doc.w * saveDialog.scale}×${doc.h * saveDialog.scale}`
2923
+ : '')),
2399
2924
  saveDialog.error ? h('div', { className: 'pxs-dialog-error' }, saveDialog.error) : null,
2400
2925
  h('div', { className: 'pxs-dialog-actions' },
2401
2926
  h('button', { className: 'pxs-btn', onClick: () => setSaveDialog(null) }, '取消'),
@@ -2490,6 +3015,139 @@ window.__ModuleLoader__.load({
2490
3015
  onClick: confirmNewDoc,
2491
3016
  }, creating ? '创建中…' : '创建')))) : null;
2492
3017
 
3018
+ const importDialogNode = importDialog ? h('div', {
3019
+ className: 'pxs-modal',
3020
+ onPointerDown: (event) => {
3021
+ if (event.target === event.currentTarget && !importDialog.busy) setImportDialog(null);
3022
+ },
3023
+ },
3024
+ h('div', { className: 'pxs-dialog pxs-import-dialog', role: 'dialog', 'aria-label': '打开图片' },
3025
+ h('div', { className: 'pxs-dialog-head' },
3026
+ h('strong', null, '打开图片'),
3027
+ h('button', {
3028
+ className: 'pxs-dialog-close',
3029
+ disabled: importDialog.busy,
3030
+ onClick: () => setImportDialog(null),
3031
+ }, '×')),
3032
+ h('div', { className: 'pxs-dialog-row' },
3033
+ h('span', { className: 'pxs-dialog-label' }, '名字'),
3034
+ h('input', {
3035
+ className: 'pxs-input',
3036
+ style: { flex: 1 },
3037
+ maxLength: 60,
3038
+ value: importDialog.name,
3039
+ onChange: (event) => {
3040
+ const value = event.target.value;
3041
+ setImportDialog((current) => (current ? { ...current, name: value } : current));
3042
+ },
3043
+ })),
3044
+ h('div', { className: 'pxs-dialog-row' },
3045
+ h('span', { className: 'pxs-dialog-label' }, '原图'),
3046
+ h('span', { className: 'pxs-hint' },
3047
+ `${importDialog.sourceW}×${importDialog.sourceH} → ${importDialog.width}×${importDialog.height}`
3048
+ + ` · ${importDialog.palette.length} 色`
3049
+ + (importDialog.note ? ` · ${importDialog.note}` : ''))),
3050
+ h('div', { className: 'pxs-dialog-row', style: { flexWrap: 'wrap' } },
3051
+ h('span', { className: 'pxs-dialog-label' }, '配色'),
3052
+ [
3053
+ { id: 'auto', label: '自动提取' },
3054
+ { id: 'current', label: '当前调色板', disabled: !(doc && doc.palette.length > 0) },
3055
+ { id: 'pico8', label: 'PICO-8' },
3056
+ ].map((mode) => h('button', {
3057
+ key: mode.id,
3058
+ className: 'pxs-scale-chip',
3059
+ 'data-active': importDialog.mode === mode.id ? 'true' : 'false',
3060
+ disabled: mode.disabled === true || importDialog.busy,
3061
+ onClick: () => retuneImport({ mode: mode.id }),
3062
+ }, mode.label)),
3063
+ h('button', {
3064
+ className: 'pxs-scale-chip',
3065
+ 'data-active': importDialog.dither ? 'true' : 'false',
3066
+ disabled: importDialog.busy,
3067
+ title: '误差扩散抖动,渐变更自然但像素更碎',
3068
+ onClick: () => retuneImport({ dither: !importDialog.dither }),
3069
+ }, '抖动')),
3070
+ importDialog.preview
3071
+ ? h('div', { className: 'pxs-import-preview' },
3072
+ h('img', { src: importDialog.preview, alt: '导入预览' }))
3073
+ : null,
3074
+ importDialog.error ? h('div', { className: 'pxs-dialog-error' }, importDialog.error) : null,
3075
+ h('div', { className: 'pxs-dialog-actions' },
3076
+ h('button', {
3077
+ className: 'pxs-btn',
3078
+ disabled: importDialog.busy,
3079
+ onClick: () => setImportDialog(null),
3080
+ }, '取消'),
3081
+ h('button', {
3082
+ className: 'pxs-btn pxs-btn-primary',
3083
+ disabled: importDialog.busy || !Array.isArray(importDialog.rows),
3084
+ onClick: confirmImport,
3085
+ }, importDialog.busy ? '导入中…' : '导入')))) : null;
3086
+
3087
+ const resizeDialogNode = resizeDialog ? h('div', {
3088
+ className: 'pxs-modal',
3089
+ onPointerDown: (event) => {
3090
+ if (event.target === event.currentTarget) setResizeDialog(null);
3091
+ },
3092
+ },
3093
+ h('div', { className: 'pxs-dialog pxs-resize-dialog', role: 'dialog', 'aria-label': '画布尺寸' },
3094
+ h('div', { className: 'pxs-dialog-head' },
3095
+ h('strong', null, '画布尺寸 / 裁剪'),
3096
+ h('button', {
3097
+ className: 'pxs-dialog-close',
3098
+ onClick: () => setResizeDialog(null),
3099
+ }, '×')),
3100
+ h('div', { className: 'pxs-dialog-row' },
3101
+ h('span', { className: 'pxs-dialog-label' }, '当前'),
3102
+ h('span', { className: 'pxs-hint' },
3103
+ doc ? `${doc.w}×${doc.h} · 目标 ${Math.round(Number(resizeDialog.w) || 0)}×${Math.round(Number(resizeDialog.h) || 0)}` : '')),
3104
+ h('div', { className: 'pxs-dialog-row' },
3105
+ h('span', { className: 'pxs-dialog-label' }, '宽'),
3106
+ h('input', {
3107
+ className: 'pxs-input',
3108
+ style: { width: 84 },
3109
+ type: 'number',
3110
+ min: 1,
3111
+ max: MAX_SIDE,
3112
+ value: resizeDialog.w,
3113
+ onChange: (event) => {
3114
+ const value = event.target.value;
3115
+ setResizeDialog((current) => (current ? { ...current, w: value } : current));
3116
+ },
3117
+ }),
3118
+ h('span', { className: 'pxs-dialog-label' }, '高'),
3119
+ h('input', {
3120
+ className: 'pxs-input',
3121
+ style: { width: 84 },
3122
+ type: 'number',
3123
+ min: 1,
3124
+ max: MAX_SIDE,
3125
+ value: resizeDialog.h,
3126
+ onChange: (event) => {
3127
+ const value = event.target.value;
3128
+ setResizeDialog((current) => (current ? { ...current, h: value } : current));
3129
+ },
3130
+ }),
3131
+ h('span', { className: 'pxs-hint' }, `最大 ${MAX_SIDE}`)),
3132
+ h('div', { className: 'pxs-dialog-row' },
3133
+ h('span', { className: 'pxs-dialog-label' }, '锚点'),
3134
+ h('div', { className: 'pxs-anchor-grid' },
3135
+ ANCHORS.map((anchor) => h('button', {
3136
+ key: anchor.id,
3137
+ className: 'pxs-anchor-chip',
3138
+ 'data-active': resizeDialog.anchor === anchor.id ? 'true' : 'false',
3139
+ title: `以${anchor.label}为基准保留画面`,
3140
+ onClick: () => setResizeDialog((current) => (current ? { ...current, anchor: anchor.id } : current)),
3141
+ }, anchor.label))),
3142
+ h('span', { className: 'pxs-hint' }, '扩边补透明,裁掉的部分丢弃')),
3143
+ resizeDialog.error ? h('div', { className: 'pxs-dialog-error' }, resizeDialog.error) : null,
3144
+ h('div', { className: 'pxs-dialog-actions' },
3145
+ h('button', { className: 'pxs-btn', onClick: () => setResizeDialog(null) }, '取消'),
3146
+ h('button', {
3147
+ className: 'pxs-btn pxs-btn-primary',
3148
+ onClick: confirmResize,
3149
+ }, '应用')))) : null;
3150
+
2493
3151
  return h('div', { className: 'pxs-root' },
2494
3152
  h('style', null, CSS),
2495
3153
  barNode,
@@ -2517,7 +3175,9 @@ window.__ModuleLoader__.load({
2517
3175
  rightPanel),
2518
3176
  menuNode,
2519
3177
  dialogNode,
2520
- newDialogNode);
3178
+ newDialogNode,
3179
+ importDialogNode,
3180
+ resizeDialogNode);
2521
3181
  }
2522
3182
 
2523
3183
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muxiuesd/dsh-pixel-studio",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "在 DSH 里画像素画:三栏编辑器(左工具 / 中画布 / 右调色板),滚轮缩放,图层、调色板、导出 PNG 都在会话页签里。",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -39,7 +39,8 @@
39
39
  "client.js",
40
40
  "cordis.patch.yml",
41
41
  "locale",
42
- "icon.png"
42
+ "icon.png",
43
+ "CHANGELOG.md"
43
44
  ],
44
45
  "dsh": {
45
46
  "bundle": {