@muxiuesd/dsh-pixel-studio 0.2.0 → 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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.3.0
4
+
5
+ - 新增矩形选区(工具 `M`):在画布上拖出选框,拖动框内只移动选框,`Shift + 拖动`(或工具条「抓着像素」)带着像素一起移动,方向键微调 1 像素(`Shift` 10 像素),`Ctrl+A` 全选,`Esc` 取消。
6
+ - 选区操作:水平 / 垂直镜像、填充(当前色)、删除,默认作用当前图层,工具条上的「多层」开关或按住 `Alt` 时作用于全部可见图层;每个操作一条撤销。
7
+ - 选区时画布底部浮出工具条,顶栏显示 `选区 12×8 @ (4,3)`;蚂蚁线画在渲染层,不污染像素。
8
+ - 撤销历史带上画布尺寸与选区状态,切换作品、改尺寸、撤销回放会自动清空选区。
9
+
3
10
  ## 0.2.0
4
11
 
5
12
  - 新增「打开图片」:把 PNG / JPEG / GIF / WebP / BMP 直接转成像素画,自动缩到 256 以内,可选自动提取配色(中位切分)/ 当前调色板 / PICO-8,可开 Floyd–Steinberg 抖动,导入前先看预览。
package/README.md CHANGED
@@ -20,6 +20,7 @@ dsh plugin --profile web add /绝对路径/dsh-pixel
20
20
 
21
21
  - **画布**:整数倍缩放 `1×…64×`,滚轮以光标为锚点;空格/中键拖动平移,`F` 适应窗口;`4×` 以上显示格线(可关)。
22
22
  - **工具**:铅笔、橡皮、油漆桶(按合成后的颜色判断连通,视觉同色一次填满)、吸管;方形笔刷 `1–8` 像素。
23
+ - **选区**(`M`):拖出矩形选框;拖动框内只移框,`Shift + 拖动` 带像素一起走;镜像 / 填充 / 删除默认作用当前图层,「多层」或 `Alt` 作用全部可见层;方向键微调,`Ctrl+A` 全选,`Esc` 取消。
23
24
  - **图层**:最多 8 层,新建/复制/合并/上移/下移/删除/改名/显隐,全部可撤销。
24
25
  - **调色板**:最多 36 色,内置 PICO-8 等预设板;改某个槽位的颜色**不会改动已经画好的像素**(画布上的旧颜色会先由一个空闲槽位接手),也可以用系统取色器可视化取色。
25
26
  - **作品**:新建时填名字和尺寸(1–256),另有 11 个常用尺寸预设;自动保存。
@@ -32,6 +33,12 @@ dsh plugin --profile web add /绝对路径/dsh-pixel
32
33
  | 键 | 作用 |
33
34
  | --- | --- |
34
35
  | `B` / `E` / `G` / `I` | 铅笔 / 橡皮 / 油漆桶 / 吸管 |
36
+ | `M` | 矩形选区 |
37
+ | `Ctrl/Cmd+A` | 全选选区 |
38
+ | `Esc` | 取消选区 |
39
+ | `Delete` / `Backspace` | 删除选区内像素 |
40
+ | `Shift + H` / `Shift + V` / `Shift + F` | 选区水平镜像 / 垂直镜像 / 填充 |
41
+ | 方向键(配合选区) | 微调选区 1 像素,`Shift` 为 10 像素 |
35
42
  | `[` `]` | 笔刷变小 / 变大 |
36
43
  | `H` | 格线开关 |
37
44
  | `F` | 适应窗口 |
package/client.js CHANGED
@@ -83,6 +83,7 @@ window.__ModuleLoader__.load({
83
83
  { id: 'eraser', label: '橡皮', key: 'E', icon: 'eraser' },
84
84
  { id: 'bucket', label: '油漆桶', key: 'G', icon: 'bucket' },
85
85
  { id: 'picker', label: '吸管', key: 'I', icon: 'picker' },
86
+ { id: 'select', label: '选区', key: 'M', icon: 'select' },
86
87
  ];
87
88
 
88
89
  /* ------------------------------------------------------------------ */
@@ -402,6 +403,114 @@ window.__ModuleLoader__.load({
402
403
  return next;
403
404
  }
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
+
405
514
  /* ------------------------------------------------------------------ */
406
515
  /* image import */
407
516
  /* ------------------------------------------------------------------ */
@@ -703,6 +812,14 @@ window.__ModuleLoader__.load({
703
812
  }),
704
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' }));
705
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
+ }
706
823
  return h('svg', common,
707
824
  h('path', {
708
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',
@@ -867,6 +984,24 @@ window.__ModuleLoader__.load({
867
984
  .pxs-status { font-size: 11px; color: var(--dsw-alias-label-secondary, #9aa1ad); min-width: 54px; text-align: right; }
868
985
  .pxs-status[data-state="dirty"] { color: var(--dsw-alias-state-warn-primary, #e0a33a); }
869
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
+ }
870
1005
  .pxs-notice {
871
1006
  font-size: 11px;
872
1007
  color: var(--dsw-alias-state-success-primary, #3fa45b);
@@ -1032,6 +1167,9 @@ window.__ModuleLoader__.load({
1032
1167
  const [editing, setEditing] = useState(null);
1033
1168
  const [importDialog, setImportDialog] = useState(null);
1034
1169
  const [resizeDialog, setResizeDialog] = useState(null);
1170
+ const [selection, setSelection] = useState(null);
1171
+ const [grabMode, setGrabMode] = useState(false);
1172
+ const [allLayersMode, setAllLayersMode] = useState(false);
1035
1173
 
1036
1174
  const stageRef = useRef(null);
1037
1175
  const canvasRef = useRef(null);
@@ -1063,10 +1201,21 @@ window.__ModuleLoader__.load({
1063
1201
  // before the callback is created, so it must not close over the binding.
1064
1202
  const saveAsRef = useRef(null);
1065
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);
1066
1212
 
1067
1213
  if (gridRef.current !== showGrid) gridRef.current = showGrid;
1068
1214
  sizeRef.current = { left: leftWidth, right: rightWidth };
1069
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;
1070
1219
 
1071
1220
  docRef.current = doc;
1072
1221
  toolRef.current = tool;
@@ -1200,10 +1349,47 @@ window.__ModuleLoader__.load({
1200
1349
  context.restore();
1201
1350
  }
1202
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
+
1203
1389
  context.strokeStyle = 'rgba(255,255,255,0.22)';
1204
1390
  context.lineWidth = 1;
1205
1391
  context.strokeRect(Math.round(pan.x) + 0.5, Math.round(pan.y) + 0.5, Math.round(docWidth), Math.round(docHeight));
1206
- }, [composeImage]);
1392
+ }, [composeImage, selection]);
1207
1393
 
1208
1394
  const requestPaint = useCallback(() => {
1209
1395
  if (frameRef.current !== 0) return;
@@ -1430,6 +1616,170 @@ window.__ModuleLoader__.load({
1430
1616
  }
1431
1617
  }, []);
1432
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
+
1433
1783
  const onPointerDown = useCallback((event) => {
1434
1784
  if (!docRef.current) return;
1435
1785
  const wantsPan = event.button === 1 || spaceRef.current || event.altKey;
@@ -1450,6 +1800,47 @@ window.__ModuleLoader__.load({
1450
1800
  if (!cell) return;
1451
1801
  const current = docRef.current;
1452
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
+
1453
1844
  if (toolRef.current === 'picker') {
1454
1845
  pickColorAt(cell);
1455
1846
  return;
@@ -1488,6 +1879,35 @@ window.__ModuleLoader__.load({
1488
1879
  requestPaint();
1489
1880
  return;
1490
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
+ }
1491
1911
  const stroke = strokeRef.current;
1492
1912
  if (!stroke || stroke.pointerId !== event.pointerId) return;
1493
1913
  const cell = cellFromEvent(event);
@@ -1498,6 +1918,18 @@ window.__ModuleLoader__.load({
1498
1918
  }, [cellFromEvent, requestPaint, strokeAt]);
1499
1919
 
1500
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
+ }
1501
1933
  if (panDrawRef.current && panDrawRef.current.pointerId === event.pointerId) {
1502
1934
  panDrawRef.current = null;
1503
1935
  requestPaint();
@@ -1570,6 +2002,11 @@ window.__ModuleLoader__.load({
1570
2002
  return;
1571
2003
  }
1572
2004
  const meta = event.metaKey || event.ctrlKey;
2005
+ if (meta && event.key.toLowerCase() === 'a') {
2006
+ event.preventDefault();
2007
+ selectApiRef.current.selectAll();
2008
+ return;
2009
+ }
1573
2010
  if (meta && event.key.toLowerCase() === 'z') {
1574
2011
  event.preventDefault();
1575
2012
  if (event.shiftKey) redo();
@@ -1593,10 +2030,45 @@ window.__ModuleLoader__.load({
1593
2030
  }
1594
2031
  if (meta) return;
1595
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
+ }
1596
2067
  if (key === 'b') setTool('pencil');
1597
2068
  else if (key === 'e') setTool('eraser');
1598
2069
  else if (key === 'g') setTool('bucket');
1599
2070
  else if (key === 'i') setTool('picker');
2071
+ else if (key === 'm') setTool('select');
1600
2072
  else if (key === 'f') fitToView();
1601
2073
  else if (key === 'h') {
1602
2074
  const next = !gridRef.current;
@@ -1962,15 +2434,6 @@ window.__ModuleLoader__.load({
1962
2434
  /* palette actions */
1963
2435
  /* -------------------------------------------------------------- */
1964
2436
 
1965
- const pushHistory = useCallback(() => {
1966
- const current = docRef.current;
1967
- if (!current) return;
1968
- const history = historyRef.current;
1969
- history.undo.push(snapshotOf(current));
1970
- if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
1971
- history.redo = [];
1972
- }, []);
1973
-
1974
2437
  const updatePalette = useCallback((palette, options = {}) => {
1975
2438
  const current = docRef.current;
1976
2439
  if (!current) return;
@@ -2377,6 +2840,10 @@ window.__ModuleLoader__.load({
2377
2840
  : status === 'error' ? '保存失败'
2378
2841
  : '已保存';
2379
2842
 
2843
+ const selectionText = selection
2844
+ ? `选区 ${selection.w}×${selection.h} @ (${selection.x},${selection.y})${selection.float ? ' · 浮动' : ''}${allLayersMode ? ' · 全部图层' : ''}`
2845
+ : '';
2846
+
2380
2847
  const currentLayer = doc ? doc.layers[layerIndex] : null;
2381
2848
 
2382
2849
  const swatchNodes = doc ? doc.palette.map((color, index) => h('button', {
@@ -2525,6 +2992,7 @@ window.__ModuleLoader__.load({
2525
2992
  title: '删除当前作品',
2526
2993
  }, '删除'),
2527
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,
2528
2996
  notice ? h('span', { className: 'pxs-notice', title: notice }, notice) : null);
2529
2997
 
2530
2998
  const errorNode = error ? h('div', { className: 'pxs-error' },
@@ -2666,7 +3134,10 @@ window.__ModuleLoader__.load({
2666
3134
  h('div', null, h('kbd', null, 'G'), '填充 ', h('kbd', null, 'I'), '吸管'),
2667
3135
  h('div', null, h('kbd', null, '滚轮'), '缩放 ', h('kbd', null, '空格'), '+ 拖动'),
2668
3136
  h('div', null, h('kbd', null, 'F'), '适应窗口 ', h('kbd', null, '[ ]'), '笔刷'),
2669
- 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'), '取消')));
2670
3141
 
2671
3142
  const leftPanel = h('div', { className: 'pxs-panel pxs-panel-left' },
2672
3143
  toolsSection, brushSection, layerSection, canvasSection, keysSection);
@@ -2678,7 +3149,10 @@ window.__ModuleLoader__.load({
2678
3149
  onPointerMove,
2679
3150
  onPointerUp,
2680
3151
  onPointerCancel: onPointerUp,
2681
- onContextMenu: (event) => event.preventDefault(),
3152
+ onContextMenu: (event) => {
3153
+ event.preventDefault();
3154
+ if (selectionRef.current) selectApiRef.current.cancel();
3155
+ },
2682
3156
  onDragStart: (event) => event.preventDefault(),
2683
3157
  });
2684
3158
 
@@ -2694,8 +3168,49 @@ window.__ModuleLoader__.load({
2694
3168
 
2695
3169
  const loadingNode = loading ? h('div', { className: 'pxs-empty' }, h('p', null, '加载作品中…')) : null;
2696
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
+
2697
3212
  const stageNode = h('div', { className: 'pxs-stage', ref: stageRef },
2698
- canvasNode, emptyNode, loadingNode);
3213
+ canvasNode, emptyNode, loadingNode, selectionBar);
2699
3214
 
2700
3215
  const colorSection = h('div', { className: 'pxs-section' },
2701
3216
  h('p', { className: 'pxs-title' }, '当前颜色'),
@@ -3235,6 +3750,23 @@ window.__ModuleLoader__.load({
3235
3750
  }
3236
3751
  }
3237
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
+
3238
3770
  return {
3239
3771
  inject: ['slots'],
3240
3772
  apply(ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muxiuesd/dsh-pixel-studio",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "在 DSH 里画像素画:三栏编辑器(左工具 / 中画布 / 右调色板),滚轮缩放,图层、调色板、导出 PNG 都在会话页签里。",
5
5
  "type": "module",
6
6
  "main": "./index.js",