@muxiuesd/dsh-pixel-studio 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.3.1
4
+
5
+ - 修复选区导致画布跳动:拖出选框时不再重新适应视图 / 改变缩放,撤销历史也不会被清空(此前选区变化会连带重建渲染回调、重新加载作品)。
6
+ - 修复「剪切」把内容从画布上抹掉:现在剪切 = 复制 + 抬起为浮动选区,像素仍显示在画布上并跟随选框,点「✓ 完成」才落到新位置,「✕ 取消」还原;`Delete` / 「清除」依旧立即抹除。
7
+ - 新增剪贴板:`Ctrl/Cmd+C` 复制、`Ctrl/Cmd+X` 剪切、`Ctrl/Cmd+V` 粘贴到选区位置(无选区时贴到左上角)。
8
+ - 拖拽期间忽略滚轮:拖动选框或画笔时,触控板滚动不会再缩放或平移画布。
9
+ - 主按钮文字随底色自适应:深色底白字、浅色底深色字,切换浅色 / 深色主题时自动更新。
10
+ - 选区浮动预览只合并被抬起的图层,不再把其他图层的像素复制进当前层。
11
+
12
+ ## 0.3.0
13
+
14
+ - 新增矩形选区(工具 `M`):在画布上拖出选框,拖动框内只移动选框,`Shift + 拖动`(或工具条「抓着像素」)带着像素一起移动,方向键微调 1 像素(`Shift` 10 像素),`Ctrl+A` 全选,`Esc` 取消。
15
+ - 选区操作:水平 / 垂直镜像、填充(当前色)、删除,默认作用当前图层,工具条上的「多层」开关或按住 `Alt` 时作用于全部可见图层;每个操作一条撤销。
16
+ - 选区时画布底部浮出工具条,顶栏显示 `选区 12×8 @ (4,3)`;蚂蚁线画在渲染层,不污染像素。
17
+ - 撤销历史带上画布尺寸与选区状态,切换作品、改尺寸、撤销回放会自动清空选区。
18
+
3
19
  ## 0.2.0
4
20
 
5
21
  - 新增「打开图片」:把 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,13 @@ 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
+ | `Ctrl/Cmd+C` / `Ctrl/Cmd+X` / `Ctrl/Cmd+V` | 复制 / 剪切(内容留在画布上) / 粘贴 |
41
+ | `Shift + H` / `Shift + V` / `Shift + F` | 选区水平镜像 / 垂直镜像 / 填充 |
42
+ | 方向键(配合选区) | 微调选区 1 像素,`Shift` 为 10 像素 |
35
43
  | `[` `]` | 笔刷变小 / 变大 |
36
44
  | `H` | 格线开关 |
37
45
  | `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,177 @@ 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
+
514
+ /** Stacks regions bottom-first; transparent cells let the lower region show through. */
515
+ function overlayRegions(regions) {
516
+ const height = regions.length === 0 ? 0 : regions[0].length;
517
+ const width = height === 0 ? 0 : regions[0][0].length;
518
+ const out = [];
519
+ for (let y = 0; y < height; y += 1) {
520
+ const chars = new Array(width).fill('.');
521
+ regions.forEach((region) => {
522
+ const line = region[y] || '';
523
+ for (let x = 0; x < width; x += 1) {
524
+ const ch = line[x];
525
+ if (ch && ch !== '.') chars[x] = ch;
526
+ }
527
+ });
528
+ out.push(chars.join(''));
529
+ }
530
+ return out;
531
+ }
532
+
533
+ /* solid fills ------------------------------------------------------ */
534
+ /* Text that sits on a solid colour has to flip with that colour: white on
535
+ * dark fills, near-black on light ones. The fill often comes from a theme
536
+ * token, so the readable ink is resolved at runtime instead of guessed in
537
+ * CSS (a light theme can map "brand" to a bright yellow). */
538
+ function parseRgbColor(value) {
539
+ if (typeof value !== 'string') return null;
540
+ const match = value.match(/rgba?\(([^)]+)\)/i);
541
+ if (!match) return null;
542
+ const parts = match[1].split(/[,\s/]+/).filter(Boolean).map(Number);
543
+ if (parts.length < 3 || parts.slice(0, 3).some((part) => !Number.isFinite(part))) return null;
544
+ return { r: parts[0], g: parts[1], b: parts[2] };
545
+ }
546
+
547
+ function readableOn(color) {
548
+ const rgb = parseRgbColor(color);
549
+ if (!rgb) return '#ffffff';
550
+ const channel = (value) => {
551
+ const unit = clamp(value, 0, 255) / 255;
552
+ return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4;
553
+ };
554
+ const luminance = 0.2126 * channel(rgb.r) + 0.7152 * channel(rgb.g) + 0.0722 * channel(rgb.b);
555
+ return luminance > 0.5 ? '#101216' : '#ffffff';
556
+ }
557
+
558
+ function resolveBrandInk(host) {
559
+ if (typeof document === 'undefined' || typeof window === 'undefined') return '#ffffff';
560
+ const parent = (host && typeof host.appendChild === 'function') ? host : document.body;
561
+ if (!parent || typeof parent.appendChild !== 'function') return '#ffffff';
562
+ if (typeof window.getComputedStyle !== 'function') return '#ffffff';
563
+ let probe = null;
564
+ try {
565
+ probe = document.createElement('span');
566
+ probe.style.cssText = 'position:absolute;left:-9999px;top:0;width:0;height:0;'
567
+ + 'background:var(--dsw-alias-brand-primary, #4b7bec)';
568
+ parent.appendChild(probe);
569
+ return readableOn(window.getComputedStyle(probe).backgroundColor);
570
+ } catch (error) {
571
+ return '#ffffff';
572
+ } finally {
573
+ if (probe && typeof probe.remove === 'function') probe.remove();
574
+ }
575
+ }
576
+
405
577
  /* ------------------------------------------------------------------ */
406
578
  /* image import */
407
579
  /* ------------------------------------------------------------------ */
@@ -703,6 +875,14 @@ window.__ModuleLoader__.load({
703
875
  }),
704
876
  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
877
  }
878
+ if (name === 'select') {
879
+ return h('svg', common,
880
+ h('rect', {
881
+ x: 2.5, y: 2.5, width: 11, height: 11, rx: 1,
882
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeDasharray: '3 2',
883
+ }),
884
+ 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' }));
885
+ }
706
886
  return h('svg', common,
707
887
  h('path', {
708
888
  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 +1047,35 @@ window.__ModuleLoader__.load({
867
1047
  .pxs-status { font-size: 11px; color: var(--dsw-alias-label-secondary, #9aa1ad); min-width: 54px; text-align: right; }
868
1048
  .pxs-status[data-state="dirty"] { color: var(--dsw-alias-state-warn-primary, #e0a33a); }
869
1049
  .pxs-status[data-state="error"] { color: var(--dsw-alias-state-error-primary, #e0574f); }
1050
+ .pxs-select-status {
1051
+ min-width: 0; text-align: left; font-variant-numeric: tabular-nums;
1052
+ color: var(--dsw-alias-brand-primary, #7aa2f7);
1053
+ }
1054
+ .pxs-selection-bar {
1055
+ position: absolute; left: 10px; right: 10px; bottom: 10px; z-index: 3;
1056
+ display: flex; flex-direction: column; gap: 5px;
1057
+ padding: 6px 8px; border-radius: 9px;
1058
+ background: var(--dsw-alias-bg-overlay, rgba(38, 42, 51, .9));
1059
+ border: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .2));
1060
+ box-shadow: 0 6px 20px rgba(0, 0, 0, .28);
1061
+ backdrop-filter: blur(6px);
1062
+ }
1063
+ .pxs-selection-row {
1064
+ display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
1065
+ }
1066
+ .pxs-selection-bar .pxs-btn { height: 24px; padding: 0 8px; font-size: 11px; }
1067
+ .pxs-selection-divider {
1068
+ width: 1px; height: 16px; margin: 0 4px;
1069
+ background: var(--dsw-alias-border-l1, rgba(255, 255, 255, .16));
1070
+ }
1071
+ .pxs-selection-hint {
1072
+ font-size: 10px; line-height: 1.5;
1073
+ color: var(--dsw-alias-label-tertiary, rgba(255, 255, 255, .55));
1074
+ }
1075
+ .pxs-selection-label {
1076
+ font-size: 11px; margin-right: auto; color: var(--dsw-alias-label-secondary, #9aa1ad);
1077
+ font-variant-numeric: tabular-nums; white-space: nowrap;
1078
+ }
870
1079
  .pxs-notice {
871
1080
  font-size: 11px;
872
1081
  color: var(--dsw-alias-state-success-primary, #3fa45b);
@@ -914,7 +1123,7 @@ window.__ModuleLoader__.load({
914
1123
  .pxs-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
915
1124
  .pxs-btn-primary {
916
1125
  background: var(--dsw-alias-brand-primary, #4b7bec); border-color: transparent;
917
- color: #fff;
1126
+ color: var(--pxs-on-brand, #fff);
918
1127
  }
919
1128
  .pxs-native-color {
920
1129
  width: 40px; height: 28px; padding: 0; border: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .18));
@@ -1007,6 +1216,12 @@ window.__ModuleLoader__.load({
1007
1216
  /* editor */
1008
1217
  /* ------------------------------------------------------------------ */
1009
1218
 
1219
+ /**
1220
+ * In-memory clipboard for the selection tool. Intentionally not persisted:
1221
+ * a page reload should never paste stale pixels into another drawing.
1222
+ */
1223
+ let clipRegion = null;
1224
+
1010
1225
  function PixelStudio(props) {
1011
1226
  const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : '';
1012
1227
  const [docs, setDocs] = useState([]);
@@ -1032,6 +1247,25 @@ window.__ModuleLoader__.load({
1032
1247
  const [editing, setEditing] = useState(null);
1033
1248
  const [importDialog, setImportDialog] = useState(null);
1034
1249
  const [resizeDialog, setResizeDialog] = useState(null);
1250
+ const [selection, setSelection] = useState(null);
1251
+ const [grabMode, setGrabMode] = useState(false);
1252
+ const [allLayersMode, setAllLayersMode] = useState(false);
1253
+ const [brandInk, setBrandInk] = useState(() => resolveBrandInk());
1254
+ const rootRef = useRef(null);
1255
+
1256
+ useEffect(() => {
1257
+ const sync = () => setBrandInk(resolveBrandInk(rootRef.current));
1258
+ sync();
1259
+ if (typeof MutationObserver !== 'function' || typeof document === 'undefined') return undefined;
1260
+ if (!document.documentElement) return undefined;
1261
+ const observer = new MutationObserver(sync);
1262
+ const targets = [document.documentElement, document.body].filter(Boolean);
1263
+ targets.forEach((target) => observer.observe(target, {
1264
+ attributes: true,
1265
+ attributeFilter: ['class', 'style', 'data-theme', 'data-ds-theme', 'data-color-scheme'],
1266
+ }));
1267
+ return () => observer.disconnect();
1268
+ }, []);
1035
1269
 
1036
1270
  const stageRef = useRef(null);
1037
1271
  const canvasRef = useRef(null);
@@ -1063,10 +1297,21 @@ window.__ModuleLoader__.load({
1063
1297
  // before the callback is created, so it must not close over the binding.
1064
1298
  const saveAsRef = useRef(null);
1065
1299
  const fileRef = useRef(null);
1300
+ /** Live rectangle, read by the pointer handlers and the paint loop. */
1301
+ const selectionRef = useRef(null);
1302
+ /** In-flight selection drag: { mode, pointerId, start, origin }. */
1303
+ const selectDragRef = useRef(null);
1304
+ /** Indirection for the keyboard effect, which is registered before these callbacks exist. */
1305
+ const selectApiRef = useRef({});
1306
+ const grabRef = useRef(false);
1307
+ const allLayersRef = useRef(false);
1066
1308
 
1067
1309
  if (gridRef.current !== showGrid) gridRef.current = showGrid;
1068
1310
  sizeRef.current = { left: leftWidth, right: rightWidth };
1069
1311
  if (editingRef.current !== editing) editingRef.current = editing;
1312
+ if (grabRef.current !== grabMode) grabRef.current = grabMode;
1313
+ if (allLayersRef.current !== allLayersMode) allLayersRef.current = allLayersMode;
1314
+ selectionRef.current = selection;
1070
1315
 
1071
1316
  docRef.current = doc;
1072
1317
  toolRef.current = tool;
@@ -1126,6 +1371,10 @@ window.__ModuleLoader__.load({
1126
1371
 
1127
1372
  const paint = useCallback(() => {
1128
1373
  frameRef.current = 0;
1374
+ // Read through the ref so paints do not depend on the selection state
1375
+ // identity: a changing callback here would cascade into fitToView →
1376
+ // openDoc → the mount effect, reloading the document on every selection.
1377
+ const selection = selectionRef.current;
1129
1378
  const canvas = canvasRef.current;
1130
1379
  const stage = stageRef.current;
1131
1380
  const current = docRef.current;
@@ -1200,6 +1449,43 @@ window.__ModuleLoader__.load({
1200
1449
  context.restore();
1201
1450
  }
1202
1451
 
1452
+ if (selection) {
1453
+ const boxX = Math.round(pan.x + selection.x * scale);
1454
+ const boxY = Math.round(pan.y + selection.y * scale);
1455
+ const boxW = Math.max(1, Math.round(selection.w * scale));
1456
+ const boxH = Math.max(1, Math.round(selection.h * scale));
1457
+ const floating = selection.float;
1458
+ if (floating && floating.region) {
1459
+ // Floating content preview, drawn over the cells it was lifted from.
1460
+ for (let row = 0; row < floating.region.length; row += 1) {
1461
+ const line = floating.region[row];
1462
+ for (let column = 0; column < line.length; column += 1) {
1463
+ const ch = line[column];
1464
+ if (!ch || ch === '.') continue;
1465
+ const color = current.palette[indexFor(ch)];
1466
+ if (!color) continue;
1467
+ context.fillStyle = color;
1468
+ context.fillRect(
1469
+ Math.round(pan.x + (selection.x + column) * scale),
1470
+ Math.round(pan.y + (selection.y + row) * scale),
1471
+ Math.ceil(scale),
1472
+ Math.ceil(scale),
1473
+ );
1474
+ }
1475
+ }
1476
+ }
1477
+ // Marching ants: one dark and one offset light dashed border.
1478
+ context.save();
1479
+ context.lineWidth = 1;
1480
+ context.setLineDash([4, 4]);
1481
+ context.strokeStyle = 'rgba(0,0,0,0.8)';
1482
+ context.strokeRect(boxX + 0.5, boxY + 0.5, boxW - 1, boxH - 1);
1483
+ context.lineDashOffset = 4;
1484
+ context.strokeStyle = 'rgba(255,255,255,0.95)';
1485
+ context.strokeRect(boxX + 0.5, boxY + 0.5, boxW - 1, boxH - 1);
1486
+ context.restore();
1487
+ }
1488
+
1203
1489
  context.strokeStyle = 'rgba(255,255,255,0.22)';
1204
1490
  context.lineWidth = 1;
1205
1491
  context.strokeRect(Math.round(pan.x) + 0.5, Math.round(pan.y) + 0.5, Math.round(docWidth), Math.round(docHeight));
@@ -1247,6 +1533,8 @@ window.__ModuleLoader__.load({
1247
1533
 
1248
1534
  const openDoc = useCallback(async (id) => {
1249
1535
  const data = await callApi({ op: 'get', id });
1536
+ const previous = docRef.current;
1537
+ const switched = !previous || previous.id !== data.doc.id;
1250
1538
  setDoc(data.doc);
1251
1539
  docRef.current = data.doc;
1252
1540
  setLayerIndex(0);
@@ -1254,6 +1542,12 @@ window.__ModuleLoader__.load({
1254
1542
  setEditing(null);
1255
1543
  editingRef.current = null;
1256
1544
  historyRef.current = { undo: [], redo: [] };
1545
+ if (switched) {
1546
+ // Selection coordinates belong to the document that was open;
1547
+ // a late-arriving reload of the same document must not wipe it.
1548
+ selectionRef.current = null;
1549
+ setSelection(null);
1550
+ }
1257
1551
  setStatus('clean');
1258
1552
  window.requestAnimationFrame(() => fitToView());
1259
1553
  }, [fitToView]);
@@ -1358,6 +1652,8 @@ window.__ModuleLoader__.load({
1358
1652
  if (!snapshot) return;
1359
1653
  const current = docRef.current;
1360
1654
  history.redo.push(snapshotOf(current));
1655
+ selectionRef.current = null;
1656
+ setSelection(null);
1361
1657
  applyHistory(snapshot);
1362
1658
  }, [applyHistory]);
1363
1659
 
@@ -1367,6 +1663,8 @@ window.__ModuleLoader__.load({
1367
1663
  if (!snapshot) return;
1368
1664
  const current = docRef.current;
1369
1665
  history.undo.push(snapshotOf(current));
1666
+ selectionRef.current = null;
1667
+ setSelection(null);
1370
1668
  applyHistory(snapshot);
1371
1669
  }, [applyHistory]);
1372
1670
 
@@ -1430,6 +1728,272 @@ window.__ModuleLoader__.load({
1430
1728
  }
1431
1729
  }, []);
1432
1730
 
1731
+ const pushHistory = useCallback(() => {
1732
+ const current = docRef.current;
1733
+ if (!current) return;
1734
+ const history = historyRef.current;
1735
+ history.undo.push(snapshotOf(current));
1736
+ if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
1737
+ history.redo = [];
1738
+ }, []);
1739
+
1740
+ const selectionTargets = useCallback((allLayers) => {
1741
+ const current = docRef.current;
1742
+ if (!current) return [];
1743
+ if (allLayers) {
1744
+ return current.layers
1745
+ .map((layer, index) => ({ layer, index }))
1746
+ .filter((entry) => entry.layer && entry.layer.visible !== false);
1747
+ }
1748
+ const layer = current.layers[layerRef.current];
1749
+ return layer ? [{ layer, index: layerRef.current }] : [];
1750
+ }, []);
1751
+
1752
+ const setSelectionRect = useCallback((rect) => {
1753
+ selectionRef.current = rect;
1754
+ setSelection(rect);
1755
+ requestPaint();
1756
+ }, [requestPaint]);
1757
+
1758
+ const selectAll = useCallback(() => {
1759
+ const current = docRef.current;
1760
+ if (!current) return;
1761
+ setTool('select');
1762
+ setSelectionRect({ x: 0, y: 0, w: current.w, h: current.h, float: null });
1763
+ requestPaint();
1764
+ }, [requestPaint, setSelectionRect]);
1765
+
1766
+ const putBackLifted = useCallback((current, active) => {
1767
+ const float = active.float;
1768
+ if (!float) return current;
1769
+ const layers = current.layers.map((layer, index) => {
1770
+ const item = float.targets.find((entry) => entry.index === index);
1771
+ if (!item) return layer;
1772
+ return {
1773
+ ...layer,
1774
+ rows: writeRegion(layer.rows, item.region, float.origin.x, float.origin.y),
1775
+ };
1776
+ });
1777
+ return { ...current, layers };
1778
+ }, []);
1779
+
1780
+ const cancelSelection = useCallback(() => {
1781
+ const current = docRef.current;
1782
+ const active = selectionRef.current;
1783
+ selectionRef.current = null;
1784
+ setSelection(null);
1785
+ if (current && active && active.float) {
1786
+ const next = putBackLifted(current, active);
1787
+ docRef.current = next;
1788
+ setDoc(next);
1789
+ scheduleSave();
1790
+ }
1791
+ requestPaint();
1792
+ }, [putBackLifted, requestPaint, scheduleSave]);
1793
+
1794
+ /** Lifts the selected pixels out of their layers; false when nothing could be lifted. */
1795
+ const liftSelection = useCallback((allLayers) => {
1796
+ const current = docRef.current;
1797
+ const active = selectionRef.current;
1798
+ if (!current || !active || active.float) return false;
1799
+ const rect = clampRectangle(active, current.w, current.h);
1800
+ const targets = selectionTargets(allLayers);
1801
+ if (targets.length === 0) return false;
1802
+ pushHistory();
1803
+ const items = targets.map((entry) => ({ index: entry.index, region: readRegion(entry.layer.rows, rect) }));
1804
+ const layers = current.layers.map((layer, index) => {
1805
+ const item = items.find((entry) => entry.index === index);
1806
+ return item ? { ...layer, rows: emptyRows(layer.rows, rect) } : layer;
1807
+ });
1808
+ const next = { ...current, layers };
1809
+ docRef.current = next;
1810
+ setDoc(next);
1811
+ setSelectionRect({
1812
+ ...rect,
1813
+ float: {
1814
+ // Only the lifted layers, so pixels of untouched layers are not
1815
+ // copied into the destination when the selection is dropped.
1816
+ region: overlayRegions(items.map((item) => item.region)),
1817
+ origin: { x: rect.x, y: rect.y },
1818
+ targets: items,
1819
+ },
1820
+ });
1821
+ setStatus('dirty');
1822
+ return true;
1823
+ }, [pushHistory, selectionTargets, setSelectionRect]);
1824
+
1825
+ /** Drops the lifted pixels at the rectangle's current position. */
1826
+ const dropSelection = useCallback(() => {
1827
+ const current = docRef.current;
1828
+ const active = selectionRef.current;
1829
+ if (!current || !active || !active.float) return;
1830
+ const rect = clampRectangle(active, current.w, current.h);
1831
+ const float = active.float;
1832
+ const layers = current.layers.map((layer, index) => {
1833
+ const item = float.targets.find((entry) => entry.index === index);
1834
+ return item ? { ...layer, rows: writeRegion(layer.rows, item.region, rect.x, rect.y) } : layer;
1835
+ });
1836
+ const next = { ...current, layers };
1837
+ docRef.current = next;
1838
+ setDoc(next);
1839
+ setSelectionRect({ ...rect, float: null });
1840
+ setStatus('dirty');
1841
+ scheduleSave();
1842
+ }, [scheduleSave, setSelectionRect]);
1843
+
1844
+ const applySelectionEdit = useCallback((mode, allLayers) => {
1845
+ const current = docRef.current;
1846
+ const active = selectionRef.current;
1847
+ if (!current || !active) return;
1848
+ let base = current;
1849
+ if (active.float) {
1850
+ const floating = clampRectangle(active, base.w, base.h);
1851
+ if (mode === 'delete') {
1852
+ // The lift already pushed its own history entry, so dropping the float
1853
+ // without writing it back is exactly "these pixels are gone".
1854
+ setSelectionRect({ ...floating, float: null });
1855
+ setStatus('dirty');
1856
+ scheduleSave();
1857
+ return;
1858
+ }
1859
+ const float = active.float;
1860
+ const committed = base.layers.map((layer, index) => {
1861
+ const item = float.targets.find((entry) => entry.index === index);
1862
+ if (!item) return layer;
1863
+ return { ...layer, rows: writeRegion(layer.rows, item.region, floating.x, floating.y) };
1864
+ });
1865
+ base = { ...base, layers: committed };
1866
+ docRef.current = base;
1867
+ setDoc(base);
1868
+ }
1869
+ const rect = clampRectangle(active, base.w, base.h);
1870
+ const targets = selectionTargets(allLayers);
1871
+ if (targets.length === 0) return;
1872
+ // A committed float already pushed its own undo entry during the lift.
1873
+ if (!active.float) pushHistory();
1874
+ const layers = base.layers.map((layer, index) => {
1875
+ const target = targets.find((entry) => entry.index === index);
1876
+ if (!target) return layer;
1877
+ let rows = layer.rows;
1878
+ if (mode === 'mirror-h') rows = mirrorRows(rows, rect, true);
1879
+ else if (mode === 'mirror-v') rows = mirrorRows(rows, rect, false);
1880
+ else if (mode === 'fill') rows = fillRows(rows, rect, charFor(colorRef.current));
1881
+ else if (mode === 'delete') rows = emptyRows(rows, rect);
1882
+ return { ...layer, rows };
1883
+ });
1884
+ const next = { ...base, layers };
1885
+ docRef.current = next;
1886
+ setDoc(next);
1887
+ setSelectionRect({ ...rect, float: null });
1888
+ setStatus('dirty');
1889
+ scheduleSave();
1890
+ }, [pushHistory, scheduleSave, selectionTargets, setSelectionRect]);
1891
+
1892
+ /** Arrow-key nudging: 1 cell, 10 with Shift; carries lifted pixels along. */
1893
+ const nudgeSelection = useCallback((dx, dy) => {
1894
+ const current = docRef.current;
1895
+ const active = selectionRef.current;
1896
+ if (!current || !active) return;
1897
+ const rect = moveRectangle(active, active.x + dx, active.y + dy, current.w, current.h);
1898
+ setSelectionRect({ ...rect, float: active.float });
1899
+ }, [setSelectionRect]);
1900
+
1901
+ /** Copy what the selection shows (floating pixels win) into the clipboard. */
1902
+ const copySelection = useCallback(() => {
1903
+ const current = docRef.current;
1904
+ const active = selectionRef.current;
1905
+ if (!current || !active) return false;
1906
+ const rect = clampRectangle(active, current.w, current.h);
1907
+ clipRegion = {
1908
+ w: rect.w,
1909
+ h: rect.h,
1910
+ rows: active.float
1911
+ ? active.float.region.map((row) => row.slice())
1912
+ : mergedRegion(current.layers, rect),
1913
+ };
1914
+ setNotice(`已复制 ${rect.w}×${rect.h}`);
1915
+ return true;
1916
+ }, []);
1917
+
1918
+ /**
1919
+ * Cut keeps the pixels on the canvas: the content is copied and lifted into the
1920
+ * floating selection, so it stays visible and can be dragged to its new home.
1921
+ * Only 「完成」 (or dropping elsewhere) actually removes it from where it was.
1922
+ */
1923
+ const cutSelection = useCallback(() => {
1924
+ const copied = copySelection();
1925
+ if (!copied) return;
1926
+ const lifted = liftSelection(allLayersRef.current);
1927
+ setNotice(lifted
1928
+ ? '已剪切 · 内容还留在画布上,拖动选框然后点「完成」落地'
1929
+ : '已剪切 · 内容已在剪贴板里');
1930
+ }, [copySelection, liftSelection]);
1931
+
1932
+ const finishSelection = useCallback(() => {
1933
+ const active = selectionRef.current;
1934
+ if (!active) return;
1935
+ if (active.float) dropSelection();
1936
+ selectionRef.current = null;
1937
+ setSelection(null);
1938
+ requestPaint();
1939
+ scheduleSave();
1940
+ }, [dropSelection, requestPaint, scheduleSave]);
1941
+
1942
+ const pasteClipboard = useCallback(() => {
1943
+ const clipboard = clipRegion;
1944
+ if (!clipboard) return;
1945
+ const active = selectionRef.current;
1946
+ // A floating selection is committed where it stands before pasting, otherwise
1947
+ // the paste would silently throw the moved pixels back to their old home.
1948
+ if (active && active.float) dropSelection();
1949
+ const base = docRef.current;
1950
+ if (!base) return;
1951
+ const width = Math.min(clipboard.w, base.w);
1952
+ const height = Math.min(clipboard.h, base.h);
1953
+ const origin = active ? clampRectangle(active, base.w, base.h) : { x: 0, y: 0 };
1954
+ const x = clamp(origin.x, 0, base.w - width);
1955
+ const y = clamp(origin.y, 0, base.h - height);
1956
+ const region = clipboard.rows
1957
+ .slice(0, height)
1958
+ .map((row) => row.padEnd(clipboard.w, '.').slice(0, width));
1959
+ pushHistory();
1960
+ const layers = base.layers.map((layer, index) => (index === layerRef.current
1961
+ ? { ...layer, rows: writeRegion(layer.rows, region, x, y) }
1962
+ : layer));
1963
+ const next = { ...base, layers };
1964
+ docRef.current = next;
1965
+ setDoc(next);
1966
+ setSelectionRect({ x, y, w: width, h: height, float: null });
1967
+ setStatus('dirty');
1968
+ scheduleSave();
1969
+ setNotice(`已粘贴 ${width}×${height} @ (${x},${y})`);
1970
+ }, [dropSelection, pushHistory, scheduleSave, setSelectionRect]);
1971
+
1972
+ const mirrorSelection = useCallback((horizontal) => {
1973
+ applySelectionEdit(horizontal ? 'mirror-h' : 'mirror-v', allLayersRef.current);
1974
+ }, [applySelectionEdit]);
1975
+
1976
+ const fillSelection = useCallback(() => {
1977
+ applySelectionEdit('fill', allLayersRef.current);
1978
+ }, [applySelectionEdit]);
1979
+
1980
+ // Exposed to the keyboard effect, which is registered before these callbacks exist.
1981
+ selectApiRef.current = {
1982
+ selectAll,
1983
+ cancel: cancelSelection,
1984
+ apply: applySelectionEdit,
1985
+ nudge: nudgeSelection,
1986
+ drop: dropSelection,
1987
+ lift: liftSelection,
1988
+ copy: copySelection,
1989
+ cut: cutSelection,
1990
+ paste: pasteClipboard,
1991
+ mirror: mirrorSelection,
1992
+ fill: fillSelection,
1993
+ finish: finishSelection,
1994
+ allLayers: allLayersRef.current,
1995
+ };
1996
+
1433
1997
  const onPointerDown = useCallback((event) => {
1434
1998
  if (!docRef.current) return;
1435
1999
  const wantsPan = event.button === 1 || spaceRef.current || event.altKey;
@@ -1450,6 +2014,46 @@ window.__ModuleLoader__.load({
1450
2014
  if (!cell) return;
1451
2015
  const current = docRef.current;
1452
2016
 
2017
+ if (toolRef.current === 'select') {
2018
+ event.preventDefault();
2019
+ const anchor = {
2020
+ x: clamp(cell.x, 0, Math.max(0, current.w - 1)),
2021
+ y: clamp(cell.y, 0, Math.max(0, current.h - 1)),
2022
+ };
2023
+ const active = selectionRef.current;
2024
+ const inside = active ? pointInRectangle(active, cell) : false;
2025
+ const carry = event.shiftKey || grabRef.current;
2026
+ event.currentTarget.setPointerCapture(event.pointerId);
2027
+ if (inside && carry && (active.float || liftSelection(allLayersRef.current))) {
2028
+ selectDragRef.current = {
2029
+ mode: 'grab',
2030
+ pointerId: event.pointerId,
2031
+ start: anchor,
2032
+ origin: { x: active.x, y: active.y },
2033
+ };
2034
+ } else if (inside) {
2035
+ // Plain drag slides the frame; the pixels stay where they are.
2036
+ selectDragRef.current = {
2037
+ mode: 'move',
2038
+ pointerId: event.pointerId,
2039
+ start: anchor,
2040
+ origin: { x: active.x, y: active.y },
2041
+ };
2042
+ } else {
2043
+ setSelectionRect({
2044
+ ...clampRectangle({ x: anchor.x, y: anchor.y, w: 1, h: 1 }, current.w, current.h),
2045
+ float: null,
2046
+ });
2047
+ selectDragRef.current = {
2048
+ mode: 'create',
2049
+ pointerId: event.pointerId,
2050
+ start: anchor,
2051
+ };
2052
+ }
2053
+ requestPaint();
2054
+ return;
2055
+ }
2056
+
1453
2057
  if (toolRef.current === 'picker') {
1454
2058
  pickColorAt(cell);
1455
2059
  return;
@@ -1488,6 +2092,35 @@ window.__ModuleLoader__.load({
1488
2092
  requestPaint();
1489
2093
  return;
1490
2094
  }
2095
+ const selectDrag = selectDragRef.current;
2096
+ if (selectDrag && selectDrag.pointerId === event.pointerId) {
2097
+ const current = docRef.current;
2098
+ const cell = cellFromEvent(event);
2099
+ if (!current || !cell) return;
2100
+ const anchor = {
2101
+ x: clamp(cell.x, 0, Math.max(0, current.w - 1)),
2102
+ y: clamp(cell.y, 0, Math.max(0, current.h - 1)),
2103
+ };
2104
+ if (selectDrag.mode === 'create') {
2105
+ setSelectionRect({
2106
+ ...clampRectangle(selectionBetween(selectDrag.start, anchor), current.w, current.h),
2107
+ float: null,
2108
+ });
2109
+ } else {
2110
+ const active = selectionRef.current;
2111
+ if (!active) return;
2112
+ const rect = moveRectangle(
2113
+ { x: 0, y: 0, w: active.w, h: active.h },
2114
+ selectDrag.origin.x + (anchor.x - selectDrag.start.x),
2115
+ selectDrag.origin.y + (anchor.y - selectDrag.start.y),
2116
+ current.w,
2117
+ current.h,
2118
+ );
2119
+ setSelectionRect({ ...rect, float: active.float });
2120
+ }
2121
+ requestPaint();
2122
+ return;
2123
+ }
1491
2124
  const stroke = strokeRef.current;
1492
2125
  if (!stroke || stroke.pointerId !== event.pointerId) return;
1493
2126
  const cell = cellFromEvent(event);
@@ -1498,6 +2131,18 @@ window.__ModuleLoader__.load({
1498
2131
  }, [cellFromEvent, requestPaint, strokeAt]);
1499
2132
 
1500
2133
  const onPointerUp = useCallback((event) => {
2134
+ const selectDrag = selectDragRef.current;
2135
+ if (selectDrag && selectDrag.pointerId === event.pointerId) {
2136
+ selectDragRef.current = null;
2137
+ if (selectDrag.mode === 'grab') dropSelection();
2138
+ const active = selectionRef.current;
2139
+ if (active && (active.w < 1 || active.h < 1)) {
2140
+ selectionRef.current = null;
2141
+ setSelection(null);
2142
+ }
2143
+ requestPaint();
2144
+ return;
2145
+ }
1501
2146
  if (panDrawRef.current && panDrawRef.current.pointerId === event.pointerId) {
1502
2147
  panDrawRef.current = null;
1503
2148
  requestPaint();
@@ -1521,6 +2166,9 @@ window.__ModuleLoader__.load({
1521
2166
  const onWheel = (event) => {
1522
2167
  if (!docRef.current) return;
1523
2168
  event.preventDefault();
2169
+ // A drag owns the pointer: a trackpad that fires wheel events
2170
+ // mid-drag must not shove or rescale the canvas under the cursor.
2171
+ if (selectDragRef.current || strokeRef.current || panDrawRef.current) return;
1524
2172
  if (event.shiftKey) {
1525
2173
  panRef.current = {
1526
2174
  x: panRef.current.x - event.deltaY,
@@ -1570,6 +2218,11 @@ window.__ModuleLoader__.load({
1570
2218
  return;
1571
2219
  }
1572
2220
  const meta = event.metaKey || event.ctrlKey;
2221
+ if (meta && event.key.toLowerCase() === 'a') {
2222
+ event.preventDefault();
2223
+ selectApiRef.current.selectAll();
2224
+ return;
2225
+ }
1573
2226
  if (meta && event.key.toLowerCase() === 'z') {
1574
2227
  event.preventDefault();
1575
2228
  if (event.shiftKey) redo();
@@ -1591,12 +2244,62 @@ window.__ModuleLoader__.load({
1591
2244
  void saveNow();
1592
2245
  return;
1593
2246
  }
1594
- if (meta) return;
2247
+ if (meta) {
2248
+ const combo = event.key.toLowerCase();
2249
+ const wantsSelection = selectionRef.current !== null;
2250
+ if ((combo === 'c' || combo === 'x') && wantsSelection) {
2251
+ event.preventDefault();
2252
+ if (combo === 'c') selectApiRef.current.copy();
2253
+ else selectApiRef.current.cut();
2254
+ return;
2255
+ }
2256
+ if (combo === 'v' && clipRegion !== null) {
2257
+ event.preventDefault();
2258
+ selectApiRef.current.paste();
2259
+ return;
2260
+ }
2261
+ return;
2262
+ }
1595
2263
  const key = event.key.toLowerCase();
2264
+ if (key === 'escape') {
2265
+ selectApiRef.current.cancel();
2266
+ return;
2267
+ }
2268
+ if (selectionRef.current) {
2269
+ if (key === 'delete' || key === 'backspace') {
2270
+ event.preventDefault();
2271
+ selectApiRef.current.apply('delete', selectApiRef.current.allLayers);
2272
+ return;
2273
+ }
2274
+ if (key.indexOf('arrow') === 0) {
2275
+ event.preventDefault();
2276
+ const step = event.shiftKey ? 10 : 1;
2277
+ const dx = key === 'arrowleft' ? -step : key === 'arrowright' ? step : 0;
2278
+ const dy = key === 'arrowup' ? -step : key === 'arrowdown' ? step : 0;
2279
+ selectApiRef.current.nudge(dx, dy);
2280
+ return;
2281
+ }
2282
+ if (event.shiftKey && key === 'h') {
2283
+ event.preventDefault();
2284
+ selectApiRef.current.apply('mirror-h', selectApiRef.current.allLayers);
2285
+ return;
2286
+ }
2287
+ if (event.shiftKey && key === 'v') {
2288
+ event.preventDefault();
2289
+ selectApiRef.current.apply('mirror-v', selectApiRef.current.allLayers);
2290
+ return;
2291
+ }
2292
+ if (event.shiftKey && key === 'f') {
2293
+ event.preventDefault();
2294
+ selectApiRef.current.apply('fill', selectApiRef.current.allLayers);
2295
+ return;
2296
+ }
2297
+ }
1596
2298
  if (key === 'b') setTool('pencil');
1597
2299
  else if (key === 'e') setTool('eraser');
1598
2300
  else if (key === 'g') setTool('bucket');
1599
2301
  else if (key === 'i') setTool('picker');
2302
+ else if (key === 'm') setTool('select');
1600
2303
  else if (key === 'f') fitToView();
1601
2304
  else if (key === 'h') {
1602
2305
  const next = !gridRef.current;
@@ -1943,6 +2646,9 @@ window.__ModuleLoader__.load({
1943
2646
  history.undo.push(snapshotOf(current));
1944
2647
  if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
1945
2648
  history.redo = [];
2649
+ // A selection in canvas coordinates would be meaningless after a resize.
2650
+ selectionRef.current = null;
2651
+ setSelection(null);
1946
2652
  const layers = current.layers.map((layer) => ({
1947
2653
  ...layer,
1948
2654
  rows: resizeRows(layer.rows, current.w, current.h, width, height, dialog.anchor),
@@ -1962,15 +2668,6 @@ window.__ModuleLoader__.load({
1962
2668
  /* palette actions */
1963
2669
  /* -------------------------------------------------------------- */
1964
2670
 
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
2671
  const updatePalette = useCallback((palette, options = {}) => {
1975
2672
  const current = docRef.current;
1976
2673
  if (!current) return;
@@ -2377,6 +3074,10 @@ window.__ModuleLoader__.load({
2377
3074
  : status === 'error' ? '保存失败'
2378
3075
  : '已保存';
2379
3076
 
3077
+ const selectionText = selection
3078
+ ? `选区 ${selection.w}×${selection.h} @ (${selection.x},${selection.y})${selection.float ? ' · 浮动' : ''}${allLayersMode ? ' · 全部图层' : ''}`
3079
+ : '';
3080
+
2380
3081
  const currentLayer = doc ? doc.layers[layerIndex] : null;
2381
3082
 
2382
3083
  const swatchNodes = doc ? doc.palette.map((color, index) => h('button', {
@@ -2525,6 +3226,7 @@ window.__ModuleLoader__.load({
2525
3226
  title: '删除当前作品',
2526
3227
  }, '删除'),
2527
3228
  h('span', { className: 'pxs-status', 'data-state': status }, statusText),
3229
+ selection ? h('span', { className: 'pxs-status pxs-select-status', title: '选区:拖动移框,Shift+拖动带内容,方向键微调,Esc 取消' }, selectionText) : null,
2528
3230
  notice ? h('span', { className: 'pxs-notice', title: notice }, notice) : null);
2529
3231
 
2530
3232
  const errorNode = error ? h('div', { className: 'pxs-error' },
@@ -2666,7 +3368,10 @@ window.__ModuleLoader__.load({
2666
3368
  h('div', null, h('kbd', null, 'G'), '填充 ', h('kbd', null, 'I'), '吸管'),
2667
3369
  h('div', null, h('kbd', null, '滚轮'), '缩放 ', h('kbd', null, '空格'), '+ 拖动'),
2668
3370
  h('div', null, h('kbd', null, 'F'), '适应窗口 ', h('kbd', null, '[ ]'), '笔刷'),
2669
- h('div', null, h('kbd', null, 'H'), '格线开/关 ', h('kbd', null, 'Ctrl+S'), '保存')));
3371
+ h('div', null, h('kbd', null, 'H'), '格线开/关 ', h('kbd', null, 'Ctrl+S'), '保存'),
3372
+ h('div', null, h('kbd', null, 'M'), '选区 ', h('kbd', null, 'Ctrl+A'), '全选'),
3373
+ h('div', null, h('kbd', null, 'Shift'), '+ 拖动', ' 移内容'),
3374
+ h('div', null, h('kbd', null, 'Del'), '删除 ', h('kbd', null, 'Esc'), '取消')));
2670
3375
 
2671
3376
  const leftPanel = h('div', { className: 'pxs-panel pxs-panel-left' },
2672
3377
  toolsSection, brushSection, layerSection, canvasSection, keysSection);
@@ -2678,7 +3383,10 @@ window.__ModuleLoader__.load({
2678
3383
  onPointerMove,
2679
3384
  onPointerUp,
2680
3385
  onPointerCancel: onPointerUp,
2681
- onContextMenu: (event) => event.preventDefault(),
3386
+ onContextMenu: (event) => {
3387
+ event.preventDefault();
3388
+ if (selectionRef.current) selectApiRef.current.cancel();
3389
+ },
2682
3390
  onDragStart: (event) => event.preventDefault(),
2683
3391
  });
2684
3392
 
@@ -2694,8 +3402,74 @@ window.__ModuleLoader__.load({
2694
3402
 
2695
3403
  const loadingNode = loading ? h('div', { className: 'pxs-empty' }, h('p', null, '加载作品中…')) : null;
2696
3404
 
3405
+ const selectionBar = selection ? h('div', { className: 'pxs-selection-bar' },
3406
+ h('div', { className: 'pxs-selection-row' },
3407
+ h('span', { className: 'pxs-selection-label' },
3408
+ `${selection.w}×${selection.h} @ ${selection.x},${selection.y}`),
3409
+ h('button', {
3410
+ className: 'pxs-btn',
3411
+ onClick: () => selectApiRef.current.cut(),
3412
+ title: '剪切选区内容到剪贴板 (Ctrl+X)',
3413
+ }, '✂ 剪切'),
3414
+ h('button', {
3415
+ className: 'pxs-btn',
3416
+ onClick: () => selectApiRef.current.copy(),
3417
+ title: '复制选区内容到剪贴板 (Ctrl+C)',
3418
+ }, '⧉ 复制'),
3419
+ h('button', {
3420
+ className: 'pxs-btn',
3421
+ onClick: () => selectApiRef.current.paste(),
3422
+ title: '把剪贴板内容粘贴到选区左上角 (Ctrl+V)',
3423
+ }, '⇩ 粘贴'),
3424
+ h('span', { className: 'pxs-selection-divider' }),
3425
+ h('button', {
3426
+ className: 'pxs-btn',
3427
+ onClick: () => selectApiRef.current.fill(),
3428
+ title: '用当前颜色填充选区 (Shift+F)',
3429
+ }, '▣ 填充'),
3430
+ h('button', {
3431
+ className: 'pxs-btn',
3432
+ onClick: () => selectApiRef.current.apply('delete', allLayersRef.current),
3433
+ title: '清空选区内容 (Delete)',
3434
+ }, '⌫ 清除'),
3435
+ h('button', {
3436
+ className: 'pxs-btn',
3437
+ onClick: () => selectApiRef.current.mirror(true),
3438
+ title: '选区内容水平镜像 (Shift+H)',
3439
+ }, '⇆ 镜像'),
3440
+ h('button', {
3441
+ className: 'pxs-btn',
3442
+ onClick: () => selectApiRef.current.mirror(false),
3443
+ title: '选区内容垂直镜像 (Shift+V)',
3444
+ }, '⇅ 镜像'),
3445
+ h('span', { className: 'pxs-selection-divider' }),
3446
+ h('button', {
3447
+ className: 'pxs-btn',
3448
+ 'data-active': grabMode,
3449
+ title: '开启后,拖动选区会搬走像素(等同 Shift+拖动)',
3450
+ onClick: () => setGrabMode((value) => !value),
3451
+ }, grabMode ? '✓ 搬内容' : '搬内容'),
3452
+ h('button', {
3453
+ className: 'pxs-btn',
3454
+ 'data-active': allLayersMode,
3455
+ title: '填充 / 清除 / 镜像作用于所有可见图层,而不只是当前图层',
3456
+ onClick: () => setAllLayersMode((value) => !value),
3457
+ }, allLayersMode ? '✓ 多层' : '多层'),
3458
+ h('button', {
3459
+ className: 'pxs-btn pxs-btn-primary',
3460
+ onClick: () => selectApiRef.current.finish(),
3461
+ title: '把浮动的内容落到当前位置并结束选区',
3462
+ }, '✓ 完成'),
3463
+ h('button', {
3464
+ className: 'pxs-btn',
3465
+ onClick: () => selectApiRef.current.cancel(),
3466
+ title: '放弃这次选区;浮动的内容回到原处 (Esc)',
3467
+ }, '✕ 取消')),
3468
+ h('div', { className: 'pxs-selection-hint' },
3469
+ '拖动 = 移动选框 · Shift + 拖动 = 搬走像素 · 剪切 = 内容留在画布上跟随选框 · 方向键 = 微调(Shift 10px) · Esc = 取消还原')) : null;
3470
+
2697
3471
  const stageNode = h('div', { className: 'pxs-stage', ref: stageRef },
2698
- canvasNode, emptyNode, loadingNode);
3472
+ canvasNode, emptyNode, loadingNode, selectionBar);
2699
3473
 
2700
3474
  const colorSection = h('div', { className: 'pxs-section' },
2701
3475
  h('p', { className: 'pxs-title' }, '当前颜色'),
@@ -3148,7 +3922,7 @@ window.__ModuleLoader__.load({
3148
3922
  onClick: confirmResize,
3149
3923
  }, '应用')))) : null;
3150
3924
 
3151
- return h('div', { className: 'pxs-root' },
3925
+ return h('div', { className: 'pxs-root', ref: rootRef, style: { '--pxs-on-brand': brandInk } },
3152
3926
  h('style', null, CSS),
3153
3927
  barNode,
3154
3928
  errorNode,
@@ -3235,6 +4009,23 @@ window.__ModuleLoader__.load({
3235
4009
  }
3236
4010
  }
3237
4011
 
4012
+ // Test hook: tools/select-test.cjs drives the pure selection helpers.
4013
+ if (window.__PXS_TEST__) {
4014
+ window.__PXS_TEST__.selection = {
4015
+ selectionBetween,
4016
+ clampRectangle,
4017
+ moveRectangle,
4018
+ pointInRectangle,
4019
+ readRegion,
4020
+ writeRegion,
4021
+ blankRegion,
4022
+ fillRows,
4023
+ emptyRows,
4024
+ mirrorRows,
4025
+ mergedRegion,
4026
+ };
4027
+ }
4028
+
3238
4029
  return {
3239
4030
  inject: ['slots'],
3240
4031
  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.1",
4
4
  "description": "在 DSH 里画像素画:三栏编辑器(左工具 / 中画布 / 右调色板),滚轮缩放,图层、调色板、导出 PNG 都在会话页签里。",
5
5
  "type": "module",
6
6
  "main": "./index.js",