@muxiuesd/dsh-pixel-studio 0.3.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 +9 -0
- package/README.md +2 -1
- package/client.js +315 -56
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
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
|
+
|
|
3
12
|
## 0.3.0
|
|
4
13
|
|
|
5
14
|
- 新增矩形选区(工具 `M`):在画布上拖出选框,拖动框内只移动选框,`Shift + 拖动`(或工具条「抓着像素」)带着像素一起移动,方向键微调 1 像素(`Shift` 10 像素),`Ctrl+A` 全选,`Esc` 取消。
|
package/README.md
CHANGED
|
@@ -35,8 +35,9 @@ dsh plugin --profile web add /绝对路径/dsh-pixel
|
|
|
35
35
|
| `B` / `E` / `G` / `I` | 铅笔 / 橡皮 / 油漆桶 / 吸管 |
|
|
36
36
|
| `M` | 矩形选区 |
|
|
37
37
|
| `Ctrl/Cmd+A` | 全选选区 |
|
|
38
|
-
| `Esc` |
|
|
38
|
+
| `Esc` | 取消选区(浮动的内容回到原处) |
|
|
39
39
|
| `Delete` / `Backspace` | 删除选区内像素 |
|
|
40
|
+
| `Ctrl/Cmd+C` / `Ctrl/Cmd+X` / `Ctrl/Cmd+V` | 复制 / 剪切(内容留在画布上) / 粘贴 |
|
|
40
41
|
| `Shift + H` / `Shift + V` / `Shift + F` | 选区水平镜像 / 垂直镜像 / 填充 |
|
|
41
42
|
| 方向键(配合选区) | 微调选区 1 像素,`Shift` 为 10 像素 |
|
|
42
43
|
| `[` `]` | 笔刷变小 / 变大 |
|
package/client.js
CHANGED
|
@@ -511,6 +511,69 @@ window.__ModuleLoader__.load({
|
|
|
511
511
|
return region;
|
|
512
512
|
}
|
|
513
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
|
+
|
|
514
577
|
/* ------------------------------------------------------------------ */
|
|
515
578
|
/* image import */
|
|
516
579
|
/* ------------------------------------------------------------------ */
|
|
@@ -990,14 +1053,25 @@ window.__ModuleLoader__.load({
|
|
|
990
1053
|
}
|
|
991
1054
|
.pxs-selection-bar {
|
|
992
1055
|
position: absolute; left: 10px; right: 10px; bottom: 10px; z-index: 3;
|
|
993
|
-
display: flex; flex-
|
|
1056
|
+
display: flex; flex-direction: column; gap: 5px;
|
|
994
1057
|
padding: 6px 8px; border-radius: 9px;
|
|
995
1058
|
background: var(--dsw-alias-bg-overlay, rgba(38, 42, 51, .9));
|
|
996
1059
|
border: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .2));
|
|
997
1060
|
box-shadow: 0 6px 20px rgba(0, 0, 0, .28);
|
|
998
1061
|
backdrop-filter: blur(6px);
|
|
999
1062
|
}
|
|
1063
|
+
.pxs-selection-row {
|
|
1064
|
+
display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
|
|
1065
|
+
}
|
|
1000
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
|
+
}
|
|
1001
1075
|
.pxs-selection-label {
|
|
1002
1076
|
font-size: 11px; margin-right: auto; color: var(--dsw-alias-label-secondary, #9aa1ad);
|
|
1003
1077
|
font-variant-numeric: tabular-nums; white-space: nowrap;
|
|
@@ -1049,7 +1123,7 @@ window.__ModuleLoader__.load({
|
|
|
1049
1123
|
.pxs-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
1050
1124
|
.pxs-btn-primary {
|
|
1051
1125
|
background: var(--dsw-alias-brand-primary, #4b7bec); border-color: transparent;
|
|
1052
|
-
color: #fff;
|
|
1126
|
+
color: var(--pxs-on-brand, #fff);
|
|
1053
1127
|
}
|
|
1054
1128
|
.pxs-native-color {
|
|
1055
1129
|
width: 40px; height: 28px; padding: 0; border: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .18));
|
|
@@ -1142,6 +1216,12 @@ window.__ModuleLoader__.load({
|
|
|
1142
1216
|
/* editor */
|
|
1143
1217
|
/* ------------------------------------------------------------------ */
|
|
1144
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
|
+
|
|
1145
1225
|
function PixelStudio(props) {
|
|
1146
1226
|
const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : '';
|
|
1147
1227
|
const [docs, setDocs] = useState([]);
|
|
@@ -1170,6 +1250,22 @@ window.__ModuleLoader__.load({
|
|
|
1170
1250
|
const [selection, setSelection] = useState(null);
|
|
1171
1251
|
const [grabMode, setGrabMode] = useState(false);
|
|
1172
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
|
+
}, []);
|
|
1173
1269
|
|
|
1174
1270
|
const stageRef = useRef(null);
|
|
1175
1271
|
const canvasRef = useRef(null);
|
|
@@ -1275,6 +1371,10 @@ window.__ModuleLoader__.load({
|
|
|
1275
1371
|
|
|
1276
1372
|
const paint = useCallback(() => {
|
|
1277
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;
|
|
1278
1378
|
const canvas = canvasRef.current;
|
|
1279
1379
|
const stage = stageRef.current;
|
|
1280
1380
|
const current = docRef.current;
|
|
@@ -1389,7 +1489,7 @@ window.__ModuleLoader__.load({
|
|
|
1389
1489
|
context.strokeStyle = 'rgba(255,255,255,0.22)';
|
|
1390
1490
|
context.lineWidth = 1;
|
|
1391
1491
|
context.strokeRect(Math.round(pan.x) + 0.5, Math.round(pan.y) + 0.5, Math.round(docWidth), Math.round(docHeight));
|
|
1392
|
-
}, [composeImage
|
|
1492
|
+
}, [composeImage]);
|
|
1393
1493
|
|
|
1394
1494
|
const requestPaint = useCallback(() => {
|
|
1395
1495
|
if (frameRef.current !== 0) return;
|
|
@@ -1433,6 +1533,8 @@ window.__ModuleLoader__.load({
|
|
|
1433
1533
|
|
|
1434
1534
|
const openDoc = useCallback(async (id) => {
|
|
1435
1535
|
const data = await callApi({ op: 'get', id });
|
|
1536
|
+
const previous = docRef.current;
|
|
1537
|
+
const switched = !previous || previous.id !== data.doc.id;
|
|
1436
1538
|
setDoc(data.doc);
|
|
1437
1539
|
docRef.current = data.doc;
|
|
1438
1540
|
setLayerIndex(0);
|
|
@@ -1440,6 +1542,12 @@ window.__ModuleLoader__.load({
|
|
|
1440
1542
|
setEditing(null);
|
|
1441
1543
|
editingRef.current = null;
|
|
1442
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
|
+
}
|
|
1443
1551
|
setStatus('clean');
|
|
1444
1552
|
window.requestAnimationFrame(() => fitToView());
|
|
1445
1553
|
}, [fitToView]);
|
|
@@ -1544,6 +1652,8 @@ window.__ModuleLoader__.load({
|
|
|
1544
1652
|
if (!snapshot) return;
|
|
1545
1653
|
const current = docRef.current;
|
|
1546
1654
|
history.redo.push(snapshotOf(current));
|
|
1655
|
+
selectionRef.current = null;
|
|
1656
|
+
setSelection(null);
|
|
1547
1657
|
applyHistory(snapshot);
|
|
1548
1658
|
}, [applyHistory]);
|
|
1549
1659
|
|
|
@@ -1553,6 +1663,8 @@ window.__ModuleLoader__.load({
|
|
|
1553
1663
|
if (!snapshot) return;
|
|
1554
1664
|
const current = docRef.current;
|
|
1555
1665
|
history.undo.push(snapshotOf(current));
|
|
1666
|
+
selectionRef.current = null;
|
|
1667
|
+
setSelection(null);
|
|
1556
1668
|
applyHistory(snapshot);
|
|
1557
1669
|
}, [applyHistory]);
|
|
1558
1670
|
|
|
@@ -1640,14 +1752,14 @@ window.__ModuleLoader__.load({
|
|
|
1640
1752
|
const setSelectionRect = useCallback((rect) => {
|
|
1641
1753
|
selectionRef.current = rect;
|
|
1642
1754
|
setSelection(rect);
|
|
1643
|
-
|
|
1755
|
+
requestPaint();
|
|
1756
|
+
}, [requestPaint]);
|
|
1644
1757
|
|
|
1645
1758
|
const selectAll = useCallback(() => {
|
|
1646
1759
|
const current = docRef.current;
|
|
1647
1760
|
if (!current) return;
|
|
1648
1761
|
setTool('select');
|
|
1649
1762
|
setSelectionRect({ x: 0, y: 0, w: current.w, h: current.h, float: null });
|
|
1650
|
-
setStatus('select');
|
|
1651
1763
|
requestPaint();
|
|
1652
1764
|
}, [requestPaint, setSelectionRect]);
|
|
1653
1765
|
|
|
@@ -1699,7 +1811,9 @@ window.__ModuleLoader__.load({
|
|
|
1699
1811
|
setSelectionRect({
|
|
1700
1812
|
...rect,
|
|
1701
1813
|
float: {
|
|
1702
|
-
|
|
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)),
|
|
1703
1817
|
origin: { x: rect.x, y: rect.y },
|
|
1704
1818
|
targets: items,
|
|
1705
1819
|
},
|
|
@@ -1731,16 +1845,32 @@ window.__ModuleLoader__.load({
|
|
|
1731
1845
|
const current = docRef.current;
|
|
1732
1846
|
const active = selectionRef.current;
|
|
1733
1847
|
if (!current || !active) return;
|
|
1848
|
+
let base = current;
|
|
1734
1849
|
if (active.float) {
|
|
1735
|
-
const
|
|
1736
|
-
|
|
1737
|
-
|
|
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);
|
|
1738
1868
|
}
|
|
1739
|
-
const base = docRef.current;
|
|
1740
1869
|
const rect = clampRectangle(active, base.w, base.h);
|
|
1741
1870
|
const targets = selectionTargets(allLayers);
|
|
1742
1871
|
if (targets.length === 0) return;
|
|
1743
|
-
|
|
1872
|
+
// A committed float already pushed its own undo entry during the lift.
|
|
1873
|
+
if (!active.float) pushHistory();
|
|
1744
1874
|
const layers = base.layers.map((layer, index) => {
|
|
1745
1875
|
const target = targets.find((entry) => entry.index === index);
|
|
1746
1876
|
if (!target) return layer;
|
|
@@ -1757,7 +1887,7 @@ window.__ModuleLoader__.load({
|
|
|
1757
1887
|
setSelectionRect({ ...rect, float: null });
|
|
1758
1888
|
setStatus('dirty');
|
|
1759
1889
|
scheduleSave();
|
|
1760
|
-
}, [
|
|
1890
|
+
}, [pushHistory, scheduleSave, selectionTargets, setSelectionRect]);
|
|
1761
1891
|
|
|
1762
1892
|
/** Arrow-key nudging: 1 cell, 10 with Shift; carries lifted pixels along. */
|
|
1763
1893
|
const nudgeSelection = useCallback((dx, dy) => {
|
|
@@ -1766,9 +1896,87 @@ window.__ModuleLoader__.load({
|
|
|
1766
1896
|
if (!current || !active) return;
|
|
1767
1897
|
const rect = moveRectangle(active, active.x + dx, active.y + dy, current.w, current.h);
|
|
1768
1898
|
setSelectionRect({ ...rect, float: active.float });
|
|
1769
|
-
setStatus('select');
|
|
1770
1899
|
}, [setSelectionRect]);
|
|
1771
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
|
+
|
|
1772
1980
|
// Exposed to the keyboard effect, which is registered before these callbacks exist.
|
|
1773
1981
|
selectApiRef.current = {
|
|
1774
1982
|
selectAll,
|
|
@@ -1777,6 +1985,12 @@ window.__ModuleLoader__.load({
|
|
|
1777
1985
|
nudge: nudgeSelection,
|
|
1778
1986
|
drop: dropSelection,
|
|
1779
1987
|
lift: liftSelection,
|
|
1988
|
+
copy: copySelection,
|
|
1989
|
+
cut: cutSelection,
|
|
1990
|
+
paste: pasteClipboard,
|
|
1991
|
+
mirror: mirrorSelection,
|
|
1992
|
+
fill: fillSelection,
|
|
1993
|
+
finish: finishSelection,
|
|
1780
1994
|
allLayers: allLayersRef.current,
|
|
1781
1995
|
};
|
|
1782
1996
|
|
|
@@ -1810,7 +2024,7 @@ window.__ModuleLoader__.load({
|
|
|
1810
2024
|
const inside = active ? pointInRectangle(active, cell) : false;
|
|
1811
2025
|
const carry = event.shiftKey || grabRef.current;
|
|
1812
2026
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
1813
|
-
if (inside && carry && (active.float || liftSelection(
|
|
2027
|
+
if (inside && carry && (active.float || liftSelection(allLayersRef.current))) {
|
|
1814
2028
|
selectDragRef.current = {
|
|
1815
2029
|
mode: 'grab',
|
|
1816
2030
|
pointerId: event.pointerId,
|
|
@@ -1836,7 +2050,6 @@ window.__ModuleLoader__.load({
|
|
|
1836
2050
|
start: anchor,
|
|
1837
2051
|
};
|
|
1838
2052
|
}
|
|
1839
|
-
setStatus('select');
|
|
1840
2053
|
requestPaint();
|
|
1841
2054
|
return;
|
|
1842
2055
|
}
|
|
@@ -1953,6 +2166,9 @@ window.__ModuleLoader__.load({
|
|
|
1953
2166
|
const onWheel = (event) => {
|
|
1954
2167
|
if (!docRef.current) return;
|
|
1955
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;
|
|
1956
2172
|
if (event.shiftKey) {
|
|
1957
2173
|
panRef.current = {
|
|
1958
2174
|
x: panRef.current.x - event.deltaY,
|
|
@@ -2028,7 +2244,22 @@ window.__ModuleLoader__.load({
|
|
|
2028
2244
|
void saveNow();
|
|
2029
2245
|
return;
|
|
2030
2246
|
}
|
|
2031
|
-
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
|
+
}
|
|
2032
2263
|
const key = event.key.toLowerCase();
|
|
2033
2264
|
if (key === 'escape') {
|
|
2034
2265
|
selectApiRef.current.cancel();
|
|
@@ -2415,6 +2646,9 @@ window.__ModuleLoader__.load({
|
|
|
2415
2646
|
history.undo.push(snapshotOf(current));
|
|
2416
2647
|
if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
|
|
2417
2648
|
history.redo = [];
|
|
2649
|
+
// A selection in canvas coordinates would be meaningless after a resize.
|
|
2650
|
+
selectionRef.current = null;
|
|
2651
|
+
setSelection(null);
|
|
2418
2652
|
const layers = current.layers.map((layer) => ({
|
|
2419
2653
|
...layer,
|
|
2420
2654
|
rows: resizeRows(layer.rows, current.w, current.h, width, height, dialog.anchor),
|
|
@@ -3169,45 +3403,70 @@ window.__ModuleLoader__.load({
|
|
|
3169
3403
|
const loadingNode = loading ? h('div', { className: 'pxs-empty' }, h('p', null, '加载作品中…')) : null;
|
|
3170
3404
|
|
|
3171
3405
|
const selectionBar = selection ? h('div', { className: 'pxs-selection-bar' },
|
|
3172
|
-
h('
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
className: 'pxs-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
'
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
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;
|
|
3211
3470
|
|
|
3212
3471
|
const stageNode = h('div', { className: 'pxs-stage', ref: stageRef },
|
|
3213
3472
|
canvasNode, emptyNode, loadingNode, selectionBar);
|
|
@@ -3663,7 +3922,7 @@ window.__ModuleLoader__.load({
|
|
|
3663
3922
|
onClick: confirmResize,
|
|
3664
3923
|
}, '应用')))) : null;
|
|
3665
3924
|
|
|
3666
|
-
return h('div', { className: 'pxs-root' },
|
|
3925
|
+
return h('div', { className: 'pxs-root', ref: rootRef, style: { '--pxs-on-brand': brandInk } },
|
|
3667
3926
|
h('style', null, CSS),
|
|
3668
3927
|
barNode,
|
|
3669
3928
|
errorNode,
|