@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 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-wrap: wrap; align-items: center; gap: 6px;
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, selection]);
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
- region: mergedRegion(current.layers, rect),
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 restored = putBackLifted(current, active);
1736
- docRef.current = restored;
1737
- setDoc(restored);
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
- pushHistory();
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
- }, [putBackLifted, pushHistory, scheduleSave, selectionTargets, setSelectionRect]);
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(event.altKey))) {
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) 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
+ }
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('span', { className: 'pxs-selection-label' },
3173
- `${selection.w}×${selection.h} @ ${selection.x},${selection.y}`),
3174
- h('button', {
3175
- className: 'pxs-btn',
3176
- onClick: () => selectApiRef.current.apply('mirror-h', allLayersRef.current),
3177
- title: '水平镜像 (Shift+H)',
3178
- }, '⇆ 水平镜像'),
3179
- h('button', {
3180
- className: 'pxs-btn',
3181
- onClick: () => selectApiRef.current.apply('mirror-v', allLayersRef.current),
3182
- title: '垂直镜像 (Shift+V)',
3183
- }, '⇅ 垂直镜像'),
3184
- h('button', {
3185
- className: 'pxs-btn',
3186
- onClick: () => selectApiRef.current.apply('fill', allLayersRef.current),
3187
- title: '用当前颜色填充 (Shift+F)',
3188
- }, '▣ 填充'),
3189
- h('button', {
3190
- className: 'pxs-btn',
3191
- onClick: () => selectApiRef.current.apply('delete', allLayersRef.current),
3192
- title: '删除选区内容 (Delete)',
3193
- }, '⌫ 删除'),
3194
- h('button', {
3195
- className: 'pxs-btn',
3196
- 'data-active': grabMode,
3197
- title: '抓着像素拖动(也可以 Shift+拖动)',
3198
- onClick: () => setGrabMode((value) => !value),
3199
- }, grabMode ? '✱ 抓着:开' : '✱ 抓着像素'),
3200
- h('button', {
3201
- className: 'pxs-btn',
3202
- 'data-active': allLayersMode,
3203
- title: '镜像 / 填充 / 删除作用于所有可见图层(也可以按住 Alt)',
3204
- onClick: () => setAllLayersMode((value) => !value),
3205
- }, allLayersMode ? '多层:开' : '多层'),
3206
- h('button', {
3207
- className: 'pxs-btn',
3208
- onClick: () => selectApiRef.current.cancel(),
3209
- title: '取消选区 (Esc)',
3210
- }, '✕')) : null;
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muxiuesd/dsh-pixel-studio",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "在 DSH 里画像素画:三栏编辑器(左工具 / 中画布 / 右调色板),滚轮缩放,图层、调色板、导出 PNG 都在会话页签里。",
5
5
  "type": "module",
6
6
  "main": "./index.js",