@muxiuesd/dsh-pixel-studio 0.0.0-stage → 0.1.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/client.js ADDED
@@ -0,0 +1,2590 @@
1
+ /* global window, document, fetch */
2
+ /**
3
+ * Client half of dsh-pixel-studio: a three-column pixel editor registered as a
4
+ * Conversation view. Tools on the left, zoomable canvas in the middle, palette on
5
+ * the right. Documents live in the Host half, reached over the plugin's own
6
+ * same-origin HTTP route.
7
+ */
8
+ window.__ModuleLoader__.load({
9
+ id: '@muxiuesd/dsh-pixel-studio',
10
+ factory(require) {
11
+ const React = require('react');
12
+ const h = React.createElement;
13
+ const { useState, useEffect, useRef, useCallback } = React;
14
+
15
+ const API = '/dsh-pixel-studio';
16
+ const ZOOMS = [1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64];
17
+ const CHARS = '0123456789abcdefghijklmnopqrstuvwxyz';
18
+ const HISTORY_LIMIT = 60;
19
+ const MAX_LAYERS = 8;
20
+
21
+ const PRESETS = [
22
+ {
23
+ id: 'pico8',
24
+ name: 'PICO-8',
25
+ colors: [
26
+ '#000000', '#1d2b53', '#7e2553', '#008751',
27
+ '#ab5236', '#5f574f', '#c2c3c7', '#fff1e8',
28
+ '#ff004d', '#ffa300', '#ffec27', '#00e436',
29
+ '#29adff', '#83769c', '#ff77a8', '#ffccaa',
30
+ ],
31
+ },
32
+ {
33
+ id: 'gameboy',
34
+ name: 'Game Boy',
35
+ colors: ['#0f380f', '#306230', '#8bac0f', '#9bbc0f'],
36
+ },
37
+ {
38
+ id: 'icecream',
39
+ name: 'Ice Cream',
40
+ colors: [
41
+ '#7c3f58', '#eb6b6f', '#f9a875', '#fff6d3',
42
+ '#d6d6d6', '#8b8b8b', '#3d3d3d', '#ffffff',
43
+ ],
44
+ },
45
+ {
46
+ id: 'mono',
47
+ name: '黑白',
48
+ colors: ['#000000', '#555555', '#aaaaaa', '#ffffff'],
49
+ },
50
+ ];
51
+
52
+ /** Sizes pixel artists actually reach for, plus room for anything custom. */
53
+ const NEW_PRESETS = [
54
+ { w: 8, h: 8, label: '8×8', hint: '图标' },
55
+ { w: 16, h: 16, label: '16×16', hint: '精灵' },
56
+ { w: 24, h: 24, label: '24×24', hint: '角色' },
57
+ { w: 32, h: 32, label: '32×32', hint: '常用' },
58
+ { w: 48, h: 48, label: '48×48', hint: '场景' },
59
+ { w: 64, h: 64, label: '64×64', hint: '场景' },
60
+ { w: 128, h: 128, label: '128×128', hint: '大图' },
61
+ { w: 160, h: 144, label: '160×144', hint: 'GB' },
62
+ { w: 16, h: 32, label: '16×32', hint: '竖版' },
63
+ { w: 32, h: 16, label: '32×16', hint: '横版' },
64
+ { w: 64, h: 32, label: '64×32', hint: '横幅' },
65
+ ];
66
+ /** Keep in sync with MAX_SIDE in index.js. */
67
+ const MAX_SIDE = 256;
68
+
69
+ const TOOLS = [
70
+ { id: 'pencil', label: '铅笔', key: 'B', icon: 'pencil' },
71
+ { id: 'eraser', label: '橡皮', key: 'E', icon: 'eraser' },
72
+ { id: 'bucket', label: '油漆桶', key: 'G', icon: 'bucket' },
73
+ { id: 'picker', label: '吸管', key: 'I', icon: 'picker' },
74
+ ];
75
+
76
+ /* ------------------------------------------------------------------ */
77
+ /* transport */
78
+ /* ------------------------------------------------------------------ */
79
+
80
+ async function callApi(payload) {
81
+ const response = await fetch(API, {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json', 'x-pixel-studio': '1' },
84
+ body: JSON.stringify(payload),
85
+ });
86
+ let data = null;
87
+ try {
88
+ data = await response.json();
89
+ } catch {
90
+ throw new Error('响应无法解析');
91
+ }
92
+ if (!data || data.ok !== true) {
93
+ const error = new Error((data && data.error) || `请求失败(${response.status})`);
94
+ error.code = data && data.error;
95
+ throw error;
96
+ }
97
+ return data;
98
+ }
99
+
100
+ /* ------------------------------------------------------------------ */
101
+ /* grid helpers */
102
+ /* ------------------------------------------------------------------ */
103
+
104
+ function blankRows(width, height) {
105
+ return Array.from({ length: height }, () => '.'.repeat(width));
106
+ }
107
+
108
+ function charFor(index) {
109
+ return index >= 0 && index < CHARS.length ? CHARS[index] : '.';
110
+ }
111
+
112
+ function indexFor(ch) {
113
+ const position = CHARS.indexOf(ch);
114
+ return position;
115
+ }
116
+
117
+ function setPixel(rows, x, y, ch) {
118
+ const row = rows[y];
119
+ if (row === undefined || x < 0 || x >= row.length) return rows;
120
+ if (row[x] === ch) return rows;
121
+ const next = rows.slice();
122
+ next[y] = row.slice(0, x) + ch + row.slice(x + 1);
123
+ return next;
124
+ }
125
+
126
+ /** Composited color of one cell: the topmost visible palette entry, '' if empty. */
127
+ function colorAt(doc, x, y) {
128
+ for (let l = doc.layers.length - 1; l >= 0; l -= 1) {
129
+ const layer = doc.layers[l];
130
+ if (layer.visible === false) continue;
131
+ const row = layer.rows[y];
132
+ const ch = row === undefined ? '.' : row[x];
133
+ const index = ch === undefined ? -1 : indexFor(ch);
134
+ if (index >= 0) return doc.palette[index] || '';
135
+ }
136
+ return '';
137
+ }
138
+
139
+ /**
140
+ * Fills the region that looks contiguous *after compositing*: cells sharing one
141
+ * visible color are covered even when they come from another palette entry or
142
+ * from a lower layer. Returns null when the fill would change nothing.
143
+ */
144
+ function fillRegion(doc, layerIndex, startX, startY, replacement) {
145
+ const layer = doc.layers[layerIndex];
146
+ if (!layer) return null;
147
+ const width = doc.w;
148
+ const height = doc.h;
149
+ if (startX < 0 || startY < 0 || startX >= width || startY >= height) return null;
150
+ const replacementColor = replacement === '.' ? '' : (doc.palette[indexFor(replacement)] || '');
151
+ const target = colorAt(doc, startX, startY);
152
+ if (target === replacementColor) return null;
153
+ const seen = new Uint8Array(width * height);
154
+ const grid = layer.rows.map((row) => row.split(''));
155
+ const stack = [[startX, startY]];
156
+ let changed = false;
157
+ while (stack.length > 0) {
158
+ const [x, y] = stack.pop();
159
+ if (x < 0 || y < 0 || x >= width || y >= height) continue;
160
+ const offset = y * width + x;
161
+ if (seen[offset] === 1) continue;
162
+ if (colorAt(doc, x, y) !== target) continue;
163
+ seen[offset] = 1;
164
+ if (grid[y] !== undefined && grid[y][x] !== replacement) {
165
+ grid[y][x] = replacement;
166
+ changed = true;
167
+ }
168
+ stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
169
+ }
170
+ if (!changed) return null;
171
+ return grid.map((row) => row.join(''));
172
+ }
173
+
174
+ function lineCells(x0, y0, x1, y1) {
175
+ const cells = [];
176
+ let dx = Math.abs(x1 - x0);
177
+ let dy = Math.abs(y1 - y0);
178
+ const sx = x0 < x1 ? 1 : -1;
179
+ const sy = y0 < y1 ? 1 : -1;
180
+ let err = dx - dy;
181
+ let x = x0;
182
+ let y = y0;
183
+ for (let guard = 0; guard < 4096; guard += 1) {
184
+ cells.push([x, y]);
185
+ if (x === x1 && y === y1) break;
186
+ const e2 = err * 2;
187
+ if (e2 > -dy) {
188
+ err -= dy;
189
+ x += sx;
190
+ }
191
+ if (e2 < dx) {
192
+ err += dx;
193
+ y += sy;
194
+ }
195
+ }
196
+ return cells;
197
+ }
198
+
199
+ function hexToRgb(hex) {
200
+ return [
201
+ parseInt(hex.slice(1, 3), 16),
202
+ parseInt(hex.slice(3, 5), 16),
203
+ parseInt(hex.slice(5, 7), 16),
204
+ ];
205
+ }
206
+
207
+ function readableText(hex) {
208
+ const [r, g, b] = hexToRgb(hex.toLowerCase());
209
+ return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? '#111111' : '#ffffff';
210
+ }
211
+
212
+ /**
213
+ * Picks a swatch color that is not already on the palette, so "add color"
214
+ * always produces a visibly new slot. Duplicated slots are legal (two
215
+ * indices may share one color), they are just confusing as a default.
216
+ */
217
+ function nextFreeColor(palette) {
218
+ const used = palette.map((color) => String(color).toLowerCase());
219
+ const candidates = [
220
+ '#ffffff', '#000000', '#ff004d', '#00e436', '#29adff', '#ffec27',
221
+ '#ff77a8', '#83769c', '#ab5236', '#1d2b53', '#c2c3c7', '#7e2553',
222
+ ];
223
+ for (const candidate of candidates) {
224
+ if (used.indexOf(candidate) === -1) return candidate;
225
+ }
226
+ for (let step = 0; step < 64; step += 1) {
227
+ const level = (step * 37) % 256;
228
+ const hex = `#${level.toString(16).padStart(2, '0').repeat(3)}`;
229
+ if (used.indexOf(hex) === -1) return hex;
230
+ }
231
+ return '#ffffff';
232
+ }
233
+
234
+ /** Does any layer still reference this palette slot? */
235
+ function slotInUse(doc, index) {
236
+ if (!doc || index < 0) return false;
237
+ const ch = charFor(index);
238
+ if (!ch) return false;
239
+ for (const layer of doc.layers) {
240
+ for (const row of layer.rows) {
241
+ if (row.indexOf(ch) !== -1) return true;
242
+ }
243
+ }
244
+ return false;
245
+ }
246
+
247
+ /** Rewrite every pixel of slot `from` so it points at slot `to`. */
248
+ function remapSlot(layers, from, to) {
249
+ const previous = charFor(from);
250
+ const next = charFor(to);
251
+ if (!previous || !next) return layers;
252
+ return layers.map((layer) => ({
253
+ ...layer,
254
+ rows: layer.rows.map((row) => row.split('').map((ch) => (ch === previous ? next : ch)).join('')),
255
+ }));
256
+ }
257
+
258
+ /**
259
+ * Palette editing must never repaint the artwork. Before a slot's colour
260
+ * changes we move the pixels that already use it onto a different slot
261
+ * that takes over the old colour, so the canvas stays pixel-identical and
262
+ * the edited slot is free to become the next drawing colour.
263
+ */
264
+ function detachSlot(doc, index) {
265
+ const oldColor = doc.palette[index];
266
+ if (oldColor === undefined) return { doc, target: -1, created: false };
267
+ if (!slotInUse(doc, index)) return { doc, target: -1, created: false };
268
+ let target = -1;
269
+ for (let slot = 0; slot < doc.palette.length; slot += 1) {
270
+ if (slot === index) continue;
271
+ if (!slotInUse(doc, slot)) {
272
+ target = slot;
273
+ break;
274
+ }
275
+ }
276
+ let palette = doc.palette.slice();
277
+ let created = false;
278
+ if (target === -1) {
279
+ if (palette.length >= CHARS.length) return null;
280
+ palette = palette.concat(oldColor);
281
+ target = palette.length - 1;
282
+ created = true;
283
+ } else if (palette[target].toLowerCase() !== oldColor.toLowerCase()) {
284
+ palette[target] = oldColor;
285
+ }
286
+ const layers = remapSlot(doc.layers, index, target);
287
+ return { doc: { ...doc, palette, layers }, target, created };
288
+ }
289
+
290
+ function clamp(value, min, max) {
291
+ return value < min ? min : (value > max ? max : value);
292
+ }
293
+
294
+ function readStored(key, fallback) {
295
+ try {
296
+ const value = window.localStorage.getItem(key);
297
+ return value === null ? fallback : value;
298
+ } catch {
299
+ return fallback;
300
+ }
301
+ }
302
+
303
+ function readStoredNumber(key, fallback) {
304
+ const value = Number(readStored(key, ''));
305
+ return Number.isFinite(value) && value > 0 ? value : fallback;
306
+ }
307
+
308
+ function storeValue(key, value) {
309
+ try {
310
+ window.localStorage.setItem(key, String(value));
311
+ } catch {
312
+ /* private mode: widths simply stay session-local */
313
+ }
314
+ }
315
+
316
+ /* ------------------------------------------------------------------ */
317
+ /* save-dialog path helpers */
318
+ /* ------------------------------------------------------------------ */
319
+
320
+ function pathSeparator(value) {
321
+ return typeof value === 'string' && value.includes('\\') ? '\\' : '/';
322
+ }
323
+
324
+ function joinPath(directory, name) {
325
+ const dir = typeof directory === 'string' ? directory : '';
326
+ const sep = pathSeparator(dir);
327
+ if (dir === '') return name;
328
+ return dir.endsWith(sep) ? `${dir}${name}` : `${dir}${sep}${name}`;
329
+ }
330
+
331
+ function baseNameOf(filePath) {
332
+ if (typeof filePath !== 'string') return '';
333
+ const cut = Math.max(filePath.lastIndexOf('\\'), filePath.lastIndexOf('/'));
334
+ return cut >= 0 ? filePath.slice(cut + 1) : filePath;
335
+ }
336
+
337
+ function dirNameOf(filePath) {
338
+ if (typeof filePath !== 'string') return '';
339
+ const cut = Math.max(filePath.lastIndexOf('\\'), filePath.lastIndexOf('/'));
340
+ return cut > 0 ? filePath.slice(0, cut) : '';
341
+ }
342
+
343
+ function ensurePngName(value) {
344
+ const trimmed = typeof value === 'string' ? value.trim() : '';
345
+ if (trimmed === '') return 'pixel-art.png';
346
+ return /\.png$/i.test(trimmed) ? trimmed : `${trimmed}.png`;
347
+ }
348
+
349
+ /* ------------------------------------------------------------------ */
350
+ /* icons */
351
+ /* ------------------------------------------------------------------ */
352
+
353
+ function ToolIcon({ name }) {
354
+ const common = { width: 16, height: 16, viewBox: '0 0 16 16', 'aria-hidden': 'true' };
355
+ if (name === 'pencil') {
356
+ return h('svg', common, h('path', {
357
+ d: 'M11.6 1.9 14.1 4.4 5.6 12.9 2.3 13.7 3.1 10.4z',
358
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round',
359
+ }));
360
+ }
361
+ if (name === 'eraser') {
362
+ return h('svg', common,
363
+ h('path', {
364
+ d: 'M9.8 2.2 13.8 6.2 8.3 11.7 4.3 7.7z',
365
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round',
366
+ }),
367
+ h('path', { d: 'M4.3 7.7 2 10v3.5h5.2L8.3 11.7', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4 }));
368
+ }
369
+ if (name === 'bucket') {
370
+ return h('svg', common,
371
+ h('path', {
372
+ d: 'M6.2 2.6 12.9 9.3 8 14.2 1.9 8.1z',
373
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round',
374
+ }),
375
+ 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' }));
376
+ }
377
+ return h('svg', common,
378
+ h('path', {
379
+ 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',
380
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinejoin: 'round',
381
+ }),
382
+ h('path', { d: 'M8.2 6.4 2.1 12.5a1.6 1.6 0 0 0 2.2 2.2l6.1-6.1', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4 }));
383
+ }
384
+
385
+ function UndoIcon({ flipped }) {
386
+ return h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', 'aria-hidden': 'true', style: flipped ? { transform: 'scaleX(-1)' } : undefined },
387
+ h('path', { d: 'M6 3.5 2.8 6.7 6 9.9', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' }),
388
+ h('path', { d: 'M3.2 6.7h5.4a4 4 0 0 1 0 8H6.4', fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }));
389
+ }
390
+
391
+ /* ------------------------------------------------------------------ */
392
+ /* styles */
393
+ /* ------------------------------------------------------------------ */
394
+
395
+ const CSS = `
396
+ .pxs-root {
397
+ height: 100%; min-height: 520px; display: flex; flex-direction: column;
398
+ background: var(--dsw-alias-bg-base, #16181d); color: var(--dsw-alias-label-primary, #e8e8ea);
399
+ font-size: 12px; line-height: 1.45; position: relative; overflow: hidden;
400
+ }
401
+ .pxs-bar {
402
+ flex: 0 0 auto; height: 40px; display: flex; align-items: center; gap: 6px; padding: 0 8px;
403
+ border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.1));
404
+ background: var(--dsw-alias-bg-layer-1, #1c1f26);
405
+ }
406
+ .pxs-main {
407
+ flex: 1 1 auto; min-height: 0; display: grid;
408
+ grid-template-columns:
409
+ var(--pxs-left, 196px) 5px minmax(0, 1fr) 5px var(--pxs-right, 216px);
410
+ }
411
+ .pxs-panel {
412
+ min-height: 0; overflow-y: auto; padding: 10px; position: relative;
413
+ background: var(--dsw-alias-bg-layer-1, #1c1f26);
414
+ }
415
+ .pxs-panel-left { border-right: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.1)); }
416
+ .pxs-panel-right { border-left: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.1)); }
417
+ .pxs-splitter {
418
+ position: relative; z-index: 4; cursor: col-resize; background: transparent; touch-action: none;
419
+ }
420
+ .pxs-splitter::after {
421
+ content: ""; position: absolute; top: 0; bottom: 0; left: 2px; width: 1px;
422
+ background: var(--dsw-alias-border-l1, rgba(255,255,255,.1));
423
+ }
424
+ .pxs-splitter:hover, .pxs-splitter[data-dragging="true"] {
425
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4c8dff) 32%, transparent);
426
+ }
427
+ .pxs-layers { display: flex; flex-direction: column; gap: 3px; }
428
+ .pxs-layer {
429
+ display: flex; align-items: center; gap: 4px; padding: 2px 6px; border-radius: 6px;
430
+ border: 1px solid transparent; cursor: pointer; user-select: none;
431
+ }
432
+ .pxs-layer:hover { background: var(--dsw-alias-bg-layer-2, #232730); }
433
+ .pxs-layer[data-active="true"] {
434
+ border-color: var(--dsw-alias-brand-primary, #4c8dff);
435
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4c8dff) 16%, transparent);
436
+ }
437
+ .pxs-layer[data-hidden="true"] .pxs-layer-name { opacity: .45; }
438
+ .pxs-layer-name {
439
+ flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
440
+ }
441
+ .pxs-layer-eye {
442
+ width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center;
443
+ border-radius: 4px; font-size: 11px; color: var(--dsw-alias-label-secondary, #9aa1ad); flex: 0 0 auto;
444
+ }
445
+ .pxs-layer-eye:hover { background: rgba(255,255,255,.08); color: var(--dsw-alias-label-primary, #e8e8ea); }
446
+ .pxs-layer-rename {
447
+ flex: 1 1 auto; min-width: 0; height: 20px; padding: 0 4px; font: inherit; color: inherit;
448
+ border-radius: 4px; border: 1px solid var(--dsw-alias-brand-primary, #4c8dff);
449
+ background: var(--dsw-alias-bg-base, #16181d);
450
+ }
451
+ .pxs-stage { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: var(--dsw-alias-bg-base, #16181d); touch-action: none; }
452
+ .pxs-canvas { display: block; width: 100%; height: 100%; cursor: crosshair; }
453
+ .pxs-canvas.is-panning { cursor: grabbing; }
454
+ .pxs-canvas.is-space { cursor: grab; }
455
+ .pxs-section { margin-bottom: 14px; }
456
+ .pxs-section:last-child { margin-bottom: 4px; }
457
+ .pxs-title {
458
+ font-size: 10px; letter-spacing: .08em; text-transform: uppercase; margin: 0 0 6px;
459
+ color: var(--dsw-alias-label-secondary, #9aa1ad);
460
+ }
461
+ .pxs-row { display: flex; flex-wrap: wrap; gap: 4px; }
462
+ .pxs-btn {
463
+ appearance: none; cursor: pointer; border-radius: 6px; padding: 0 8px; height: 26px;
464
+ display: inline-flex; align-items: center; justify-content: center; gap: 4px;
465
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.12));
466
+ background: var(--dsw-alias-bg-layer-2, #232730); color: inherit; font: inherit;
467
+ }
468
+ .pxs-btn:hover { border-color: var(--dsw-alias-border-l2, rgba(255,255,255,.28)); }
469
+ .pxs-btn:disabled { opacity: .4; cursor: default; }
470
+ .pxs-btn[data-active="true"] {
471
+ border-color: var(--dsw-alias-brand-primary, #4c8dff);
472
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4c8dff) 18%, transparent);
473
+ }
474
+ .pxs-btn-icon { width: 26px; padding: 0; }
475
+ .pxs-input {
476
+ min-width: 0; height: 26px; padding: 0 8px; border-radius: 6px; font: inherit; color: inherit;
477
+ background: var(--dsw-alias-bg-layer-2, #232730);
478
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.12));
479
+ }
480
+ .pxs-input:focus { outline: 1px solid var(--dsw-alias-brand-primary, #4c8dff); outline-offset: 0; }
481
+ .pxs-spacer { flex: 1 1 auto; }
482
+ .pxs-swatches { display: grid; grid-template-columns: repeat(8, 1fr); gap: 3px; }
483
+ .pxs-swatch {
484
+ aspect-ratio: 1; border-radius: 4px; cursor: pointer; padding: 0; border: 1px solid rgba(0,0,0,.35);
485
+ position: relative;
486
+ }
487
+ .pxs-swatch[data-selected="true"] { box-shadow: 0 0 0 2px var(--dsw-alias-brand-primary, #4c8dff); z-index: 1; }
488
+ .pxs-swatch-add {
489
+ aspect-ratio: 1; border-radius: 4px; cursor: pointer; border: 1px dashed var(--dsw-alias-border-l2, rgba(255,255,255,.3));
490
+ background: transparent; color: var(--dsw-alias-label-secondary, #9aa1ad); font-size: 14px; line-height: 1;
491
+ }
492
+ .pxs-primary { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
493
+ .pxs-chip { width: 34px; height: 34px; border-radius: 8px; border: 1px solid rgba(0,0,0,.4); flex: 0 0 auto; }
494
+ .pxs-hex { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; color: var(--dsw-alias-label-secondary, #9aa1ad); }
495
+ .pxs-color-edit {
496
+ border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.16));
497
+ border-radius: 8px; padding: 8px; display: grid; gap: 8px;
498
+ background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,.04));
499
+ }
500
+ .pxs-color-edit-head { display: flex; align-items: center; gap: 6px; }
501
+ .pxs-color-edit-head .pxs-chip { width: 26px; height: 26px; border-radius: 6px; }
502
+ .pxs-hex-input {
503
+ flex: 1 1 auto; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
504
+ font-size: 11px; text-transform: uppercase;
505
+ }
506
+ .pxs-color-edit-sliders { display: grid; gap: 4px; }
507
+ .pxs-slider-row { display: grid; grid-template-columns: 10px 1fr 26px; align-items: center; gap: 6px; }
508
+ .pxs-slider-label { font-size: 10.5px; color: var(--dsw-alias-label-secondary, #9aa1ad); }
509
+ .pxs-slider-value {
510
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10.5px; text-align: right;
511
+ color: var(--dsw-alias-label-secondary, #9aa1ad);
512
+ }
513
+ .pxs-range { width: 100%; height: 14px; margin: 0; accent-color: var(--dsw-alias-brand-primary, #4c8dff); }
514
+ .pxs-meta { display: grid; grid-template-columns: auto 1fr; gap: 2px 8px; font-size: 11px; color: var(--dsw-alias-label-secondary, #9aa1ad); }
515
+ .pxs-meta b { color: var(--dsw-alias-label-primary, #e8e8ea); font-weight: 500; }
516
+ .pxs-keys { font-size: 10.5px; color: var(--dsw-alias-label-secondary, #9aa1ad); display: grid; gap: 2px; }
517
+ .pxs-keys kbd {
518
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; padding: 0 4px; border-radius: 3px;
519
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255,255,255,.14));
520
+ background: var(--dsw-alias-bg-base, #16181d);
521
+ }
522
+ .pxs-empty {
523
+ position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
524
+ gap: 10px; text-align: center; padding: 24px;
525
+ }
526
+ .pxs-empty h3 { margin: 0; font-size: 15px; font-weight: 600; }
527
+ .pxs-empty p { margin: 0; color: var(--dsw-alias-label-secondary, #9aa1ad); max-width: 320px; }
528
+ .pxs-hint { font-size: 11px; color: var(--dsw-alias-label-secondary, #9aa1ad); }
529
+ .pxs-fallback {
530
+ position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
531
+ gap: 10px; padding: 24px; text-align: center;
532
+ }
533
+ .pxs-fallback h3 { margin: 0; font-size: 14px; font-weight: 600; color: var(--dsw-alias-state-error-primary, #e0574f); }
534
+ .pxs-fallback p {
535
+ margin: 0; max-width: 520px; font-size: 11px; word-break: break-word;
536
+ color: var(--dsw-alias-label-secondary, #9aa1ad); font-family: ui-monospace, Menlo, monospace;
537
+ }
538
+ .pxs-status { font-size: 11px; color: var(--dsw-alias-label-secondary, #9aa1ad); min-width: 54px; text-align: right; }
539
+ .pxs-status[data-state="dirty"] { color: var(--dsw-alias-state-warn-primary, #e0a33a); }
540
+ .pxs-status[data-state="error"] { color: var(--dsw-alias-state-error-primary, #e0574f); }
541
+ .pxs-notice {
542
+ font-size: 11px;
543
+ color: var(--dsw-alias-state-success-primary, #3fa45b);
544
+ max-width: 320px;
545
+ overflow: hidden;
546
+ text-overflow: ellipsis;
547
+ white-space: nowrap;
548
+ }
549
+ .pxs-modal {
550
+ position: fixed; inset: 0; z-index: 60; display: flex; align-items: center; justify-content: center;
551
+ background: rgba(0, 0, 0, .45); padding: 24px;
552
+ }
553
+ .pxs-dialog {
554
+ display: flex; flex-direction: column; gap: 10px; width: min(560px, 100%); max-height: 100%;
555
+ padding: 14px; border-radius: 10px; box-sizing: border-box;
556
+ background: var(--dsw-alias-bg-overlay, #262a33);
557
+ border: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .2));
558
+ box-shadow: 0 18px 48px rgba(0, 0, 0, .45);
559
+ color: var(--dsw-alias-label-primary, #e8ebf2); font-size: 12px;
560
+ }
561
+ .pxs-dialog-head { display: flex; align-items: center; justify-content: space-between; }
562
+ .pxs-dialog-close {
563
+ width: 22px; height: 22px; padding: 0; border: 0; border-radius: 6px; cursor: pointer;
564
+ background: transparent; color: var(--dsw-alias-label-secondary, #9aa3b2); font-size: 16px; line-height: 1;
565
+ }
566
+ .pxs-dialog-close:hover { background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, .08)); color: var(--dsw-alias-label-primary, #e8ebf2); }
567
+ .pxs-dialog-row { display: flex; align-items: center; gap: 6px; }
568
+ .pxs-dialog-label { color: var(--dsw-alias-label-secondary, #9aa3b2); min-width: 42px; }
569
+ .pxs-dialog-list {
570
+ display: flex; flex-direction: column; gap: 2px; overflow-y: auto; min-height: 160px; max-height: 46vh;
571
+ padding: 6px; border-radius: 8px; box-sizing: border-box;
572
+ background: var(--dsw-alias-bg-layer-1, #1c1f26);
573
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .1));
574
+ }
575
+ .pxs-dialog-item {
576
+ display: flex; align-items: center; gap: 6px; width: 100%; padding: 5px 8px; text-align: left;
577
+ border: 0; border-radius: 6px; cursor: pointer; font: inherit;
578
+ background: transparent; color: var(--dsw-alias-label-primary, #e8ebf2);
579
+ }
580
+ .pxs-dialog-item::before { content: '📁'; font-size: 12px; }
581
+ .pxs-dialog-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, .08)); }
582
+ .pxs-dialog-item[data-hidden="true"] { opacity: .6; }
583
+ .pxs-dialog-empty { padding: 12px 8px; color: var(--dsw-alias-label-secondary, #9aa3b2); text-align: center; }
584
+ .pxs-dialog-error { color: var(--dsw-alias-state-error-primary, #e0574f); word-break: break-all; }
585
+ .pxs-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
586
+ .pxs-btn-primary {
587
+ background: var(--dsw-alias-brand-primary, #4b7bec); border-color: transparent;
588
+ color: #fff;
589
+ }
590
+ .pxs-native-color {
591
+ width: 40px; height: 28px; padding: 0; border: 1px solid var(--dsw-alias-border-l2, rgba(255, 255, 255, .18));
592
+ border-radius: 6px; background: transparent; cursor: pointer; flex: none;
593
+ }
594
+ .pxs-native-color::-webkit-color-swatch-wrapper { padding: 2px; }
595
+ .pxs-native-color::-webkit-color-swatch { border: 0; border-radius: 4px; }
596
+ .pxs-native-color::-moz-color-swatch { border: 0; border-radius: 4px; }
597
+ .pxs-picker-btn { width: 100%; }
598
+ .pxs-hint { color: var(--dsw-alias-label-secondary, #9aa3b2); line-height: 1.5; margin: 0; }
599
+ .pxs-new-dialog { width: min(420px, calc(100vw - 40px)); }
600
+ .pxs-size-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 5px; }
601
+ .pxs-size-chip {
602
+ display: flex; flex-direction: column; align-items: flex-start; gap: 1px; padding: 5px 7px;
603
+ border-radius: 7px; cursor: pointer; font: inherit; text-align: left;
604
+ color: var(--dsw-alias-label-primary, #e8ebf2);
605
+ background: var(--dsw-alias-bg-layer-1, #1c1f26);
606
+ border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .1));
607
+ }
608
+ .pxs-size-chip:hover { border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, .2)); }
609
+ .pxs-size-chip[data-active="true"] {
610
+ border-color: var(--dsw-alias-brand-primary, #4b7bec);
611
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b7bec) 16%, transparent);
612
+ }
613
+ .pxs-size-label { font-size: 12px; font-weight: 600; }
614
+ .pxs-size-hint { font-size: 10px; color: var(--dsw-alias-label-secondary, #9aa3b2); }
615
+ .pxs-menu {
616
+ position: fixed; z-index: 40; min-width: 132px; padding: 4px; border-radius: 8px;
617
+ background: var(--dsw-alias-bg-overlay, #262a33); border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.2));
618
+ box-shadow: 0 10px 30px rgba(0,0,0,.35);
619
+ }
620
+ .pxs-menu button {
621
+ display: block; width: 100%; text-align: left; padding: 5px 8px; border: 0; border-radius: 5px;
622
+ background: transparent; color: inherit; font: inherit; cursor: pointer;
623
+ }
624
+ .pxs-menu button:hover { background: var(--dsw-alias-bg-layer-2, #232730); }
625
+ .pxs-menu button[data-danger="true"] { color: var(--dsw-alias-state-error-primary, #e0574f); }
626
+ .pxs-error {
627
+ flex: 0 0 auto; padding: 4px 10px; font-size: 11px;
628
+ color: var(--dsw-alias-state-error-primary, #e0574f);
629
+ background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #e0574f) 12%, transparent);
630
+ }
631
+ @media (max-width: 900px) {
632
+ .pxs-main { grid-template-columns: 168px 5px minmax(0, 1fr) 5px 176px; }
633
+ }
634
+ @media (max-width: 700px) {
635
+ .pxs-main { grid-template-columns: 56px 5px minmax(0, 1fr) 5px 132px; }
636
+ .pxs-panel { padding: 6px; }
637
+ .pxs-hide-narrow { display: none; }
638
+ }
639
+ `;
640
+
641
+ /* ------------------------------------------------------------------ */
642
+ /* editor */
643
+ /* ------------------------------------------------------------------ */
644
+
645
+ function PixelStudio(props) {
646
+ const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : '';
647
+ const [docs, setDocs] = useState([]);
648
+ const [doc, setDoc] = useState(null);
649
+ const [layerIndex, setLayerIndex] = useState(0);
650
+ const [tool, setTool] = useState('pencil');
651
+ const [colorIndex, setColorIndex] = useState(0);
652
+ const [brush, setBrush] = useState(1);
653
+ const [zoom, setZoom] = useState(8);
654
+ const [status, setStatus] = useState('idle');
655
+ const [notice, setNotice] = useState('');
656
+ const [error, setError] = useState('');
657
+ const [loading, setLoading] = useState(true);
658
+ const [menu, setMenu] = useState(null);
659
+ const [creating, setCreating] = useState(false);
660
+ const [newDialog, setNewDialog] = useState(null);
661
+ const [showGrid, setShowGrid] = useState(() => readStored('pxs:grid', '1') !== '0');
662
+ const [leftWidth, setLeftWidth] = useState(() => clamp(readStoredNumber('pxs:left', 196), 132, 360));
663
+ const [rightWidth, setRightWidth] = useState(() => clamp(readStoredNumber('pxs:right', 216), 152, 420));
664
+ const [renaming, setRenaming] = useState(-1);
665
+ const [renameValue, setRenameValue] = useState('');
666
+ const [saveDialog, setSaveDialog] = useState(null);
667
+ const [editing, setEditing] = useState(null);
668
+
669
+ const stageRef = useRef(null);
670
+ const canvasRef = useRef(null);
671
+ const offRef = useRef(null);
672
+ const docRef = useRef(null);
673
+ const scaleRef = useRef(8);
674
+ const panRef = useRef({ x: 0, y: 0 });
675
+ const toolRef = useRef('pencil');
676
+ const colorRef = useRef(0);
677
+ const brushRef = useRef(1);
678
+ const layerRef = useRef(0);
679
+ const strokeRef = useRef(null);
680
+ const panDrawRef = useRef(null);
681
+ const spaceRef = useRef(false);
682
+ const historyRef = useRef({ undo: [], redo: [] });
683
+ const saveTimerRef = useRef(0);
684
+ /** Palette slot currently open in the built-in color editor. */
685
+ const editingRef = useRef(null);
686
+ /** Colour the edited slot had when the editor opened (before any edit). */
687
+ const editSessionRef = useRef(null);
688
+ const hexInputRef = useRef(null);
689
+ const nativeColorRef = useRef(null);
690
+ const frameRef = useRef(0);
691
+ const mainRef = useRef(null);
692
+ const gridRef = useRef(true);
693
+ const splitRef = useRef(null);
694
+ const sizeRef = useRef({ left: 196, right: 216 });
695
+ // Indirection for the save-as action: the keyboard effect is registered
696
+ // before the callback is created, so it must not close over the binding.
697
+ const saveAsRef = useRef(null);
698
+
699
+ if (gridRef.current !== showGrid) gridRef.current = showGrid;
700
+ sizeRef.current = { left: leftWidth, right: rightWidth };
701
+ if (editingRef.current !== editing) editingRef.current = editing;
702
+
703
+ docRef.current = doc;
704
+ toolRef.current = tool;
705
+ colorRef.current = colorIndex;
706
+ brushRef.current = brush;
707
+ layerRef.current = layerIndex;
708
+
709
+ /* -------------------------------------------------------------- */
710
+ /* painting */
711
+ /* -------------------------------------------------------------- */
712
+
713
+ const composeImage = useCallback(() => {
714
+ const current = docRef.current;
715
+ if (!current) return null;
716
+ let off = offRef.current;
717
+ if (!off) {
718
+ off = document.createElement('canvas');
719
+ offRef.current = off;
720
+ }
721
+ if (off.width !== current.w || off.height !== current.h) {
722
+ off.width = current.w;
723
+ off.height = current.h;
724
+ }
725
+ const context = off.getContext('2d');
726
+ if (!context) return null;
727
+ const image = context.createImageData(current.w, current.h);
728
+ const rgb = current.palette.map(hexToRgb);
729
+ const paletteRgb = rgb.map(([r, g, b]) => [r, g, b]);
730
+ for (let y = 0; y < current.h; y += 1) {
731
+ for (let x = 0; x < current.w; x += 1) {
732
+ let color = null;
733
+ for (let l = current.layers.length - 1; l >= 0; l -= 1) {
734
+ const layer = current.layers[l];
735
+ if (layer.visible === false) continue;
736
+ const row = layer.rows[y];
737
+ const ch = row === undefined ? '.' : row[x];
738
+ const index = ch === undefined ? -1 : indexFor(ch);
739
+ if (index >= 0 && index < paletteRgb.length) {
740
+ color = paletteRgb[index];
741
+ break;
742
+ }
743
+ }
744
+ const offset = (y * current.w + x) * 4;
745
+ if (color === null) {
746
+ image.data[offset + 3] = 0;
747
+ } else {
748
+ image.data[offset] = color[0];
749
+ image.data[offset + 1] = color[1];
750
+ image.data[offset + 2] = color[2];
751
+ image.data[offset + 3] = 255;
752
+ }
753
+ }
754
+ }
755
+ context.putImageData(image, 0, 0);
756
+ return off;
757
+ }, []);
758
+
759
+ const paint = useCallback(() => {
760
+ frameRef.current = 0;
761
+ const canvas = canvasRef.current;
762
+ const stage = stageRef.current;
763
+ const current = docRef.current;
764
+ if (!canvas || !stage || !current) return;
765
+ const context = canvas.getContext('2d');
766
+ if (!context) return;
767
+ const dpr = window.devicePixelRatio || 1;
768
+ const width = Math.max(1, Math.round(stage.clientWidth));
769
+ const height = Math.max(1, Math.round(stage.clientHeight));
770
+ if (canvas.width !== Math.round(width * dpr) || canvas.height !== Math.round(height * dpr)) {
771
+ canvas.width = Math.round(width * dpr);
772
+ canvas.height = Math.round(height * dpr);
773
+ }
774
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
775
+ context.clearRect(0, 0, width, height);
776
+
777
+ const scale = scaleRef.current;
778
+ const pan = panRef.current;
779
+ const docWidth = current.w * scale;
780
+ const docHeight = current.h * scale;
781
+
782
+ // Checkerboard under the artwork so transparent pixels read as empty.
783
+ context.save();
784
+ context.beginPath();
785
+ context.rect(pan.x, pan.y, docWidth, docHeight);
786
+ context.clip();
787
+ const cell = 8;
788
+ const light = 'rgba(255,255,255,0.055)';
789
+ const dark = 'rgba(255,255,255,0.015)';
790
+ context.fillStyle = dark;
791
+ context.fillRect(pan.x, pan.y, docWidth, docHeight);
792
+ context.fillStyle = light;
793
+ const startX = Math.floor(pan.x / cell) * cell;
794
+ const startY = Math.floor(pan.y / cell) * cell;
795
+ for (let y = startY; y < pan.y + docHeight; y += cell) {
796
+ for (let x = startX; x < pan.x + docWidth; x += cell) {
797
+ if (((x / cell) + (y / cell)) % 2 === 0) continue;
798
+ context.fillRect(x, y, cell, cell);
799
+ }
800
+ }
801
+ context.restore();
802
+
803
+ const off = composeImage();
804
+ if (off) {
805
+ context.save();
806
+ context.translate(pan.x, pan.y);
807
+ context.scale(scale, scale);
808
+ context.imageSmoothingEnabled = false;
809
+ context.drawImage(off, 0, 0);
810
+ context.restore();
811
+ }
812
+
813
+ if (gridRef.current && scale >= 4) {
814
+ context.save();
815
+ context.beginPath();
816
+ context.rect(pan.x, pan.y, docWidth, docHeight);
817
+ context.clip();
818
+ context.strokeStyle = 'rgba(255,255,255,0.09)';
819
+ context.lineWidth = 1;
820
+ context.beginPath();
821
+ for (let x = 1; x < current.w; x += 1) {
822
+ const px = Math.round(pan.x + x * scale) + 0.5;
823
+ context.moveTo(px, pan.y);
824
+ context.lineTo(px, pan.y + docHeight);
825
+ }
826
+ for (let y = 1; y < current.h; y += 1) {
827
+ const py = Math.round(pan.y + y * scale) + 0.5;
828
+ context.moveTo(pan.x, py);
829
+ context.lineTo(pan.x + docWidth, py);
830
+ }
831
+ context.stroke();
832
+ context.restore();
833
+ }
834
+
835
+ context.strokeStyle = 'rgba(255,255,255,0.22)';
836
+ context.lineWidth = 1;
837
+ context.strokeRect(Math.round(pan.x) + 0.5, Math.round(pan.y) + 0.5, Math.round(docWidth), Math.round(docHeight));
838
+ }, [composeImage]);
839
+
840
+ const requestPaint = useCallback(() => {
841
+ if (frameRef.current !== 0) return;
842
+ frameRef.current = window.requestAnimationFrame(paint);
843
+ }, [paint]);
844
+
845
+ const fitToView = useCallback(() => {
846
+ const stage = stageRef.current;
847
+ const current = docRef.current;
848
+ if (!stage || !current) return;
849
+ const width = Math.max(1, stage.clientWidth);
850
+ const height = Math.max(1, stage.clientHeight);
851
+ const raw = Math.min((width - 48) / current.w, (height - 48) / current.h);
852
+ let scale = 1;
853
+ for (const candidate of ZOOMS) {
854
+ if (candidate <= raw) scale = candidate;
855
+ }
856
+ if (raw >= 1) {
857
+ let best = 1;
858
+ for (const candidate of ZOOMS) if (candidate <= raw) best = candidate;
859
+ scale = best;
860
+ }
861
+ scaleRef.current = scale;
862
+ setZoom(scale);
863
+ panRef.current = {
864
+ x: Math.round((width - current.w * scale) / 2),
865
+ y: Math.round((height - current.h * scale) / 2),
866
+ };
867
+ requestPaint();
868
+ }, [requestPaint]);
869
+
870
+ /* -------------------------------------------------------------- */
871
+ /* documents */
872
+ /* -------------------------------------------------------------- */
873
+
874
+ const refreshDocs = useCallback(async () => {
875
+ const data = await callApi({ op: 'list' });
876
+ setDocs(Array.isArray(data.docs) ? data.docs : []);
877
+ return data.docs || [];
878
+ }, []);
879
+
880
+ const openDoc = useCallback(async (id) => {
881
+ const data = await callApi({ op: 'get', id });
882
+ setDoc(data.doc);
883
+ docRef.current = data.doc;
884
+ setLayerIndex(0);
885
+ setColorIndex(0);
886
+ setEditing(null);
887
+ editingRef.current = null;
888
+ historyRef.current = { undo: [], redo: [] };
889
+ setStatus('clean');
890
+ window.requestAnimationFrame(() => fitToView());
891
+ }, [fitToView]);
892
+
893
+ useEffect(() => {
894
+ let disposed = false;
895
+ (async () => {
896
+ try {
897
+ const list = await refreshDocs();
898
+ if (disposed) return;
899
+ if (list.length > 0) {
900
+ await openDoc(list[0].id);
901
+ }
902
+ } catch (cause) {
903
+ if (!disposed) setError(cause instanceof Error ? cause.message : String(cause));
904
+ } finally {
905
+ if (!disposed) setLoading(false);
906
+ }
907
+ })();
908
+ return () => {
909
+ disposed = true;
910
+ };
911
+ }, [openDoc, refreshDocs]);
912
+
913
+ const saveNow = useCallback(async () => {
914
+ const current = docRef.current;
915
+ if (!current) return;
916
+ setStatus('saving');
917
+ try {
918
+ const data = await callApi({ op: 'save', doc: current });
919
+ const saved = data.doc;
920
+ docRef.current = saved;
921
+ setDoc(saved);
922
+ setStatus('clean');
923
+ setError('');
924
+ const list = await refreshDocs();
925
+ if (list.length === 0) setDocs([saved]);
926
+ } catch (cause) {
927
+ setStatus('error');
928
+ setError(cause instanceof Error ? cause.message : String(cause));
929
+ }
930
+ }, [refreshDocs]);
931
+
932
+ const scheduleSave = useCallback(() => {
933
+ setStatus('dirty');
934
+ if (saveTimerRef.current !== 0) window.clearTimeout(saveTimerRef.current);
935
+ saveTimerRef.current = window.setTimeout(() => {
936
+ saveTimerRef.current = 0;
937
+ void saveNow();
938
+ }, 700);
939
+ }, [saveNow]);
940
+
941
+ useEffect(() => () => {
942
+ if (saveTimerRef.current !== 0) window.clearTimeout(saveTimerRef.current);
943
+ }, []);
944
+
945
+ const commitRows = useCallback((rows, options = {}) => {
946
+ const current = docRef.current;
947
+ if (!current) return;
948
+ const history = historyRef.current;
949
+ if (options.snapshot !== false) {
950
+ history.undo.push({
951
+ palette: current.palette.slice(),
952
+ layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
953
+ });
954
+ if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
955
+ history.redo = [];
956
+ }
957
+ const layers = current.layers.map((layer, index) => (
958
+ index === layerRef.current ? { ...layer, rows } : layer
959
+ ));
960
+ const next = { ...current, layers };
961
+ docRef.current = next;
962
+ setDoc(next);
963
+ requestPaint();
964
+ scheduleSave();
965
+ }, [requestPaint, scheduleSave]);
966
+
967
+ const applyHistory = useCallback((snapshot) => {
968
+ const current = docRef.current;
969
+ if (!current || !snapshot) return;
970
+ const next = {
971
+ ...current,
972
+ palette: snapshot.palette.slice(),
973
+ layers: snapshot.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
974
+ };
975
+ docRef.current = next;
976
+ setDoc(next);
977
+ if (!next.layers[layerRef.current]) {
978
+ const safe = Math.max(0, next.layers.length - 1);
979
+ layerRef.current = safe;
980
+ setLayerIndex(safe);
981
+ }
982
+ requestPaint();
983
+ scheduleSave();
984
+ }, [requestPaint, scheduleSave]);
985
+
986
+ const undo = useCallback(() => {
987
+ const history = historyRef.current;
988
+ const snapshot = history.undo.pop();
989
+ if (!snapshot) return;
990
+ const current = docRef.current;
991
+ history.redo.push({
992
+ palette: current.palette.slice(),
993
+ layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
994
+ });
995
+ applyHistory(snapshot);
996
+ }, [applyHistory]);
997
+
998
+ const redo = useCallback(() => {
999
+ const history = historyRef.current;
1000
+ const snapshot = history.redo.pop();
1001
+ if (!snapshot) return;
1002
+ const current = docRef.current;
1003
+ history.undo.push({
1004
+ palette: current.palette.slice(),
1005
+ layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
1006
+ });
1007
+ applyHistory(snapshot);
1008
+ }, [applyHistory]);
1009
+
1010
+ /* -------------------------------------------------------------- */
1011
+ /* pointer input */
1012
+ /* -------------------------------------------------------------- */
1013
+
1014
+ const cellFromEvent = useCallback((event) => {
1015
+ const canvas = canvasRef.current;
1016
+ const current = docRef.current;
1017
+ if (!canvas || !current) return null;
1018
+ const rect = canvas.getBoundingClientRect();
1019
+ const scale = scaleRef.current;
1020
+ const pan = panRef.current;
1021
+ return {
1022
+ x: Math.floor((event.clientX - rect.left - pan.x) / scale),
1023
+ y: Math.floor((event.clientY - rect.top - pan.y) / scale),
1024
+ };
1025
+ }, []);
1026
+
1027
+ const paintBrush = useCallback((rows, cx, cy, ch) => {
1028
+ const current = docRef.current;
1029
+ if (!current) return rows;
1030
+ const size = brushRef.current;
1031
+ const offset = Math.floor((size - 1) / 2);
1032
+ let next = rows;
1033
+ for (let dy = 0; dy < size; dy += 1) {
1034
+ for (let dx = 0; dx < size; dx += 1) {
1035
+ next = setPixel(next, cx + dx - offset, cy + dy - offset, ch);
1036
+ }
1037
+ }
1038
+ return next;
1039
+ }, []);
1040
+
1041
+ const strokeAt = useCallback((cell, previous) => {
1042
+ const current = docRef.current;
1043
+ if (!current) return;
1044
+ const layer = current.layers[layerRef.current];
1045
+ if (!layer) return;
1046
+ const ch = toolRef.current === 'eraser' ? '.' : charFor(colorRef.current);
1047
+ let rows = layer.rows;
1048
+ const cells = previous ? lineCells(previous.x, previous.y, cell.x, cell.y) : [[cell.x, cell.y]];
1049
+ for (const [x, y] of cells) rows = paintBrush(rows, x, y, ch);
1050
+ commitRows(rows, { snapshot: false });
1051
+ }, [commitRows, paintBrush]);
1052
+
1053
+ const pickColorAt = useCallback((cell) => {
1054
+ const current = docRef.current;
1055
+ if (!current) return;
1056
+ for (let l = current.layers.length - 1; l >= 0; l -= 1) {
1057
+ const layer = current.layers[l];
1058
+ if (layer.visible === false) continue;
1059
+ const row = layer.rows[cell.y];
1060
+ const ch = row === undefined ? '.' : row[cell.x];
1061
+ const index = ch === undefined ? -1 : indexFor(ch);
1062
+ if (index >= 0) {
1063
+ setColorIndex(index);
1064
+ colorRef.current = index;
1065
+ return;
1066
+ }
1067
+ }
1068
+ }, []);
1069
+
1070
+ const onPointerDown = useCallback((event) => {
1071
+ if (!docRef.current) return;
1072
+ const wantsPan = event.button === 1 || spaceRef.current || event.altKey;
1073
+ if (wantsPan) {
1074
+ event.preventDefault();
1075
+ panDrawRef.current = {
1076
+ pointerId: event.pointerId,
1077
+ startX: event.clientX,
1078
+ startY: event.clientY,
1079
+ origin: { ...panRef.current },
1080
+ };
1081
+ event.currentTarget.setPointerCapture(event.pointerId);
1082
+ requestPaint();
1083
+ return;
1084
+ }
1085
+ if (event.button !== 0) return;
1086
+ const cell = cellFromEvent(event);
1087
+ if (!cell) return;
1088
+ const current = docRef.current;
1089
+
1090
+ if (toolRef.current === 'picker') {
1091
+ pickColorAt(cell);
1092
+ return;
1093
+ }
1094
+ if (cell.x < 0 || cell.y < 0 || cell.x >= current.w || cell.y >= current.h) return;
1095
+
1096
+ if (toolRef.current === 'bucket') {
1097
+ const rows = fillRegion(
1098
+ current,
1099
+ layerRef.current,
1100
+ cell.x,
1101
+ cell.y,
1102
+ charFor(colorRef.current),
1103
+ );
1104
+ if (rows) commitRows(rows);
1105
+ return;
1106
+ }
1107
+
1108
+ // Real stroke: snapshot once, then paint continuously.
1109
+ const history = historyRef.current;
1110
+ history.undo.push({
1111
+ palette: current.palette.slice(),
1112
+ layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
1113
+ });
1114
+ if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
1115
+ history.redo = [];
1116
+ strokeRef.current = { pointerId: event.pointerId, last: cell };
1117
+ event.currentTarget.setPointerCapture(event.pointerId);
1118
+ strokeAt(cell, null);
1119
+ }, [cellFromEvent, commitRows, pickColorAt, requestPaint, strokeAt]);
1120
+
1121
+ const onPointerMove = useCallback((event) => {
1122
+ const panDraw = panDrawRef.current;
1123
+ if (panDraw && panDraw.pointerId === event.pointerId) {
1124
+ panRef.current = {
1125
+ x: panDraw.origin.x + (event.clientX - panDraw.startX),
1126
+ y: panDraw.origin.y + (event.clientY - panDraw.startY),
1127
+ };
1128
+ requestPaint();
1129
+ return;
1130
+ }
1131
+ const stroke = strokeRef.current;
1132
+ if (!stroke || stroke.pointerId !== event.pointerId) return;
1133
+ const cell = cellFromEvent(event);
1134
+ if (!cell) return;
1135
+ if (stroke.last && stroke.last.x === cell.x && stroke.last.y === cell.y) return;
1136
+ strokeAt(cell, stroke.last);
1137
+ stroke.last = cell;
1138
+ }, [cellFromEvent, requestPaint, strokeAt]);
1139
+
1140
+ const onPointerUp = useCallback((event) => {
1141
+ if (panDrawRef.current && panDrawRef.current.pointerId === event.pointerId) {
1142
+ panDrawRef.current = null;
1143
+ requestPaint();
1144
+ return;
1145
+ }
1146
+ if (strokeRef.current && strokeRef.current.pointerId === event.pointerId) {
1147
+ strokeRef.current = null;
1148
+ const current = docRef.current;
1149
+ if (current) setStatus('dirty');
1150
+ scheduleSave();
1151
+ }
1152
+ }, [requestPaint, scheduleSave]);
1153
+
1154
+ /* -------------------------------------------------------------- */
1155
+ /* wheel / keyboard / resize */
1156
+ /* -------------------------------------------------------------- */
1157
+
1158
+ useEffect(() => {
1159
+ const stage = stageRef.current;
1160
+ if (!stage) return undefined;
1161
+ const onWheel = (event) => {
1162
+ if (!docRef.current) return;
1163
+ event.preventDefault();
1164
+ if (event.shiftKey) {
1165
+ panRef.current = {
1166
+ x: panRef.current.x - event.deltaY,
1167
+ y: panRef.current.y - event.deltaX,
1168
+ };
1169
+ requestPaint();
1170
+ return;
1171
+ }
1172
+ const rect = stage.getBoundingClientRect();
1173
+ const mx = event.clientX - rect.left;
1174
+ const my = event.clientY - rect.top;
1175
+ const current = scaleRef.current;
1176
+ const position = ZOOMS.indexOf(current);
1177
+ const base = position === -1 ? 0 : position;
1178
+ const step = event.deltaY > 0 ? -1 : 1;
1179
+ const next = ZOOMS[Math.max(0, Math.min(ZOOMS.length - 1, base + step))];
1180
+ if (next === current) return;
1181
+ panRef.current = {
1182
+ x: mx - ((mx - panRef.current.x) / current) * next,
1183
+ y: my - ((my - panRef.current.y) / current) * next,
1184
+ };
1185
+ scaleRef.current = next;
1186
+ setZoom(next);
1187
+ requestPaint();
1188
+ };
1189
+ stage.addEventListener('wheel', onWheel, { passive: false });
1190
+ return () => stage.removeEventListener('wheel', onWheel);
1191
+ }, [requestPaint]);
1192
+
1193
+ useEffect(() => {
1194
+ const observer = new ResizeObserver(() => requestPaint());
1195
+ if (stageRef.current) observer.observe(stageRef.current);
1196
+ return () => observer.disconnect();
1197
+ }, [requestPaint]);
1198
+
1199
+ useEffect(() => {
1200
+ const onKeyDown = (event) => {
1201
+ const target = event.target;
1202
+ const typing = target instanceof HTMLElement && (
1203
+ target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
1204
+ );
1205
+ if (typing) return;
1206
+ if (event.code === 'Space') {
1207
+ spaceRef.current = true;
1208
+ if (canvasRef.current) canvasRef.current.classList.add('is-space');
1209
+ event.preventDefault();
1210
+ return;
1211
+ }
1212
+ const meta = event.metaKey || event.ctrlKey;
1213
+ if (meta && event.key.toLowerCase() === 'z') {
1214
+ event.preventDefault();
1215
+ if (event.shiftKey) redo();
1216
+ else undo();
1217
+ return;
1218
+ }
1219
+ if (meta && event.key.toLowerCase() === 'y') {
1220
+ event.preventDefault();
1221
+ redo();
1222
+ return;
1223
+ }
1224
+ if (meta && event.shiftKey && event.key.toLowerCase() === 's') {
1225
+ event.preventDefault();
1226
+ if (typeof saveAsRef.current === 'function') saveAsRef.current();
1227
+ return;
1228
+ }
1229
+ if (meta && event.key.toLowerCase() === 's') {
1230
+ event.preventDefault();
1231
+ void saveNow();
1232
+ return;
1233
+ }
1234
+ if (meta) return;
1235
+ const key = event.key.toLowerCase();
1236
+ if (key === 'b') setTool('pencil');
1237
+ else if (key === 'e') setTool('eraser');
1238
+ else if (key === 'g') setTool('bucket');
1239
+ else if (key === 'i') setTool('picker');
1240
+ else if (key === 'f') fitToView();
1241
+ else if (key === 'h') {
1242
+ const next = !gridRef.current;
1243
+ gridRef.current = next;
1244
+ setShowGrid(next);
1245
+ storeValue('pxs:grid', next ? '1' : '0');
1246
+ requestPaint();
1247
+ }
1248
+ else if (key === '[') setBrush((value) => Math.max(1, value - 1));
1249
+ else if (key === ']') setBrush((value) => Math.min(8, value + 1));
1250
+ else if (key === '+' || key === '=') {
1251
+ const position = ZOOMS.indexOf(scaleRef.current);
1252
+ const next = ZOOMS[Math.min(ZOOMS.length - 1, (position === -1 ? 0 : position) + 1)];
1253
+ scaleRef.current = next;
1254
+ setZoom(next);
1255
+ requestPaint();
1256
+ } else if (key === '-' || key === '_') {
1257
+ const position = ZOOMS.indexOf(scaleRef.current);
1258
+ const next = ZOOMS[Math.max(0, (position === -1 ? 0 : position) - 1)];
1259
+ scaleRef.current = next;
1260
+ setZoom(next);
1261
+ requestPaint();
1262
+ }
1263
+ };
1264
+ const onKeyUp = (event) => {
1265
+ if (event.code === 'Space') {
1266
+ spaceRef.current = false;
1267
+ if (canvasRef.current) canvasRef.current.classList.remove('is-space');
1268
+ }
1269
+ };
1270
+ window.addEventListener('keydown', onKeyDown);
1271
+ window.addEventListener('keyup', onKeyUp);
1272
+ return () => {
1273
+ window.removeEventListener('keydown', onKeyDown);
1274
+ window.removeEventListener('keyup', onKeyUp);
1275
+ };
1276
+ }, [fitToView, redo, requestPaint, saveNow, undo]);
1277
+
1278
+ useEffect(() => {
1279
+ const close = () => setMenu(null);
1280
+ window.addEventListener('pointerdown', close);
1281
+ return () => window.removeEventListener('pointerdown', close);
1282
+ }, []);
1283
+
1284
+ useEffect(() => {
1285
+ if (notice === '') return undefined;
1286
+ const timer = window.setTimeout(() => setNotice(''), 6000);
1287
+ return () => window.clearTimeout(timer);
1288
+ }, [notice]);
1289
+
1290
+ useEffect(() => {
1291
+ if (doc) window.requestAnimationFrame(() => requestPaint());
1292
+ }, [doc, requestPaint]);
1293
+
1294
+ useEffect(() => {
1295
+ setColorIndex((value) => {
1296
+ const max = doc ? doc.palette.length - 1 : 0;
1297
+ return value > max ? Math.max(0, max) : value;
1298
+ });
1299
+ }, [doc]);
1300
+
1301
+ /* -------------------------------------------------------------- */
1302
+ /* document actions */
1303
+ /* -------------------------------------------------------------- */
1304
+
1305
+ const createDoc = useCallback(async (spec) => {
1306
+ const options = spec || {};
1307
+ const width = clamp(Math.round(Number(options.w) || 32), 1, MAX_SIDE);
1308
+ const height = clamp(Math.round(Number(options.h) || width), 1, MAX_SIDE);
1309
+ const name = String(options.name || '').trim().slice(0, 60) || `${width}×${height} 作品`;
1310
+ try {
1311
+ setCreating(true);
1312
+ const data = await callApi({
1313
+ op: 'create',
1314
+ name,
1315
+ w: width,
1316
+ h: height,
1317
+ palette: PRESETS[0].colors.slice(0, 8),
1318
+ });
1319
+ setDoc(data.doc);
1320
+ docRef.current = data.doc;
1321
+ setLayerIndex(0);
1322
+ setColorIndex(0);
1323
+ editSessionRef.current = null;
1324
+ setEditing(null);
1325
+ historyRef.current = { undo: [], redo: [] };
1326
+ await refreshDocs();
1327
+ setStatus('clean');
1328
+ setError('');
1329
+ setNewDialog(null);
1330
+ window.requestAnimationFrame(() => fitToView());
1331
+ } catch (cause) {
1332
+ setError(cause instanceof Error ? cause.message : String(cause));
1333
+ } finally {
1334
+ setCreating(false);
1335
+ }
1336
+ }, [fitToView, refreshDocs]);
1337
+
1338
+ /** New-work dialog: name + size, with presets one click away. */
1339
+ const openNewDialog = useCallback(() => {
1340
+ const base = docs.length + 1;
1341
+ setNewDialog({ name: `作品 ${base}`, w: 32, h: 32, error: '' });
1342
+ }, [docs.length]);
1343
+
1344
+ const confirmNewDoc = useCallback(() => {
1345
+ const dialog = newDialog;
1346
+ if (!dialog) return;
1347
+ const width = Math.round(Number(dialog.w));
1348
+ const height = Math.round(Number(dialog.h));
1349
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) {
1350
+ setNewDialog({ ...dialog, error: '宽高要填 1 以上的整数' });
1351
+ return;
1352
+ }
1353
+ if (width > MAX_SIDE || height > MAX_SIDE) {
1354
+ setNewDialog({ ...dialog, error: `最大 ${MAX_SIDE}×${MAX_SIDE}` });
1355
+ return;
1356
+ }
1357
+ void createDoc({ name: dialog.name, w: width, h: height });
1358
+ }, [createDoc, newDialog]);
1359
+
1360
+ const deleteDoc = useCallback(async () => {
1361
+ const current = docRef.current;
1362
+ if (!current) return;
1363
+ if (!window.confirm(`删除「${current.name}」?此操作不可撤销。`)) return;
1364
+ try {
1365
+ await callApi({ op: 'delete', id: current.id });
1366
+ const list = await callApi({ op: 'list' });
1367
+ setDocs(list.docs || []);
1368
+ const next = (list.docs || [])[0];
1369
+ if (next) await openDoc(next.id);
1370
+ else {
1371
+ setDoc(null);
1372
+ docRef.current = null;
1373
+ }
1374
+ } catch (cause) {
1375
+ setError(cause instanceof Error ? cause.message : String(cause));
1376
+ }
1377
+ }, [openDoc]);
1378
+
1379
+ const browseDir = useCallback(async (target) => {
1380
+ setSaveDialog((value) => (value ? { ...value, loading: true, error: '' } : value));
1381
+ try {
1382
+ const data = await callApi({ op: 'browse', path: typeof target === 'string' ? target : '', sessionId });
1383
+ const dirs = Array.isArray(data.dirs) ? data.dirs : [];
1384
+ setSaveDialog((value) => (value ? {
1385
+ ...value,
1386
+ loading: false,
1387
+ path: data.path,
1388
+ parent: data.parent,
1389
+ root: data.root,
1390
+ pathInput: data.path,
1391
+ dirs,
1392
+ } : value));
1393
+ } catch (cause) {
1394
+ const message = cause instanceof Error ? cause.message : String(cause);
1395
+ setSaveDialog((value) => (value ? { ...value, loading: false, error: message } : value));
1396
+ }
1397
+ }, [sessionId]);
1398
+
1399
+ const openSaveDialog = useCallback(async (mode) => {
1400
+ const current = docRef.current;
1401
+ if (!current) return;
1402
+ const filePath = typeof current.filePath === 'string' ? current.filePath : '';
1403
+ const startDir = mode === 'saveAs' ? dirNameOf(filePath) : '';
1404
+ const name = mode === 'saveAs' && filePath !== '' ? baseNameOf(filePath) : ensurePngName(current.name);
1405
+ setSaveDialog({
1406
+ mode,
1407
+ path: '',
1408
+ pathInput: '',
1409
+ parent: null,
1410
+ root: '',
1411
+ dirs: [],
1412
+ name,
1413
+ loading: true,
1414
+ busy: false,
1415
+ error: '',
1416
+ });
1417
+ await browseDir(startDir);
1418
+ }, [browseDir]);
1419
+
1420
+ const confirmSave = useCallback(async () => {
1421
+ const current = docRef.current;
1422
+ const dialog = saveDialog;
1423
+ if (!current || !dialog || dialog.busy) return;
1424
+ if (typeof dialog.path !== 'string' || dialog.path === '') return;
1425
+ const target = joinPath(dialog.path, ensurePngName(dialog.name));
1426
+ setSaveDialog((value) => (value ? { ...value, busy: true, error: '' } : value));
1427
+ try {
1428
+ const saved = await callApi({ op: 'exportTo', doc: current, path: target });
1429
+ const next = { ...current, filePath: typeof saved.path === 'string' ? saved.path : target };
1430
+ docRef.current = next;
1431
+ setDoc(next);
1432
+ setSaveDialog(null);
1433
+ setError('');
1434
+ setNotice(`已保存 ${next.filePath}`);
1435
+ const stored = await callApi({ op: 'save', doc: next });
1436
+ if (stored && stored.doc) {
1437
+ docRef.current = stored.doc;
1438
+ setDoc(stored.doc);
1439
+ }
1440
+ setStatus('clean');
1441
+ void refreshDocs();
1442
+ } catch (cause) {
1443
+ const message = cause instanceof Error ? cause.message : String(cause);
1444
+ setSaveDialog((value) => (value ? { ...value, busy: false, error: message } : value));
1445
+ }
1446
+ }, [refreshDocs, saveDialog]);
1447
+
1448
+ const exportPng = useCallback(async () => {
1449
+ const current = docRef.current;
1450
+ if (!current) return;
1451
+ const filePath = typeof current.filePath === 'string' ? current.filePath : '';
1452
+ if (filePath === '') {
1453
+ // No file yet: the operator picks where this artwork lives, once.
1454
+ await openSaveDialog('export');
1455
+ return;
1456
+ }
1457
+ try {
1458
+ const saved = await callApi({ op: 'exportTo', doc: current, path: filePath });
1459
+ setNotice(saved.overwritten ? `已覆盖 ${saved.path}` : `已保存 ${saved.path}`);
1460
+ setError('');
1461
+ } catch (cause) {
1462
+ const message = cause instanceof Error ? cause.message : String(cause);
1463
+ setError(message);
1464
+ // The previous target vanished: let the operator pick a new one.
1465
+ await openSaveDialog('saveAs');
1466
+ }
1467
+ }, [openSaveDialog]);
1468
+
1469
+ const saveAsPng = useCallback(() => {
1470
+ void openSaveDialog('saveAs');
1471
+ }, [openSaveDialog]);
1472
+ saveAsRef.current = saveAsPng;
1473
+
1474
+ /* -------------------------------------------------------------- */
1475
+ /* palette actions */
1476
+ /* -------------------------------------------------------------- */
1477
+
1478
+ const pushHistory = useCallback(() => {
1479
+ const current = docRef.current;
1480
+ if (!current) return;
1481
+ const history = historyRef.current;
1482
+ history.undo.push({
1483
+ palette: current.palette.slice(),
1484
+ layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
1485
+ });
1486
+ if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
1487
+ history.redo = [];
1488
+ }, []);
1489
+
1490
+ const updatePalette = useCallback((palette, options = {}) => {
1491
+ const current = docRef.current;
1492
+ if (!current) return;
1493
+ pushHistory();
1494
+ const next = { ...current, palette };
1495
+ docRef.current = next;
1496
+ setDoc(next);
1497
+ if (options.color !== undefined) {
1498
+ setColorIndex(options.color);
1499
+ colorRef.current = options.color;
1500
+ }
1501
+ requestPaint();
1502
+ scheduleSave();
1503
+ }, [pushHistory, requestPaint, scheduleSave]);
1504
+
1505
+ const beginEdit = useCallback((index) => {
1506
+ const current = docRef.current;
1507
+ if (!current || index < 0 || index >= current.palette.length) return;
1508
+ pushHistory();
1509
+ setColorIndex(index);
1510
+ colorRef.current = index;
1511
+ editSessionRef.current = { index, color: current.palette[index], detached: false };
1512
+ setEditing(index);
1513
+ }, [pushHistory]);
1514
+
1515
+ const endEdit = useCallback(() => {
1516
+ editSessionRef.current = null;
1517
+ setEditing(null);
1518
+ }, []);
1519
+
1520
+ const editColor = useCallback((index) => {
1521
+ beginEdit(index);
1522
+ }, [beginEdit]);
1523
+
1524
+ /**
1525
+ * Apply a colour to the slot the editor is showing. The first change
1526
+ * of a session first parks the artwork's pixels on their own slot, so
1527
+ * editing a palette colour never repaints the existing canvas.
1528
+ */
1529
+ const applyEditColor = useCallback((target, color) => {
1530
+ const current = docRef.current;
1531
+ if (!current) return;
1532
+ const index = typeof target === 'number' ? target : editingRef.current;
1533
+ if (index === null || index === undefined) return;
1534
+ if (index < 0 || index >= current.palette.length) return;
1535
+ const nextColor = String(color || '').toLowerCase();
1536
+ if (!/^#[0-9a-f]{6}$/.test(nextColor)) return;
1537
+ if (current.palette[index].toLowerCase() === nextColor) return;
1538
+ let base = current;
1539
+ const session = editSessionRef.current;
1540
+ if (session && session.index === index && !session.detached) {
1541
+ const detached = detachSlot(base, index);
1542
+ if (!detached) {
1543
+ setNotice(`调色板已满(${CHARS.length} 色),先删掉一个用不到的颜色再改这个`);
1544
+ return;
1545
+ }
1546
+ session.detached = true;
1547
+ base = detached.doc;
1548
+ if (detached.target >= 0) {
1549
+ setNotice(`画布上的旧颜色已由色板 #${detached.target + 1} 接手,画布未改动`);
1550
+ }
1551
+ }
1552
+ const palette = base.palette.slice();
1553
+ palette[index] = nextColor;
1554
+ const next = { ...base, palette };
1555
+ docRef.current = next;
1556
+ setDoc(next);
1557
+ requestPaint();
1558
+ scheduleSave();
1559
+ }, [requestPaint, scheduleSave]);
1560
+
1561
+ const setEditChannel = useCallback((channel, value) => {
1562
+ const current = docRef.current;
1563
+ if (!current || editingRef.current === null) return;
1564
+ const index = editingRef.current;
1565
+ const color = current.palette[index];
1566
+ if (!color) return;
1567
+ const rgb = hexToRgb(color);
1568
+ rgb[channel] = clamp(Math.round(Number(value) || 0), 0, 255);
1569
+ const hex = `#${rgb.map((part) => part.toString(16).padStart(2, '0')).join('')}`;
1570
+ applyEditColor(index, hex);
1571
+ if (hexInputRef.current) hexInputRef.current.value = hex.toUpperCase();
1572
+ }, [applyEditColor]);
1573
+
1574
+ const setEditHex = useCallback((value) => {
1575
+ const index = editingRef.current;
1576
+ if (index === null) return;
1577
+ let text = String(value || '').trim().toLowerCase();
1578
+ if (text.charAt(0) !== '#') text = `#${text}`;
1579
+ if (/^#[0-9a-f]{3}$/.test(text)) {
1580
+ text = `#${text[1]}${text[1]}${text[2]}${text[2]}${text[3]}${text[3]}`;
1581
+ }
1582
+ if (!/^#[0-9a-f]{6}$/.test(text)) {
1583
+ setNotice('颜色要写成 #rrggbb 或 #rgb');
1584
+ return;
1585
+ }
1586
+ applyEditColor(index, text);
1587
+ }, [applyEditColor]);
1588
+
1589
+ /**
1590
+ * Open the system colour picker. Chrome keeps the input's current
1591
+ * value as the starting colour, so seed it from the slot first; the
1592
+ * in-app sliders/HEX box stay as the precise path.
1593
+ */
1594
+ const openNativePicker = useCallback(() => {
1595
+ const input = nativeColorRef.current;
1596
+ const current = docRef.current;
1597
+ const index = editingRef.current;
1598
+ if (!input || !current || index === null) return;
1599
+ input.value = /^#[0-9a-f]{6}$/i.test(current.palette[index] || '')
1600
+ ? current.palette[index]
1601
+ : '#000000';
1602
+ try {
1603
+ if (typeof input.showPicker === 'function') {
1604
+ input.showPicker();
1605
+ return;
1606
+ }
1607
+ } catch {
1608
+ /* showPicker can throw without a user gesture — fall back */
1609
+ }
1610
+ input.click();
1611
+ }, []);
1612
+
1613
+ const onNativeColor = useCallback((event) => {
1614
+ const index = editingRef.current;
1615
+ if (index === null) return;
1616
+ const value = String(event.target.value || '').toLowerCase();
1617
+ if (!/^#[0-9a-f]{6}$/.test(value)) return;
1618
+ applyEditColor(index, value);
1619
+ if (hexInputRef.current) hexInputRef.current.value = value.toUpperCase();
1620
+ }, [applyEditColor]);
1621
+
1622
+ const addColor = useCallback(() => {
1623
+ const current = docRef.current;
1624
+ if (!current) return;
1625
+ if (current.palette.length >= CHARS.length) {
1626
+ setNotice(`调色板最多 ${CHARS.length} 色,先删掉一个再加`);
1627
+ return;
1628
+ }
1629
+ const palette = current.palette.concat(nextFreeColor(current.palette));
1630
+ updatePalette(palette, { color: palette.length - 1 });
1631
+ setEditing(palette.length - 1);
1632
+ }, [updatePalette]);
1633
+
1634
+ const removeColor = useCallback((index) => {
1635
+ const current = docRef.current;
1636
+ if (!current || current.palette.length <= 1) return;
1637
+ const palette = current.palette.slice();
1638
+ palette.splice(index, 1);
1639
+ // Pixels that used the removed slot become transparent.
1640
+ const layers = current.layers.map((layer) => ({
1641
+ ...layer,
1642
+ rows: layer.rows.map((row) => row.split('').map((ch) => {
1643
+ const value = indexFor(ch);
1644
+ if (value < 0) return '.';
1645
+ if (value === index) return '.';
1646
+ return value > index ? charFor(value - 1) : ch;
1647
+ }).join('')),
1648
+ }));
1649
+ const next = { ...current, palette, layers };
1650
+ docRef.current = next;
1651
+ setDoc(next);
1652
+ setColorIndex((value) => Math.max(0, Math.min(value, palette.length - 1)));
1653
+ // Keep the color editor pointing at a slot that still exists.
1654
+ setEditing((value) => {
1655
+ if (value === null) return null;
1656
+ if (value === index) return null;
1657
+ return value > index ? value - 1 : value;
1658
+ });
1659
+ requestPaint();
1660
+ scheduleSave();
1661
+ }, [requestPaint, scheduleSave]);
1662
+
1663
+ /**
1664
+ * Presets only ever *add* colors. Existing slots keep their index, so every
1665
+ * pixel already on the canvas keeps the exact color it was drawn with.
1666
+ */
1667
+ const applyPreset = useCallback((preset) => {
1668
+ const current = docRef.current;
1669
+ if (!current) return;
1670
+ const palette = current.palette.slice();
1671
+ let added = 0;
1672
+ preset.colors.slice().forEach((color) => {
1673
+ if (palette.length >= CHARS.length) return;
1674
+ if (palette.indexOf(color) !== -1) return;
1675
+ palette.push(color);
1676
+ added += 1;
1677
+ });
1678
+ if (added === 0) {
1679
+ setNotice('预设颜色已全部在色板里,画布未改动');
1680
+ return;
1681
+ }
1682
+ updatePalette(palette, {});
1683
+ setNotice(`加入 ${added} 个预设色(第 ${current.palette.length + 1} 号起,画布未改动)`);
1684
+ }, [updatePalette]);
1685
+
1686
+ /* -------------------------------------------------------------- */
1687
+ /* layers */
1688
+ /* -------------------------------------------------------------- */
1689
+
1690
+ const snapshotLayers = () => {
1691
+ const current = docRef.current;
1692
+ if (!current) return null;
1693
+ const history = historyRef.current;
1694
+ history.undo.push({
1695
+ palette: current.palette.slice(),
1696
+ layers: current.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
1697
+ });
1698
+ if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
1699
+ history.redo = [];
1700
+ return current;
1701
+ };
1702
+
1703
+ const writeLayers = (base, layers) => {
1704
+ const next = { ...base, layers };
1705
+ docRef.current = next;
1706
+ setDoc(next);
1707
+ requestPaint();
1708
+ scheduleSave();
1709
+ };
1710
+
1711
+ const selectLayer = (index) => {
1712
+ const current = docRef.current;
1713
+ if (!current || index < 0 || index >= current.layers.length) return;
1714
+ layerRef.current = index;
1715
+ setLayerIndex(index);
1716
+ };
1717
+
1718
+ const addLayer = () => {
1719
+ const current = docRef.current;
1720
+ if (!current) return;
1721
+ if (current.layers.length >= MAX_LAYERS) {
1722
+ setNotice(`最多 ${MAX_LAYERS} 个图层`);
1723
+ return;
1724
+ }
1725
+ const at = Math.min(current.layers.length, Math.max(1, layerRef.current + 1));
1726
+ const base = snapshotLayers();
1727
+ const layers = base.layers.slice();
1728
+ layers.splice(at, 0, {
1729
+ name: `图层 ${base.layers.length + 1}`.slice(0, 60),
1730
+ visible: true,
1731
+ rows: blankRows(base.w, base.h),
1732
+ });
1733
+ writeLayers(base, layers);
1734
+ selectLayer(at);
1735
+ setNotice(`已新建图层(第 ${at + 1} 层)`);
1736
+ };
1737
+
1738
+ const deleteLayer = (index) => {
1739
+ const current = docRef.current;
1740
+ if (!current || !current.layers[index]) return;
1741
+ if (current.layers.length <= 1) {
1742
+ setNotice('至少要保留一个图层');
1743
+ return;
1744
+ }
1745
+ const base = snapshotLayers();
1746
+ const layers = base.layers.filter((_, position) => position !== index);
1747
+ writeLayers(base, layers);
1748
+ selectLayer(Math.max(0, Math.min(index - 1, layers.length - 1)));
1749
+ setNotice('已删除图层');
1750
+ };
1751
+
1752
+ const toggleLayerVisible = (index) => {
1753
+ const current = docRef.current;
1754
+ if (!current || !current.layers[index]) return;
1755
+ const base = snapshotLayers();
1756
+ const layers = base.layers.map((layer, position) => (
1757
+ position === index ? { ...layer, visible: layer.visible === false } : layer
1758
+ ));
1759
+ writeLayers(base, layers);
1760
+ };
1761
+
1762
+ const moveLayer = (index, delta) => {
1763
+ const current = docRef.current;
1764
+ if (!current) return;
1765
+ const to = index + delta;
1766
+ if (to < 0 || to >= current.layers.length) return;
1767
+ const base = snapshotLayers();
1768
+ const layers = base.layers.slice();
1769
+ const moved = layers.splice(index, 1)[0];
1770
+ layers.splice(to, 0, moved);
1771
+ writeLayers(base, layers);
1772
+ selectLayer(to);
1773
+ };
1774
+
1775
+ const duplicateLayer = (index) => {
1776
+ const current = docRef.current;
1777
+ if (!current || !current.layers[index]) return;
1778
+ if (current.layers.length >= MAX_LAYERS) {
1779
+ setNotice(`最多 ${MAX_LAYERS} 个图层`);
1780
+ return;
1781
+ }
1782
+ const base = snapshotLayers();
1783
+ const layers = base.layers.slice();
1784
+ const source = layers[index];
1785
+ layers.splice(index + 1, 0, {
1786
+ name: `${source.name} 副本`.slice(0, 60),
1787
+ visible: source.visible !== false,
1788
+ rows: source.rows.slice(),
1789
+ });
1790
+ writeLayers(base, layers);
1791
+ selectLayer(index + 1);
1792
+ setNotice('已复制图层');
1793
+ };
1794
+
1795
+ const mergeLayerDown = (index) => {
1796
+ const current = docRef.current;
1797
+ if (!current || index <= 0 || !current.layers[index]) return;
1798
+ const base = snapshotLayers();
1799
+ const layers = base.layers.slice();
1800
+ const upper = layers[index];
1801
+ const lower = layers[index - 1];
1802
+ const rows = lower.rows.map((row, y) => {
1803
+ const over = upper.rows[y] || '.'.repeat(base.w);
1804
+ const cells = row.split('');
1805
+ for (let x = 0; x < base.w; x += 1) {
1806
+ const ch = over[x];
1807
+ if (ch !== undefined && indexFor(ch) >= 0) cells[x] = ch;
1808
+ }
1809
+ return cells.join('');
1810
+ });
1811
+ layers.splice(index - 1, 2, {
1812
+ name: lower.name.slice(0, 60),
1813
+ visible: lower.visible !== false || upper.visible !== false,
1814
+ rows,
1815
+ });
1816
+ writeLayers(base, layers);
1817
+ selectLayer(index - 1);
1818
+ setNotice('已向下合并');
1819
+ };
1820
+
1821
+ const startRename = (index) => {
1822
+ const current = docRef.current;
1823
+ if (!current || !current.layers[index]) return;
1824
+ setRenameValue(current.layers[index].name);
1825
+ setRenaming(index);
1826
+ };
1827
+
1828
+ const commitRename = () => {
1829
+ const current = docRef.current;
1830
+ const index = renaming;
1831
+ setRenaming(-1);
1832
+ if (!current || index < 0 || !current.layers[index]) return;
1833
+ const name = renameValue.trim().slice(0, 60) || current.layers[index].name;
1834
+ if (name === current.layers[index].name) return;
1835
+ const base = snapshotLayers();
1836
+ const layers = base.layers.map((layer, position) => (
1837
+ position === index ? { ...layer, name } : layer
1838
+ ));
1839
+ writeLayers(base, layers);
1840
+ };
1841
+
1842
+ /* -------------------------------------------------------------- */
1843
+ /* panel resizing */
1844
+ /* -------------------------------------------------------------- */
1845
+
1846
+ const applyWidth = (side, value) => {
1847
+ const main = mainRef.current;
1848
+ if (!main) return;
1849
+ main.style.setProperty(side === 'left' ? '--pxs-left' : '--pxs-right', `${value}px`);
1850
+ sizeRef.current = side === 'left'
1851
+ ? { left: value, right: sizeRef.current.right }
1852
+ : { left: sizeRef.current.left, right: value };
1853
+ };
1854
+
1855
+ const beginResize = (side) => (event) => {
1856
+ if (event.button !== 0) return;
1857
+ event.preventDefault();
1858
+ event.stopPropagation();
1859
+ const main = mainRef.current;
1860
+ if (!main) return;
1861
+ const handle = event.currentTarget;
1862
+ if (handle) handle.setAttribute('data-dragging', 'true');
1863
+ const startX = event.clientX;
1864
+ const start = side === 'left' ? leftWidth : rightWidth;
1865
+ let latest = start;
1866
+ const move = (moveEvent) => {
1867
+ const delta = moveEvent.clientX - startX;
1868
+ latest = side === 'left'
1869
+ ? clamp(start + delta, 132, 360)
1870
+ : clamp(start - delta, 152, 420);
1871
+ applyWidth(side, latest);
1872
+ requestPaint();
1873
+ };
1874
+ const stop = () => {
1875
+ window.removeEventListener('pointermove', move);
1876
+ window.removeEventListener('pointerup', stop);
1877
+ window.removeEventListener('pointercancel', stop);
1878
+ if (handle) handle.removeAttribute('data-dragging');
1879
+ if (side === 'left') setLeftWidth(latest);
1880
+ else setRightWidth(latest);
1881
+ sizeRef.current = side === 'left'
1882
+ ? { left: latest, right: sizeRef.current.right }
1883
+ : { left: sizeRef.current.left, right: latest };
1884
+ storeValue(side === 'left' ? 'pxs:left' : 'pxs:right', Math.round(latest));
1885
+ requestPaint();
1886
+ };
1887
+ window.addEventListener('pointermove', move);
1888
+ window.addEventListener('pointerup', stop);
1889
+ window.addEventListener('pointercancel', stop);
1890
+ };
1891
+
1892
+
1893
+
1894
+ const statusText = status === 'saving' ? '保存中…'
1895
+ : status === 'dirty' ? '未保存'
1896
+ : status === 'error' ? '保存失败'
1897
+ : '已保存';
1898
+
1899
+ const currentLayer = doc ? doc.layers[layerIndex] : null;
1900
+
1901
+ const swatchNodes = doc ? doc.palette.map((color, index) => h('button', {
1902
+ key: `${color}-${index}`,
1903
+ className: 'pxs-swatch',
1904
+ title: `#${index}: ${color}`,
1905
+ 'data-selected': index === colorIndex,
1906
+ style: { background: color },
1907
+ onPointerDown: (event) => event.stopPropagation(),
1908
+ onClick: () => {
1909
+ setColorIndex(index);
1910
+ colorRef.current = index;
1911
+ if (tool === 'eraser') setTool('pencil');
1912
+ },
1913
+ onContextMenu: (event) => {
1914
+ event.preventDefault();
1915
+ setMenu({ index, x: event.clientX, y: event.clientY });
1916
+ },
1917
+ onDoubleClick: () => editColor(index),
1918
+ })) : [];
1919
+
1920
+ const barNode = h('div', { className: 'pxs-bar' },
1921
+ h('select', {
1922
+ className: 'pxs-input',
1923
+ style: { maxWidth: 168 },
1924
+ value: doc ? doc.id : '',
1925
+ onChange: (event) => {
1926
+ const id = event.target.value;
1927
+ if (id) void openDoc(id).catch((cause) => setError(String(cause)));
1928
+ },
1929
+ }, docs.length === 0
1930
+ ? h('option', { value: '' }, '还没有作品')
1931
+ : docs.map((entry) => h('option', { key: entry.id, value: entry.id }, entry.name))),
1932
+ h('input', {
1933
+ className: 'pxs-input',
1934
+ style: { width: 148 },
1935
+ value: doc ? doc.name : '',
1936
+ disabled: !doc,
1937
+ placeholder: '作品名',
1938
+ onChange: (event) => {
1939
+ const current = docRef.current;
1940
+ if (!current) return;
1941
+ const next = { ...current, name: event.target.value };
1942
+ docRef.current = next;
1943
+ setDoc(next);
1944
+ scheduleSave();
1945
+ },
1946
+ }),
1947
+ h('button', {
1948
+ className: 'pxs-btn',
1949
+ onClick: openNewDialog,
1950
+ title: '新建作品(可选名字与尺寸)',
1951
+ }, '新建'),
1952
+ h('span', { className: 'pxs-spacer' }),
1953
+ h('button', {
1954
+ className: 'pxs-btn pxs-btn-icon',
1955
+ title: '撤销 (Ctrl+Z)',
1956
+ disabled: historyRef.current.undo.length === 0,
1957
+ onClick: undo,
1958
+ }, h(UndoIcon, null)),
1959
+ h('button', {
1960
+ className: 'pxs-btn pxs-btn-icon',
1961
+ title: '重做 (Ctrl+Shift+Z)',
1962
+ disabled: historyRef.current.redo.length === 0,
1963
+ onClick: redo,
1964
+ }, h(UndoIcon, { flipped: true })),
1965
+ h('button', {
1966
+ className: 'pxs-btn',
1967
+ title: '缩小 (-)',
1968
+ onClick: () => {
1969
+ const position = ZOOMS.indexOf(scaleRef.current);
1970
+ const next = ZOOMS[Math.max(0, (position === -1 ? 0 : position) - 1)];
1971
+ scaleRef.current = next;
1972
+ setZoom(next);
1973
+ requestPaint();
1974
+ },
1975
+ }, '−'),
1976
+ h('span', { className: 'pxs-hex', style: { minWidth: 42, textAlign: 'center' } }, `${zoom}×`),
1977
+ h('button', {
1978
+ className: 'pxs-btn',
1979
+ title: '放大 (+)',
1980
+ onClick: () => {
1981
+ const position = ZOOMS.indexOf(scaleRef.current);
1982
+ const next = ZOOMS[Math.min(ZOOMS.length - 1, (position === -1 ? 0 : position) + 1)];
1983
+ scaleRef.current = next;
1984
+ setZoom(next);
1985
+ requestPaint();
1986
+ },
1987
+ }, '+'),
1988
+ h('button', { className: 'pxs-btn', title: '适应窗口 (F)', onClick: fitToView }, '适应'),
1989
+ h('button', {
1990
+ className: 'pxs-btn',
1991
+ 'data-active': showGrid,
1992
+ title: '显示/隐藏格子边界 (H)',
1993
+ onClick: () => {
1994
+ const next = !gridRef.current;
1995
+ gridRef.current = next;
1996
+ setShowGrid(next);
1997
+ storeValue('pxs:grid', next ? '1' : '0');
1998
+ requestPaint();
1999
+ },
2000
+ }, showGrid ? '格线开' : '格线关'),
2001
+ h('button', {
2002
+ className: 'pxs-btn',
2003
+ disabled: !doc,
2004
+ onClick: () => void exportPng(),
2005
+ title: doc && typeof doc.filePath === 'string' && doc.filePath !== ''
2006
+ ? `覆盖保存到 ${doc.filePath}`
2007
+ : '导出 PNG(首次导出会先选保存位置)',
2008
+ }, '导出'),
2009
+ h('button', {
2010
+ className: 'pxs-btn',
2011
+ disabled: !doc,
2012
+ onClick: () => void saveAsPng(),
2013
+ title: '另存为 PNG (Ctrl+Shift+S)',
2014
+ }, '另存为'),
2015
+ h('button', {
2016
+ className: 'pxs-btn',
2017
+ disabled: !doc,
2018
+ onClick: () => void deleteDoc(),
2019
+ title: '删除当前作品',
2020
+ }, '删除'),
2021
+ h('span', { className: 'pxs-status', 'data-state': status }, statusText),
2022
+ notice ? h('span', { className: 'pxs-notice', title: notice }, notice) : null);
2023
+
2024
+ const errorNode = error ? h('div', { className: 'pxs-error' },
2025
+ `${error}`,
2026
+ h('button', {
2027
+ className: 'pxs-btn',
2028
+ style: { marginLeft: 8, height: 20 },
2029
+ onClick: () => setError(''),
2030
+ }, '知道了')) : null;
2031
+
2032
+ const toolsSection = h('div', { className: 'pxs-section' },
2033
+ h('p', { className: 'pxs-title' }, '工具'),
2034
+ h('div', { className: 'pxs-row' }, TOOLS.map((entry) => h('button', {
2035
+ key: entry.id,
2036
+ className: 'pxs-btn pxs-btn-icon',
2037
+ title: `${entry.label} (${entry.key})`,
2038
+ 'data-active': tool === entry.id,
2039
+ onClick: () => setTool(entry.id),
2040
+ }, h(ToolIcon, { name: entry.icon })))));
2041
+
2042
+ const brushSection = h('div', { className: 'pxs-section' },
2043
+ h('p', { className: 'pxs-title' }, '笔刷'),
2044
+ h('div', { className: 'pxs-row' },
2045
+ [1, 2, 3, 4].map((size) => h('button', {
2046
+ key: size,
2047
+ className: 'pxs-btn',
2048
+ style: { width: 28, padding: 0 },
2049
+ 'data-active': brush === size,
2050
+ title: `${size}×${size} 笔刷 ([ / ])`,
2051
+ onClick: () => setBrush(size),
2052
+ }, String(size)))));
2053
+
2054
+ const layerButtons = h('div', { className: 'pxs-row', style: { gap: 4, marginBottom: 6, flexWrap: 'wrap' } },
2055
+ h('button', {
2056
+ className: 'pxs-btn',
2057
+ style: { padding: '0 8px', height: 24 },
2058
+ title: '新建图层',
2059
+ disabled: !doc || doc.layers.length >= MAX_LAYERS,
2060
+ onClick: addLayer,
2061
+ }, '+'),
2062
+ h('button', {
2063
+ className: 'pxs-btn',
2064
+ style: { padding: '0 8px', height: 24 },
2065
+ title: '复制当前图层',
2066
+ disabled: !doc,
2067
+ onClick: () => duplicateLayer(layerIndex),
2068
+ }, '复制'),
2069
+ h('button', {
2070
+ className: 'pxs-btn',
2071
+ style: { padding: '0 8px', height: 24 },
2072
+ title: '与下面一层合并',
2073
+ disabled: !doc || layerIndex <= 0,
2074
+ onClick: () => mergeLayerDown(layerIndex),
2075
+ }, '合并'),
2076
+ h('button', {
2077
+ className: 'pxs-btn',
2078
+ style: { padding: '0 6px', height: 24 },
2079
+ title: '上移一层',
2080
+ disabled: !doc || layerIndex >= doc.layers.length - 1,
2081
+ onClick: () => moveLayer(layerIndex, 1),
2082
+ }, '↑'),
2083
+ h('button', {
2084
+ className: 'pxs-btn',
2085
+ style: { padding: '0 6px', height: 24 },
2086
+ title: '下移一层',
2087
+ disabled: !doc || layerIndex <= 0,
2088
+ onClick: () => moveLayer(layerIndex, -1),
2089
+ }, '↓'),
2090
+ h('button', {
2091
+ className: 'pxs-btn',
2092
+ style: { padding: '0 8px', height: 24 },
2093
+ title: '删除当前图层',
2094
+ disabled: !doc || doc.layers.length <= 1,
2095
+ onClick: () => deleteLayer(layerIndex),
2096
+ }, '删除'));
2097
+
2098
+ const layerRows = doc ? doc.layers
2099
+ .map((_, index) => index)
2100
+ .reverse()
2101
+ .map((index) => {
2102
+ const layer = doc.layers[index];
2103
+ const hidden = layer.visible === false;
2104
+ return h('div', {
2105
+ key: index,
2106
+ className: 'pxs-layer',
2107
+ 'data-active': index === layerIndex,
2108
+ 'data-hidden': hidden,
2109
+ title: '单击选中,双击改名',
2110
+ onClick: () => selectLayer(index),
2111
+ onDoubleClick: () => startRename(index),
2112
+ },
2113
+ h('span', {
2114
+ className: 'pxs-layer-eye',
2115
+ title: hidden ? '显示图层' : '隐藏图层',
2116
+ onClick: (event) => {
2117
+ event.stopPropagation();
2118
+ toggleLayerVisible(index);
2119
+ },
2120
+ }, hidden ? '○' : '●'),
2121
+ renaming === index
2122
+ ? h('input', {
2123
+ className: 'pxs-input pxs-layer-rename',
2124
+ value: renameValue,
2125
+ autoFocus: true,
2126
+ onChange: (event) => setRenameValue(event.target.value),
2127
+ onClick: (event) => event.stopPropagation(),
2128
+ onBlur: commitRename,
2129
+ onKeyDown: (event) => {
2130
+ if (event.key === 'Enter') {
2131
+ event.preventDefault();
2132
+ commitRename();
2133
+ } else if (event.key === 'Escape') {
2134
+ event.preventDefault();
2135
+ setRenaming(-1);
2136
+ }
2137
+ },
2138
+ })
2139
+ : h('span', { className: 'pxs-layer-name' }, layer.name));
2140
+ }) : null;
2141
+
2142
+ const layerSection = h('div', { className: 'pxs-section' },
2143
+ h('p', { className: 'pxs-title' }, '图层'),
2144
+ layerButtons,
2145
+ h('div', { className: 'pxs-layers' }, layerRows),
2146
+ h('p', { className: 'pxs-hint', style: { marginTop: 6 } },
2147
+ `最多 ${MAX_LAYERS} 层,双击名字可改名`));
2148
+
2149
+ const canvasSection = h('div', { className: 'pxs-section pxs-hide-narrow' },
2150
+ h('p', { className: 'pxs-title' }, '画布'),
2151
+ h('div', { className: 'pxs-meta' },
2152
+ h('span', null, '尺寸'), h('b', null, doc ? `${doc.w} × ${doc.h}` : '—'),
2153
+ h('span', null, '颜色'), h('b', null, doc ? String(doc.palette.length) : '—'),
2154
+ h('span', null, '缩放'), h('b', null, `${zoom}×`)));
2155
+
2156
+ const keysSection = h('div', { className: 'pxs-section pxs-hide-narrow' },
2157
+ h('p', { className: 'pxs-title' }, '快捷键'),
2158
+ h('div', { className: 'pxs-keys' },
2159
+ h('div', null, h('kbd', null, 'B'), '铅笔 ', h('kbd', null, 'E'), '橡皮'),
2160
+ h('div', null, h('kbd', null, 'G'), '填充 ', h('kbd', null, 'I'), '吸管'),
2161
+ h('div', null, h('kbd', null, '滚轮'), '缩放 ', h('kbd', null, '空格'), '+ 拖动'),
2162
+ h('div', null, h('kbd', null, 'F'), '适应窗口 ', h('kbd', null, '[ ]'), '笔刷'),
2163
+ h('div', null, h('kbd', null, 'H'), '格线开/关 ', h('kbd', null, 'Ctrl+S'), '保存')));
2164
+
2165
+ const leftPanel = h('div', { className: 'pxs-panel pxs-panel-left' },
2166
+ toolsSection, brushSection, layerSection, canvasSection, keysSection);
2167
+
2168
+ const canvasNode = h('canvas', {
2169
+ className: 'pxs-canvas',
2170
+ ref: canvasRef,
2171
+ onPointerDown,
2172
+ onPointerMove,
2173
+ onPointerUp,
2174
+ onPointerCancel: onPointerUp,
2175
+ onContextMenu: (event) => event.preventDefault(),
2176
+ onDragStart: (event) => event.preventDefault(),
2177
+ });
2178
+
2179
+ const emptyNode = !doc && !loading ? h('div', { className: 'pxs-empty' },
2180
+ h('h3', null, '画一张像素画'),
2181
+ h('p', null, '点「新建作品」填个名字、选个尺寸(也可以自己填宽高)。作品保存在本机,随时可以继续画或导出 PNG。'),
2182
+ h('button', {
2183
+ className: 'pxs-btn pxs-btn-primary',
2184
+ style: { height: 34, padding: '0 18px' },
2185
+ disabled: creating,
2186
+ onClick: openNewDialog,
2187
+ }, '新建作品')) : null;
2188
+
2189
+ const loadingNode = loading ? h('div', { className: 'pxs-empty' }, h('p', null, '加载作品中…')) : null;
2190
+
2191
+ const stageNode = h('div', { className: 'pxs-stage', ref: stageRef },
2192
+ canvasNode, emptyNode, loadingNode);
2193
+
2194
+ const colorSection = h('div', { className: 'pxs-section' },
2195
+ h('p', { className: 'pxs-title' }, '当前颜色'),
2196
+ h('div', { className: 'pxs-primary' },
2197
+ h('div', {
2198
+ className: 'pxs-chip',
2199
+ style: { background: doc && doc.palette[colorIndex] ? doc.palette[colorIndex] : '#000000' },
2200
+ }),
2201
+ h('div', null,
2202
+ h('div', { className: 'pxs-hex', style: { fontSize: 12, color: 'inherit' } },
2203
+ doc && doc.palette[colorIndex] ? doc.palette[colorIndex].toUpperCase() : '—'),
2204
+ h('div', { className: 'pxs-hint' }, `色板 #${colorIndex}`))));
2205
+
2206
+ const paletteSection = h('div', { className: 'pxs-section' },
2207
+ h('p', { className: 'pxs-title' }, '调色板'),
2208
+ h('div', { className: 'pxs-swatches' },
2209
+ swatchNodes,
2210
+ h('button', {
2211
+ className: 'pxs-swatch-add',
2212
+ title: '添加颜色',
2213
+ disabled: !doc,
2214
+ onPointerDown: (event) => event.stopPropagation(),
2215
+ onClick: addColor,
2216
+ }, '+')),
2217
+ h('p', { className: 'pxs-hint', style: { marginTop: 6 } }, '点选颜色 · 双击改色 · 右键更多'));
2218
+
2219
+ const presetSection = h('div', { className: 'pxs-section' },
2220
+ h('p', { className: 'pxs-title' }, '预设色板'),
2221
+ h('div', { className: 'pxs-row' }, PRESETS.map((preset) => h('button', {
2222
+ key: preset.id,
2223
+ className: 'pxs-btn',
2224
+ disabled: !doc,
2225
+ title: preset.colors.join(' '),
2226
+ onClick: () => applyPreset(preset),
2227
+ }, preset.name))));
2228
+
2229
+ const editColorValue = doc && editing !== null && editing < doc.palette.length ? doc.palette[editing] : null;
2230
+ const editRgb = editColorValue ? hexToRgb(editColorValue) : [0, 0, 0];
2231
+
2232
+ const colorEditNode = editColorValue === null ? null : h('div', { className: 'pxs-section pxs-color-edit' },
2233
+ h('p', { className: 'pxs-title' }, `改色 #${editing}`),
2234
+ h('div', { className: 'pxs-color-edit-head' },
2235
+ h('input', {
2236
+ key: `native-${editing}`,
2237
+ ref: nativeColorRef,
2238
+ type: 'color',
2239
+ className: 'pxs-native-color',
2240
+ defaultValue: editColorValue,
2241
+ onChange: onNativeColor,
2242
+ title: '点开系统取色器(可视化色域)',
2243
+ }),
2244
+ h('input', {
2245
+ key: `hex-${editing}`,
2246
+ ref: hexInputRef,
2247
+ className: 'pxs-input pxs-hex-input',
2248
+ defaultValue: String(editColorValue).toUpperCase(),
2249
+ spellCheck: false,
2250
+ title: 'Enter 应用 · Esc 关闭',
2251
+ onKeyDown: (event) => {
2252
+ if (event.key === 'Enter') setEditHex(event.currentTarget.value);
2253
+ else if (event.key === 'Escape') endEdit();
2254
+ event.stopPropagation();
2255
+ },
2256
+ onBlur: (event) => setEditHex(event.currentTarget.value),
2257
+ }),
2258
+ h('button', { className: 'pxs-btn', onClick: endEdit, title: '关闭改色面板' }, '完成')),
2259
+ h('button', {
2260
+ className: 'pxs-btn pxs-picker-btn',
2261
+ onClick: openNativePicker,
2262
+ title: '打开系统取色器',
2263
+ }, '取色器…'),
2264
+ h('p', { className: 'pxs-hint' }, '改色只改这个色板槽位,画布上已有的像素颜色不变。'),
2265
+ h('div', { className: 'pxs-color-edit-sliders' },
2266
+ ['R', 'G', 'B'].map((label, channel) => h('label', { key: label, className: 'pxs-slider-row' },
2267
+ h('span', { className: 'pxs-slider-label' }, label),
2268
+ h('input', {
2269
+ className: 'pxs-range',
2270
+ type: 'range',
2271
+ min: 0,
2272
+ max: 255,
2273
+ step: 1,
2274
+ value: editRgb[channel],
2275
+ onChange: (event) => setEditChannel(channel, event.target.value),
2276
+ }),
2277
+ h('span', { className: 'pxs-slider-value' }, String(editRgb[channel]))))),
2278
+ h('div', { className: 'pxs-row' },
2279
+ h('button', {
2280
+ className: 'pxs-btn',
2281
+ onClick: () => {
2282
+ setColorIndex(editing);
2283
+ colorRef.current = editing;
2284
+ },
2285
+ title: '把这个颜色设为当前画笔色',
2286
+ }, '选为当前色'),
2287
+ h('button', {
2288
+ className: 'pxs-btn',
2289
+ onClick: () => {
2290
+ removeColor(editing);
2291
+ endEdit();
2292
+ },
2293
+ disabled: !doc || doc.palette.length <= 1,
2294
+ }, '删除该色')));
2295
+
2296
+ const rightPanel = h('div', { className: 'pxs-panel pxs-panel-right' },
2297
+ colorSection, paletteSection, colorEditNode, presetSection);
2298
+
2299
+ const menuNode = menu ? h('div', {
2300
+ className: 'pxs-menu',
2301
+ style: {
2302
+ left: Math.min(menu.x, window.innerWidth - 150),
2303
+ top: Math.min(menu.y, window.innerHeight - 110),
2304
+ },
2305
+ onPointerDown: (event) => event.stopPropagation(),
2306
+ },
2307
+ h('button', {
2308
+ onClick: () => {
2309
+ setColorIndex(menu.index);
2310
+ colorRef.current = menu.index;
2311
+ setMenu(null);
2312
+ },
2313
+ }, '设为当前颜色'),
2314
+ h('button', {
2315
+ onClick: () => {
2316
+ editColor(menu.index);
2317
+ setMenu(null);
2318
+ },
2319
+ }, '编辑颜色'),
2320
+ h('button', {
2321
+ 'data-danger': 'true',
2322
+ onClick: () => {
2323
+ removeColor(menu.index);
2324
+ setMenu(null);
2325
+ },
2326
+ }, '删除颜色')) : null;
2327
+
2328
+ const dialogNode = saveDialog ? h('div', {
2329
+ className: 'pxs-modal',
2330
+ onPointerDown: (event) => {
2331
+ if (event.target === event.currentTarget) setSaveDialog(null);
2332
+ },
2333
+ },
2334
+ h('div', { className: 'pxs-dialog', role: 'dialog', 'aria-label': '保存 PNG' },
2335
+ h('div', { className: 'pxs-dialog-head' },
2336
+ h('strong', null, saveDialog.mode === 'saveAs' ? '另存为 PNG' : '选择保存位置'),
2337
+ h('button', { className: 'pxs-dialog-close', onClick: () => setSaveDialog(null) }, '×')),
2338
+ h('div', { className: 'pxs-dialog-row' },
2339
+ h('button', {
2340
+ className: 'pxs-btn',
2341
+ disabled: saveDialog.loading || !saveDialog.parent,
2342
+ title: '上级目录',
2343
+ onClick: () => void browseDir(saveDialog.parent),
2344
+ }, '↑'),
2345
+ h('input', {
2346
+ className: 'pxs-input',
2347
+ style: { flex: 1 },
2348
+ spellCheck: false,
2349
+ value: saveDialog.pathInput,
2350
+ onChange: (event) => {
2351
+ const value = event.target.value;
2352
+ setSaveDialog((current) => (current ? { ...current, pathInput: value } : current));
2353
+ },
2354
+ onKeyDown: (event) => {
2355
+ if (event.key === 'Enter') {
2356
+ event.preventDefault();
2357
+ void browseDir(saveDialog.pathInput);
2358
+ }
2359
+ },
2360
+ }),
2361
+ h('button', {
2362
+ className: 'pxs-btn',
2363
+ onClick: () => void browseDir(saveDialog.pathInput),
2364
+ }, '前往')),
2365
+ h('div', { className: 'pxs-dialog-list' },
2366
+ saveDialog.loading ? h('div', { className: 'pxs-dialog-empty' }, '读取目录…') : null,
2367
+ !saveDialog.loading && saveDialog.dirs.length === 0
2368
+ ? h('div', { className: 'pxs-dialog-empty' }, '这里没有子目录')
2369
+ : null,
2370
+ saveDialog.dirs.map((entry) => h('button', {
2371
+ key: entry.path,
2372
+ className: 'pxs-dialog-item',
2373
+ 'data-hidden': entry.hidden ? 'true' : 'false',
2374
+ title: entry.path,
2375
+ onClick: () => void browseDir(entry.path),
2376
+ }, entry.name))),
2377
+ h('div', { className: 'pxs-dialog-row' },
2378
+ h('span', { className: 'pxs-dialog-label' }, '文件名'),
2379
+ h('input', {
2380
+ className: 'pxs-input',
2381
+ style: { flex: 1 },
2382
+ spellCheck: false,
2383
+ value: saveDialog.name,
2384
+ onChange: (event) => {
2385
+ const value = event.target.value;
2386
+ setSaveDialog((current) => (current ? { ...current, name: value } : current));
2387
+ },
2388
+ onKeyDown: (event) => {
2389
+ if (event.key === 'Enter') {
2390
+ event.preventDefault();
2391
+ void confirmSave();
2392
+ }
2393
+ if (event.key === 'Escape') {
2394
+ event.preventDefault();
2395
+ setSaveDialog(null);
2396
+ }
2397
+ },
2398
+ })),
2399
+ saveDialog.error ? h('div', { className: 'pxs-dialog-error' }, saveDialog.error) : null,
2400
+ h('div', { className: 'pxs-dialog-actions' },
2401
+ h('button', { className: 'pxs-btn', onClick: () => setSaveDialog(null) }, '取消'),
2402
+ h('button', {
2403
+ className: 'pxs-btn pxs-btn-primary',
2404
+ disabled: saveDialog.busy || saveDialog.loading || saveDialog.path === '',
2405
+ onClick: () => void confirmSave(),
2406
+ }, saveDialog.busy ? '保存中…' : '保存')))) : null;
2407
+
2408
+ const newDialogNode = newDialog ? h('div', {
2409
+ className: 'pxs-modal',
2410
+ onPointerDown: (event) => {
2411
+ if (event.target === event.currentTarget && !creating) setNewDialog(null);
2412
+ },
2413
+ },
2414
+ h('div', { className: 'pxs-dialog pxs-new-dialog', role: 'dialog', 'aria-label': '新建作品' },
2415
+ h('div', { className: 'pxs-dialog-head' },
2416
+ h('strong', null, '新建作品'),
2417
+ h('button', {
2418
+ className: 'pxs-dialog-close',
2419
+ disabled: creating,
2420
+ onClick: () => setNewDialog(null),
2421
+ }, '×')),
2422
+ h('div', { className: 'pxs-dialog-row' },
2423
+ h('span', { className: 'pxs-dialog-label' }, '名字'),
2424
+ h('input', {
2425
+ className: 'pxs-input',
2426
+ style: { flex: 1 },
2427
+ spellCheck: false,
2428
+ autoFocus: true,
2429
+ value: newDialog.name,
2430
+ onChange: (event) => {
2431
+ const value = event.target.value;
2432
+ setNewDialog((current) => (current ? { ...current, name: value } : current));
2433
+ },
2434
+ onKeyDown: (event) => {
2435
+ if (event.key === 'Enter') {
2436
+ event.preventDefault();
2437
+ confirmNewDoc();
2438
+ }
2439
+ if (event.key === 'Escape') setNewDialog(null);
2440
+ event.stopPropagation();
2441
+ },
2442
+ })),
2443
+ h('div', { className: 'pxs-dialog-row' },
2444
+ h('span', { className: 'pxs-dialog-label' }, '宽'),
2445
+ h('input', {
2446
+ className: 'pxs-input',
2447
+ style: { width: 72 },
2448
+ type: 'number',
2449
+ min: 1,
2450
+ max: MAX_SIDE,
2451
+ value: newDialog.w,
2452
+ onChange: (event) => {
2453
+ const value = event.target.value;
2454
+ setNewDialog((current) => (current ? { ...current, w: value } : current));
2455
+ },
2456
+ }),
2457
+ h('span', { className: 'pxs-dialog-label' }, '高'),
2458
+ h('input', {
2459
+ className: 'pxs-input',
2460
+ style: { width: 72 },
2461
+ type: 'number',
2462
+ min: 1,
2463
+ max: MAX_SIDE,
2464
+ value: newDialog.h,
2465
+ onChange: (event) => {
2466
+ const value = event.target.value;
2467
+ setNewDialog((current) => (current ? { ...current, h: value } : current));
2468
+ },
2469
+ }),
2470
+ h('span', { className: 'pxs-hint' }, `最大 ${MAX_SIDE}`)),
2471
+ h('div', { className: 'pxs-size-grid' },
2472
+ NEW_PRESETS.map((preset) => h('button', {
2473
+ key: preset.label,
2474
+ className: 'pxs-size-chip',
2475
+ 'data-active': String(newDialog.w) === String(preset.w) && String(newDialog.h) === String(preset.h) ? 'true' : 'false',
2476
+ onClick: () => setNewDialog((current) => (current ? { ...current, w: preset.w, h: preset.h } : current)),
2477
+ },
2478
+ h('span', { className: 'pxs-size-label' }, preset.label),
2479
+ h('span', { className: 'pxs-size-hint' }, preset.hint)))),
2480
+ newDialog.error ? h('div', { className: 'pxs-dialog-error' }, newDialog.error) : null,
2481
+ h('div', { className: 'pxs-dialog-actions' },
2482
+ h('button', {
2483
+ className: 'pxs-btn',
2484
+ disabled: creating,
2485
+ onClick: () => setNewDialog(null),
2486
+ }, '取消'),
2487
+ h('button', {
2488
+ className: 'pxs-btn pxs-btn-primary',
2489
+ disabled: creating,
2490
+ onClick: confirmNewDoc,
2491
+ }, creating ? '创建中…' : '创建')))) : null;
2492
+
2493
+ return h('div', { className: 'pxs-root' },
2494
+ h('style', null, CSS),
2495
+ barNode,
2496
+ errorNode,
2497
+ h('div', {
2498
+ className: 'pxs-main',
2499
+ ref: mainRef,
2500
+ style: {
2501
+ '--pxs-left': `${leftWidth}px`,
2502
+ '--pxs-right': `${rightWidth}px`,
2503
+ },
2504
+ },
2505
+ leftPanel,
2506
+ h('div', {
2507
+ className: 'pxs-splitter',
2508
+ title: '拖动调整左栏宽度',
2509
+ onPointerDown: beginResize('left'),
2510
+ }),
2511
+ stageNode,
2512
+ h('div', {
2513
+ className: 'pxs-splitter',
2514
+ title: '拖动调整右栏宽度',
2515
+ onPointerDown: beginResize('right'),
2516
+ }),
2517
+ rightPanel),
2518
+ menuNode,
2519
+ dialogNode,
2520
+ newDialogNode);
2521
+ }
2522
+
2523
+ /**
2524
+ * Keeps a failure inside the Pixel Studio view: without this, a render error
2525
+ * takes down the whole conversation surface and the UI appears blank.
2526
+ */
2527
+ class PixelStudioBoundary extends React.Component {
2528
+ constructor(props) {
2529
+ super(props);
2530
+ this.state = { error: null };
2531
+ this.retry = () => this.setState({ error: null });
2532
+ }
2533
+
2534
+ static getDerivedStateFromError(error) {
2535
+ return { error };
2536
+ }
2537
+
2538
+ componentDidCatch(error) {
2539
+ console.error('[dsh-pixel-studio]', error);
2540
+ }
2541
+
2542
+ render() {
2543
+ const error = this.state.error;
2544
+ if (!error) return h(PixelStudio, this.props);
2545
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
2546
+ return h('div', { className: 'pxs-root' },
2547
+ h('style', null, CSS),
2548
+ h('div', {
2549
+ className: 'pxs-fallback',
2550
+ style: {
2551
+ flex: 1,
2552
+ display: 'flex',
2553
+ flexDirection: 'column',
2554
+ alignItems: 'flex-start',
2555
+ gap: '8px',
2556
+ padding: '24px',
2557
+ overflow: 'auto',
2558
+ },
2559
+ },
2560
+ h('strong', { style: { fontSize: '13px' } }, '像素画工作区出错了'),
2561
+ h('p', {
2562
+ style: {
2563
+ margin: 0,
2564
+ fontSize: '12px',
2565
+ color: 'var(--dsw-alias-label-secondary, #6b7280)',
2566
+ whiteSpace: 'pre-wrap',
2567
+ },
2568
+ }, message),
2569
+ h('div', { style: { display: 'flex', gap: '8px' } },
2570
+ h('button', { className: 'pxs-btn', onClick: this.retry }, '重试'),
2571
+ h('button', {
2572
+ className: 'pxs-btn',
2573
+ onClick: () => window.location.reload(),
2574
+ }, '刷新页面'))));
2575
+ }
2576
+ }
2577
+
2578
+ return {
2579
+ inject: ['slots'],
2580
+ apply(ctx) {
2581
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
2582
+ name: 'conversation.view',
2583
+ id: 'pixel-studio',
2584
+ order: 20,
2585
+ label: () => '像素画',
2586
+ }, PixelStudioBoundary));
2587
+ },
2588
+ };
2589
+ },
2590
+ });