@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 +16 -0
- package/README.md +8 -0
- package/client.js +806 -15
- package/package.json +1 -1
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)
|
|
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) =>
|
|
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) {
|