@muxiuesd/dsh-pixel-studio 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/README.md +3 -1
- package/client.js +689 -29
- package/package.json +3 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# 更新记录
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- 新增「打开图片」:把 PNG / JPEG / GIF / WebP / BMP 直接转成像素画,自动缩到 256 以内,可选自动提取配色(中位切分)/ 当前调色板 / PICO-8,可开 Floyd–Steinberg 抖动,导入前先看预览。
|
|
6
|
+
- 「另存为」对话框新增导出倍率(1× / 2× / 3× / 4× / 6× / 8× / 12× / 16×,按单张 PNG 上限自动禁用超出的档位),选择会被记住;普通导出同样使用记住的倍率。
|
|
7
|
+
- 新增「尺寸」:调整画布宽高(1–256),9 宫格锚点决定裁剪或扩边的方向,扩边补透明,支持撤销。
|
|
8
|
+
- 撤销历史现在也记录画布尺寸,尺寸调整和绘制可以一起回退。
|
|
9
|
+
|
|
10
|
+
## 0.1.1
|
|
11
|
+
|
|
12
|
+
- 首个发布版本:三栏编辑器、铅笔 / 橡皮 / 油漆桶 / 吸管、1–8 方形笔刷、整数倍缩放、图层、36 色调色板(改色不动画布)、11 个新建尺寸预设、导出 1× PNG 到会话目录、`Ctrl+S` / `Ctrl+Shift+S`。
|
package/README.md
CHANGED
|
@@ -23,7 +23,9 @@ dsh plugin --profile web add /绝对路径/dsh-pixel
|
|
|
23
23
|
- **图层**:最多 8 层,新建/复制/合并/上移/下移/删除/改名/显隐,全部可撤销。
|
|
24
24
|
- **调色板**:最多 36 色,内置 PICO-8 等预设板;改某个槽位的颜色**不会改动已经画好的像素**(画布上的旧颜色会先由一个空闲槽位接手),也可以用系统取色器可视化取色。
|
|
25
25
|
- **作品**:新建时填名字和尺寸(1–256),另有 11 个常用尺寸预设;自动保存。
|
|
26
|
-
-
|
|
26
|
+
- **打开图片**:选一张喜欢的 PNG / JPEG / GIF / WebP / BMP,自动缩到 256 以内并按需提取配色(也可沿用当前调色板或 PICO-8),可开抖动,生成一个新作品继续画。
|
|
27
|
+
- **画布尺寸 / 裁剪**:`尺寸` 里改宽高(1–256),9 宫格锚点决定裁掉哪边或从哪边扩边,改完可以撤销。
|
|
28
|
+
- **导出**:首次导出选择保存位置(默认从当前会话目录开始),之后导出直接覆盖那个文件;`另存为` 随时换位置,并可挑 1×–16× 的导出倍率(会记住上次的选择)。
|
|
27
29
|
|
|
28
30
|
## 快捷键
|
|
29
31
|
|
package/client.js
CHANGED
|
@@ -65,6 +65,18 @@ window.__ModuleLoader__.load({
|
|
|
65
65
|
];
|
|
66
66
|
/** Keep in sync with MAX_SIDE in index.js. */
|
|
67
67
|
const MAX_SIDE = 256;
|
|
68
|
+
/** Keep in sync with MAX_EXPORT_PIXELS in index.js. */
|
|
69
|
+
const MAX_EXPORT_PIXELS = 4096 * 4096;
|
|
70
|
+
/** Magnifications offered by the Save-as dialog. */
|
|
71
|
+
const EXPORT_SCALES = [1, 2, 3, 4, 6, 8, 12, 16];
|
|
72
|
+
/** One character per palette slot, so an import can never use more colours than this. */
|
|
73
|
+
const IMPORT_MAX_COLORS = CHARS.length;
|
|
74
|
+
/** Where existing content sits when the canvas grows or shrinks. */
|
|
75
|
+
const ANCHORS = [
|
|
76
|
+
{ id: 'nw', label: '↖' }, { id: 'n', label: '↑' }, { id: 'ne', label: '↗' },
|
|
77
|
+
{ id: 'w', label: '←' }, { id: 'c', label: '·' }, { id: 'e', label: '→' },
|
|
78
|
+
{ id: 'sw', label: '↙' }, { id: 's', label: '↓' }, { id: 'se', label: '↘' },
|
|
79
|
+
];
|
|
68
80
|
|
|
69
81
|
const TOOLS = [
|
|
70
82
|
{ id: 'pencil', label: '铅笔', key: 'B', icon: 'pencil' },
|
|
@@ -346,6 +358,323 @@ window.__ModuleLoader__.load({
|
|
|
346
358
|
return /\.png$/i.test(trimmed) ? trimmed : `${trimmed}.png`;
|
|
347
359
|
}
|
|
348
360
|
|
|
361
|
+
/* ------------------------------------------------------------------ */
|
|
362
|
+
/* canvas resize */
|
|
363
|
+
/* ------------------------------------------------------------------ */
|
|
364
|
+
|
|
365
|
+
/** Undo snapshot; width/height ride along so a canvas resize can be undone. */
|
|
366
|
+
function snapshotOf(doc) {
|
|
367
|
+
return {
|
|
368
|
+
palette: doc.palette.slice(),
|
|
369
|
+
layers: doc.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
|
|
370
|
+
w: doc.w,
|
|
371
|
+
h: doc.h,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Offset that keeps existing pixels at the chosen anchor when the canvas changes size. */
|
|
376
|
+
function anchorOffsets(anchor, oldWidth, oldHeight, newWidth, newHeight) {
|
|
377
|
+
const id = typeof anchor === 'string' && anchor.length > 0 ? anchor : 'c';
|
|
378
|
+
const dx = id.includes('w') ? 0
|
|
379
|
+
: id.includes('e') ? newWidth - oldWidth
|
|
380
|
+
: Math.round((newWidth - oldWidth) / 2);
|
|
381
|
+
const dy = id.includes('n') ? 0
|
|
382
|
+
: id.includes('s') ? newHeight - oldHeight
|
|
383
|
+
: Math.round((newHeight - oldHeight) / 2);
|
|
384
|
+
return [dx, dy];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Re-frames one layer onto a new canvas: grown areas become transparent, cropped pixels go away. */
|
|
388
|
+
function resizeRows(rows, oldWidth, oldHeight, newWidth, newHeight, anchor) {
|
|
389
|
+
const [dx, dy] = anchorOffsets(anchor, oldWidth, oldHeight, newWidth, newHeight);
|
|
390
|
+
const next = [];
|
|
391
|
+
for (let y = 0; y < newHeight; y += 1) {
|
|
392
|
+
const sourceY = y - dy;
|
|
393
|
+
const row = sourceY >= 0 && sourceY < oldHeight ? String(rows[sourceY] === undefined ? '' : rows[sourceY]) : '';
|
|
394
|
+
let line = '';
|
|
395
|
+
for (let x = 0; x < newWidth; x += 1) {
|
|
396
|
+
const sourceX = x - dx;
|
|
397
|
+
const ch = sourceX >= 0 && sourceX < oldWidth ? row[sourceX] : undefined;
|
|
398
|
+
line += ch === undefined || ch === '' ? '.' : ch;
|
|
399
|
+
}
|
|
400
|
+
next.push(line);
|
|
401
|
+
}
|
|
402
|
+
return next;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/* ------------------------------------------------------------------ */
|
|
406
|
+
/* image import */
|
|
407
|
+
/* ------------------------------------------------------------------ */
|
|
408
|
+
|
|
409
|
+
/** Largest integer magnification whose PNG stays under the host's pixel cap. */
|
|
410
|
+
function maxScaleFor(width, height) {
|
|
411
|
+
return Math.max(1, Math.floor(Math.sqrt(MAX_EXPORT_PIXELS / Math.max(1, width * height))));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Remembered export magnification, clamped to what the host can encode for this canvas. */
|
|
415
|
+
function clampExportScale(value, width, height) {
|
|
416
|
+
const wanted = Math.round(Number(value) || 1);
|
|
417
|
+
if (!Number.isFinite(wanted) || wanted < 1) return 1;
|
|
418
|
+
const ceiling = Math.min(64, maxScaleFor(width, height));
|
|
419
|
+
return wanted > ceiling ? ceiling : wanted;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function clampByte(value) {
|
|
423
|
+
const number = Math.round(Number(value) || 0);
|
|
424
|
+
if (number < 0) return 0;
|
|
425
|
+
return number > 255 ? 255 : number;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function hex2(value) {
|
|
429
|
+
return clampByte(value).toString(16).padStart(2, '0');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function rgbOf(color) {
|
|
433
|
+
return [Number(color[0]) || 0, Number(color[1]) || 0, Number(color[2]) || 0];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function colorDistanceSq(a, b) {
|
|
437
|
+
const dr = a[0] - b[0];
|
|
438
|
+
const dg = a[1] - b[1];
|
|
439
|
+
const db = a[2] - b[2];
|
|
440
|
+
return dr * dr + dg * dg + db * db;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function nearestColorIndex(paletteRgb, color) {
|
|
444
|
+
let best = 0;
|
|
445
|
+
let bestDistance = Infinity;
|
|
446
|
+
for (let i = 0; i < paletteRgb.length; i += 1) {
|
|
447
|
+
const distance = colorDistanceSq(paletteRgb[i], color);
|
|
448
|
+
if (distance < bestDistance) {
|
|
449
|
+
bestDistance = distance;
|
|
450
|
+
best = i;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return best;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** RGB extent of a colour box: [ranges, channel with the widest range]. */
|
|
457
|
+
function boxExtent(box) {
|
|
458
|
+
let minR = 255; let minG = 255; let minB = 255;
|
|
459
|
+
let maxR = 0; let maxG = 0; let maxB = 0;
|
|
460
|
+
for (const color of box) {
|
|
461
|
+
if (color[0] < minR) minR = color[0];
|
|
462
|
+
if (color[1] < minG) minG = color[1];
|
|
463
|
+
if (color[2] < minB) minB = color[2];
|
|
464
|
+
if (color[0] > maxR) maxR = color[0];
|
|
465
|
+
if (color[1] > maxG) maxG = color[1];
|
|
466
|
+
if (color[2] > maxB) maxB = color[2];
|
|
467
|
+
}
|
|
468
|
+
const ranges = [maxR - minR, maxG - minG, maxB - minB];
|
|
469
|
+
let channel = 0;
|
|
470
|
+
if (ranges[1] > ranges[channel]) channel = 1;
|
|
471
|
+
if (ranges[2] > ranges[channel]) channel = 2;
|
|
472
|
+
return { ranges, channel, widest: ranges[channel] };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function averageColor(box) {
|
|
476
|
+
let r = 0; let g = 0; let b = 0;
|
|
477
|
+
for (const color of box) {
|
|
478
|
+
r += color[0]; g += color[1]; b += color[2];
|
|
479
|
+
}
|
|
480
|
+
const count = box.length === 0 ? 1 : box.length;
|
|
481
|
+
return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Median cut: fold thousands of photo colours into at most `limit` representative ones. */
|
|
485
|
+
function quantizeColors(colors, limit) {
|
|
486
|
+
if (colors.length <= limit) return colors.slice();
|
|
487
|
+
const boxes = [colors.slice()];
|
|
488
|
+
while (boxes.length < limit) {
|
|
489
|
+
let index = -1;
|
|
490
|
+
let score = -1;
|
|
491
|
+
for (let i = 0; i < boxes.length; i += 1) {
|
|
492
|
+
const box = boxes[i];
|
|
493
|
+
if (box.length < 2) continue;
|
|
494
|
+
const extent = boxExtent(box);
|
|
495
|
+
const weight = extent.widest * Math.min(box.length, 256);
|
|
496
|
+
if (weight > score) {
|
|
497
|
+
score = weight;
|
|
498
|
+
index = i;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (index === -1) break;
|
|
502
|
+
const box = boxes[index];
|
|
503
|
+
const channel = boxExtent(box).channel;
|
|
504
|
+
const sorted = box.slice().sort((a, b) => a[channel] - b[channel]);
|
|
505
|
+
const middle = Math.max(1, Math.floor(sorted.length / 2));
|
|
506
|
+
boxes.splice(index, 1, sorted.slice(0, middle), sorted.slice(middle));
|
|
507
|
+
}
|
|
508
|
+
return boxes.map(averageColor);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/** Most common opaque colours of a sampled image, as hex strings plus RGB triples. */
|
|
512
|
+
function paletteFromSample(sample, limit) {
|
|
513
|
+
const counts = new Map();
|
|
514
|
+
const total = sample.width * sample.height;
|
|
515
|
+
for (let i = 0; i < total; i += 1) {
|
|
516
|
+
if (sample.data[i * 4 + 3] < 128) continue;
|
|
517
|
+
const key = (sample.data[i * 4] << 16) | (sample.data[i * 4 + 1] << 8) | sample.data[i * 4 + 2];
|
|
518
|
+
counts.set(key, (counts.get(key) || 0) + 1);
|
|
519
|
+
}
|
|
520
|
+
const ordered = Array.from(counts.entries())
|
|
521
|
+
.sort((a, b) => b[1] - a[1])
|
|
522
|
+
.map(([key]) => [Number((key >> 16) & 255), Number((key >> 8) & 255), Number(key & 255)]);
|
|
523
|
+
const limited = ordered.length > limit ? quantizeColors(ordered, limit) : ordered;
|
|
524
|
+
const rgb = limited.map((color) => [color[0], color[1], color[2]]);
|
|
525
|
+
return {
|
|
526
|
+
palette: rgb.map(([r, g, b]) => `#${hex2(r)}${hex2(g)}${hex2(b)}`),
|
|
527
|
+
rgb,
|
|
528
|
+
original: ordered.length,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Maps sampled pixels onto the palette; optional Floyd–Steinberg dithering spreads the error. */
|
|
533
|
+
function buildImportRows(sample, paletteRgb, dither) {
|
|
534
|
+
const { width, height, data } = sample;
|
|
535
|
+
const work = new Float32Array(width * height * 3);
|
|
536
|
+
for (let i = 0; i < width * height; i += 1) {
|
|
537
|
+
work[i * 3] = data[i * 4];
|
|
538
|
+
work[i * 3 + 1] = data[i * 4 + 1];
|
|
539
|
+
work[i * 3 + 2] = data[i * 4 + 2];
|
|
540
|
+
}
|
|
541
|
+
const rows = [];
|
|
542
|
+
const spread = (x, y, error, dx, dy, weight) => {
|
|
543
|
+
const nx = x + dx;
|
|
544
|
+
const ny = y + dy;
|
|
545
|
+
if (nx < 0 || nx >= width || ny >= height) return;
|
|
546
|
+
const at = ny * width + nx;
|
|
547
|
+
if (data[at * 4 + 3] < 128) return;
|
|
548
|
+
work[at * 3] += error[0] * weight;
|
|
549
|
+
work[at * 3 + 1] += error[1] * weight;
|
|
550
|
+
work[at * 3 + 2] += error[2] * weight;
|
|
551
|
+
};
|
|
552
|
+
for (let y = 0; y < height; y += 1) {
|
|
553
|
+
let line = '';
|
|
554
|
+
for (let x = 0; x < width; x += 1) {
|
|
555
|
+
const at = y * width + x;
|
|
556
|
+
if (data[at * 4 + 3] < 128) {
|
|
557
|
+
line += '.';
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const color = [work[at * 3], work[at * 3 + 1], work[at * 3 + 2]];
|
|
561
|
+
const index = nearestColorIndex(paletteRgb, color);
|
|
562
|
+
line += charFor(index);
|
|
563
|
+
if (dither) {
|
|
564
|
+
const chosen = paletteRgb[index];
|
|
565
|
+
const error = [color[0] - chosen[0], color[1] - chosen[1], color[2] - chosen[2]];
|
|
566
|
+
spread(x, y, error, 1, 0, 7 / 16);
|
|
567
|
+
spread(x, y, error, -1, 1, 3 / 16);
|
|
568
|
+
spread(x, y, error, 0, 1, 5 / 16);
|
|
569
|
+
spread(x, y, error, 1, 1, 1 / 16);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
rows.push(line);
|
|
573
|
+
}
|
|
574
|
+
return rows;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** PNG data URL of a char grid, shown as the import preview. */
|
|
578
|
+
function previewDataUrl(palette, rows) {
|
|
579
|
+
const height = rows.length;
|
|
580
|
+
const width = height > 0 ? rows[0].length : 0;
|
|
581
|
+
if (width === 0 || height === 0) return '';
|
|
582
|
+
const canvas = document.createElement('canvas');
|
|
583
|
+
canvas.width = width;
|
|
584
|
+
canvas.height = height;
|
|
585
|
+
const context = canvas.getContext('2d');
|
|
586
|
+
if (!context) return '';
|
|
587
|
+
const image = context.createImageData(width, height);
|
|
588
|
+
const rgb = palette.map(hexToRgb);
|
|
589
|
+
for (let y = 0; y < height; y += 1) {
|
|
590
|
+
const row = rows[y];
|
|
591
|
+
for (let x = 0; x < width; x += 1) {
|
|
592
|
+
const index = indexFor(row === undefined ? '.' : row[x]);
|
|
593
|
+
const offset = (y * width + x) * 4;
|
|
594
|
+
if (index < 0 || index >= rgb.length) {
|
|
595
|
+
image.data[offset + 3] = 0;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
image.data[offset] = rgb[index][0];
|
|
599
|
+
image.data[offset + 1] = rgb[index][1];
|
|
600
|
+
image.data[offset + 2] = rgb[index][2];
|
|
601
|
+
image.data[offset + 3] = 255;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
context.putImageData(image, 0, 0);
|
|
605
|
+
return canvas.toDataURL('image/png');
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** Decoded pixels of a picked file, downscaled into the editor's size cap. */
|
|
609
|
+
async function readImagePixels(file) {
|
|
610
|
+
const url = window.URL.createObjectURL(file);
|
|
611
|
+
try {
|
|
612
|
+
const image = await new Promise((resolve, reject) => {
|
|
613
|
+
const element = new window.Image();
|
|
614
|
+
element.onload = () => resolve(element);
|
|
615
|
+
element.onerror = () => reject(new Error('无法解码这张图片(试 PNG / JPG)'));
|
|
616
|
+
element.src = url;
|
|
617
|
+
});
|
|
618
|
+
const sourceW = Math.max(1, Math.round(image.naturalWidth || image.width || 1));
|
|
619
|
+
const sourceH = Math.max(1, Math.round(image.naturalHeight || image.height || 1));
|
|
620
|
+
const ratio = Math.min(1, MAX_SIDE / Math.max(sourceW, sourceH));
|
|
621
|
+
const width = Math.max(1, Math.round(sourceW * ratio));
|
|
622
|
+
const height = Math.max(1, Math.round(sourceH * ratio));
|
|
623
|
+
const canvas = document.createElement('canvas');
|
|
624
|
+
canvas.width = width;
|
|
625
|
+
canvas.height = height;
|
|
626
|
+
const context = canvas.getContext('2d');
|
|
627
|
+
if (!context) throw new Error('浏览器不支持读取图片');
|
|
628
|
+
context.imageSmoothingEnabled = ratio < 1;
|
|
629
|
+
context.drawImage(image, 0, 0, width, height);
|
|
630
|
+
const data = context.getImageData(0, 0, width, height).data;
|
|
631
|
+
return { width, height, sourceW, sourceH, data };
|
|
632
|
+
} finally {
|
|
633
|
+
window.URL.revokeObjectURL(url);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function importName(file) {
|
|
638
|
+
const raw = file && typeof file.name === 'string' ? file.name : '';
|
|
639
|
+
const stem = raw.replace(/\.[^.]+$/, '').trim();
|
|
640
|
+
return (stem || '导入的图片').slice(0, 60);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Rebuilds palette + rows + preview for the current import options. */
|
|
644
|
+
function buildImportDocument(dialog, currentPalette) {
|
|
645
|
+
const sample = dialog.sample;
|
|
646
|
+
let palette;
|
|
647
|
+
let rgb;
|
|
648
|
+
let note = '';
|
|
649
|
+
if (dialog.mode === 'current' && Array.isArray(currentPalette) && currentPalette.length > 0) {
|
|
650
|
+
palette = currentPalette.slice(0, IMPORT_MAX_COLORS);
|
|
651
|
+
rgb = palette.map(hexToRgb).map(rgbOf);
|
|
652
|
+
} else if (dialog.mode === 'pico8') {
|
|
653
|
+
palette = PRESETS[0].colors.slice(0, IMPORT_MAX_COLORS);
|
|
654
|
+
rgb = palette.map(hexToRgb).map(rgbOf);
|
|
655
|
+
} else {
|
|
656
|
+
const auto = paletteFromSample(sample, IMPORT_MAX_COLORS);
|
|
657
|
+
palette = auto.palette;
|
|
658
|
+
rgb = auto.rgb;
|
|
659
|
+
note = auto.original > palette.length
|
|
660
|
+
? `${auto.original} 种颜色压到 ${palette.length} 种`
|
|
661
|
+
: `${auto.original} 种颜色`;
|
|
662
|
+
}
|
|
663
|
+
if (palette.length === 0) {
|
|
664
|
+
palette = ['#000000'];
|
|
665
|
+
rgb = [[0, 0, 0]];
|
|
666
|
+
}
|
|
667
|
+
const rows = buildImportRows(sample, rgb, dialog.dither === true);
|
|
668
|
+
return {
|
|
669
|
+
...dialog,
|
|
670
|
+
palette,
|
|
671
|
+
rows,
|
|
672
|
+
note,
|
|
673
|
+
preview: previewDataUrl(palette, rows),
|
|
674
|
+
error: '',
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
349
678
|
/* ------------------------------------------------------------------ */
|
|
350
679
|
/* icons */
|
|
351
680
|
/* ------------------------------------------------------------------ */
|
|
@@ -612,6 +941,42 @@ window.__ModuleLoader__.load({
|
|
|
612
941
|
}
|
|
613
942
|
.pxs-size-label { font-size: 12px; font-weight: 600; }
|
|
614
943
|
.pxs-size-hint { font-size: 10px; color: var(--dsw-alias-label-secondary, #9aa3b2); }
|
|
944
|
+
.pxs-import-dialog, .pxs-resize-dialog { width: min(460px, calc(100vw - 40px)); }
|
|
945
|
+
.pxs-scale-chip {
|
|
946
|
+
flex-direction: row; align-items: center; gap: 0; padding: 3px 8px; border-radius: 999px;
|
|
947
|
+
font-size: 11px; color: var(--dsw-alias-label-primary, #e8ebf2);
|
|
948
|
+
background: var(--dsw-alias-bg-layer-1, #1c1f26);
|
|
949
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .1));
|
|
950
|
+
}
|
|
951
|
+
.pxs-scale-chip:hover { border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, .2)); }
|
|
952
|
+
.pxs-scale-chip[data-active="true"] {
|
|
953
|
+
border-color: var(--dsw-alias-brand-primary, #4b7bec);
|
|
954
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b7bec) 18%, transparent);
|
|
955
|
+
}
|
|
956
|
+
.pxs-scale-chip:disabled { opacity: .45; cursor: not-allowed; }
|
|
957
|
+
.pxs-import-preview {
|
|
958
|
+
display: flex; justify-content: center; padding: 8px; border-radius: 8px;
|
|
959
|
+
background: var(--dsw-alias-bg-layer-1, #1c1f26);
|
|
960
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .08));
|
|
961
|
+
max-height: 190px; overflow: auto;
|
|
962
|
+
}
|
|
963
|
+
.pxs-import-preview img {
|
|
964
|
+
image-rendering: pixelated; max-width: 100%; max-height: 172px;
|
|
965
|
+
}
|
|
966
|
+
.pxs-anchor-grid {
|
|
967
|
+
display: grid; grid-template-columns: repeat(3, 30px); gap: 3px;
|
|
968
|
+
}
|
|
969
|
+
.pxs-anchor-chip {
|
|
970
|
+
width: 30px; height: 26px; border-radius: 6px; cursor: pointer; font: inherit;
|
|
971
|
+
line-height: 1; color: var(--dsw-alias-label-primary, #e8ebf2);
|
|
972
|
+
background: var(--dsw-alias-bg-layer-1, #1c1f26);
|
|
973
|
+
border: 1px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, .1));
|
|
974
|
+
}
|
|
975
|
+
.pxs-anchor-chip:hover { border-color: var(--dsw-alias-border-l2, rgba(255, 255, 255, .24)); }
|
|
976
|
+
.pxs-anchor-chip[data-active="true"] {
|
|
977
|
+
border-color: var(--dsw-alias-brand-primary, #4b7bec);
|
|
978
|
+
background: color-mix(in srgb, var(--dsw-alias-brand-primary, #4b7bec) 18%, transparent);
|
|
979
|
+
}
|
|
615
980
|
.pxs-menu {
|
|
616
981
|
position: fixed; z-index: 40; min-width: 132px; padding: 4px; border-radius: 8px;
|
|
617
982
|
background: var(--dsw-alias-bg-overlay, #262a33); border: 1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.2));
|
|
@@ -665,6 +1030,8 @@ window.__ModuleLoader__.load({
|
|
|
665
1030
|
const [renameValue, setRenameValue] = useState('');
|
|
666
1031
|
const [saveDialog, setSaveDialog] = useState(null);
|
|
667
1032
|
const [editing, setEditing] = useState(null);
|
|
1033
|
+
const [importDialog, setImportDialog] = useState(null);
|
|
1034
|
+
const [resizeDialog, setResizeDialog] = useState(null);
|
|
668
1035
|
|
|
669
1036
|
const stageRef = useRef(null);
|
|
670
1037
|
const canvasRef = useRef(null);
|
|
@@ -695,6 +1062,7 @@ window.__ModuleLoader__.load({
|
|
|
695
1062
|
// Indirection for the save-as action: the keyboard effect is registered
|
|
696
1063
|
// before the callback is created, so it must not close over the binding.
|
|
697
1064
|
const saveAsRef = useRef(null);
|
|
1065
|
+
const fileRef = useRef(null);
|
|
698
1066
|
|
|
699
1067
|
if (gridRef.current !== showGrid) gridRef.current = showGrid;
|
|
700
1068
|
sizeRef.current = { left: leftWidth, right: rightWidth };
|
|
@@ -947,10 +1315,7 @@ window.__ModuleLoader__.load({
|
|
|
947
1315
|
if (!current) return;
|
|
948
1316
|
const history = historyRef.current;
|
|
949
1317
|
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
|
-
});
|
|
1318
|
+
history.undo.push(snapshotOf(current));
|
|
954
1319
|
if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
|
|
955
1320
|
history.redo = [];
|
|
956
1321
|
}
|
|
@@ -972,6 +1337,10 @@ window.__ModuleLoader__.load({
|
|
|
972
1337
|
palette: snapshot.palette.slice(),
|
|
973
1338
|
layers: snapshot.layers.map((layer) => ({ ...layer, rows: layer.rows.slice() })),
|
|
974
1339
|
};
|
|
1340
|
+
if (typeof snapshot.w === 'number' && typeof snapshot.h === 'number') {
|
|
1341
|
+
next.w = snapshot.w;
|
|
1342
|
+
next.h = snapshot.h;
|
|
1343
|
+
}
|
|
975
1344
|
docRef.current = next;
|
|
976
1345
|
setDoc(next);
|
|
977
1346
|
if (!next.layers[layerRef.current]) {
|
|
@@ -988,10 +1357,7 @@ window.__ModuleLoader__.load({
|
|
|
988
1357
|
const snapshot = history.undo.pop();
|
|
989
1358
|
if (!snapshot) return;
|
|
990
1359
|
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
|
-
});
|
|
1360
|
+
history.redo.push(snapshotOf(current));
|
|
995
1361
|
applyHistory(snapshot);
|
|
996
1362
|
}, [applyHistory]);
|
|
997
1363
|
|
|
@@ -1000,10 +1366,7 @@ window.__ModuleLoader__.load({
|
|
|
1000
1366
|
const snapshot = history.redo.pop();
|
|
1001
1367
|
if (!snapshot) return;
|
|
1002
1368
|
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
|
-
});
|
|
1369
|
+
history.undo.push(snapshotOf(current));
|
|
1007
1370
|
applyHistory(snapshot);
|
|
1008
1371
|
}, [applyHistory]);
|
|
1009
1372
|
|
|
@@ -1107,10 +1470,7 @@ window.__ModuleLoader__.load({
|
|
|
1107
1470
|
|
|
1108
1471
|
// Real stroke: snapshot once, then paint continuously.
|
|
1109
1472
|
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
|
-
});
|
|
1473
|
+
history.undo.push(snapshotOf(current));
|
|
1114
1474
|
if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
|
|
1115
1475
|
history.redo = [];
|
|
1116
1476
|
strokeRef.current = { pointerId: event.pointerId, last: cell };
|
|
@@ -1410,6 +1770,7 @@ window.__ModuleLoader__.load({
|
|
|
1410
1770
|
root: '',
|
|
1411
1771
|
dirs: [],
|
|
1412
1772
|
name,
|
|
1773
|
+
scale: clampExportScale(readStoredNumber('pxs:exportScale', 1), current.w, current.h),
|
|
1413
1774
|
loading: true,
|
|
1414
1775
|
busy: false,
|
|
1415
1776
|
error: '',
|
|
@@ -1423,15 +1784,17 @@ window.__ModuleLoader__.load({
|
|
|
1423
1784
|
if (!current || !dialog || dialog.busy) return;
|
|
1424
1785
|
if (typeof dialog.path !== 'string' || dialog.path === '') return;
|
|
1425
1786
|
const target = joinPath(dialog.path, ensurePngName(dialog.name));
|
|
1787
|
+
const scale = clampExportScale(dialog.scale, current.w, current.h);
|
|
1426
1788
|
setSaveDialog((value) => (value ? { ...value, busy: true, error: '' } : value));
|
|
1427
1789
|
try {
|
|
1428
|
-
const saved = await callApi({ op: 'exportTo', doc: current, path: target });
|
|
1790
|
+
const saved = await callApi({ op: 'exportTo', doc: current, path: target, scale });
|
|
1429
1791
|
const next = { ...current, filePath: typeof saved.path === 'string' ? saved.path : target };
|
|
1430
1792
|
docRef.current = next;
|
|
1431
1793
|
setDoc(next);
|
|
1432
1794
|
setSaveDialog(null);
|
|
1433
1795
|
setError('');
|
|
1434
|
-
|
|
1796
|
+
storeValue('pxs:exportScale', String(scale));
|
|
1797
|
+
setNotice(`已保存 ${next.filePath}${scale > 1 ? `(${scale}×)` : ''}`);
|
|
1435
1798
|
const stored = await callApi({ op: 'save', doc: next });
|
|
1436
1799
|
if (stored && stored.doc) {
|
|
1437
1800
|
docRef.current = stored.doc;
|
|
@@ -1455,8 +1818,10 @@ window.__ModuleLoader__.load({
|
|
|
1455
1818
|
return;
|
|
1456
1819
|
}
|
|
1457
1820
|
try {
|
|
1458
|
-
const
|
|
1459
|
-
|
|
1821
|
+
const scale = clampExportScale(readStoredNumber('pxs:exportScale', 1), current.w, current.h);
|
|
1822
|
+
const saved = await callApi({ op: 'exportTo', doc: current, path: filePath, scale });
|
|
1823
|
+
const suffix = scale > 1 ? `(${scale}×)` : '';
|
|
1824
|
+
setNotice(`${saved.overwritten ? `已覆盖 ${saved.path}` : `已保存 ${saved.path}`}${suffix}`);
|
|
1460
1825
|
setError('');
|
|
1461
1826
|
} catch (cause) {
|
|
1462
1827
|
const message = cause instanceof Error ? cause.message : String(cause);
|
|
@@ -1471,6 +1836,128 @@ window.__ModuleLoader__.load({
|
|
|
1471
1836
|
}, [openSaveDialog]);
|
|
1472
1837
|
saveAsRef.current = saveAsPng;
|
|
1473
1838
|
|
|
1839
|
+
/* -------------------------------------------------------------- */
|
|
1840
|
+
/* import a picture */
|
|
1841
|
+
/* -------------------------------------------------------------- */
|
|
1842
|
+
|
|
1843
|
+
const openImportDialog = useCallback((file) => {
|
|
1844
|
+
if (!file) return;
|
|
1845
|
+
void (async () => {
|
|
1846
|
+
try {
|
|
1847
|
+
const sample = await readImagePixels(file);
|
|
1848
|
+
const current = docRef.current;
|
|
1849
|
+
setError('');
|
|
1850
|
+
setImportDialog(buildImportDocument({
|
|
1851
|
+
sample,
|
|
1852
|
+
name: importName(file),
|
|
1853
|
+
mode: 'auto',
|
|
1854
|
+
dither: false,
|
|
1855
|
+
sourceW: sample.sourceW,
|
|
1856
|
+
sourceH: sample.sourceH,
|
|
1857
|
+
width: sample.width,
|
|
1858
|
+
height: sample.height,
|
|
1859
|
+
busy: false,
|
|
1860
|
+
}, current ? current.palette : null));
|
|
1861
|
+
} catch (cause) {
|
|
1862
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
1863
|
+
}
|
|
1864
|
+
})();
|
|
1865
|
+
}, []);
|
|
1866
|
+
|
|
1867
|
+
const retuneImport = useCallback((patch) => {
|
|
1868
|
+
const current = docRef.current;
|
|
1869
|
+
setImportDialog((value) => (value
|
|
1870
|
+
? buildImportDocument({ ...value, ...patch }, current ? current.palette : null)
|
|
1871
|
+
: value));
|
|
1872
|
+
}, []);
|
|
1873
|
+
|
|
1874
|
+
const confirmImport = useCallback(() => {
|
|
1875
|
+
const dialog = importDialog;
|
|
1876
|
+
if (!dialog || dialog.busy || !Array.isArray(dialog.rows)) return;
|
|
1877
|
+
setImportDialog((value) => (value ? { ...value, busy: true, error: '' } : value));
|
|
1878
|
+
void (async () => {
|
|
1879
|
+
try {
|
|
1880
|
+
const width = dialog.width;
|
|
1881
|
+
const height = dialog.height;
|
|
1882
|
+
const name = String(dialog.name || '').trim().slice(0, 60) || `${width}×${height} 导入`;
|
|
1883
|
+
const created = await callApi({ op: 'create', name, w: width, h: height, palette: dialog.palette });
|
|
1884
|
+
// create ignores layers, so the pixel grid goes in through save.
|
|
1885
|
+
const saved = await callApi({
|
|
1886
|
+
op: 'save',
|
|
1887
|
+
doc: {
|
|
1888
|
+
...created.doc,
|
|
1889
|
+
layers: [{ name: '图层 1', visible: true, rows: dialog.rows }],
|
|
1890
|
+
},
|
|
1891
|
+
});
|
|
1892
|
+
const next = saved && saved.doc ? saved.doc : created.doc;
|
|
1893
|
+
docRef.current = next;
|
|
1894
|
+
setDoc(next);
|
|
1895
|
+
layerRef.current = 0;
|
|
1896
|
+
setLayerIndex(0);
|
|
1897
|
+
setColorIndex(0);
|
|
1898
|
+
editSessionRef.current = null;
|
|
1899
|
+
setEditing(null);
|
|
1900
|
+
historyRef.current = { undo: [], redo: [] };
|
|
1901
|
+
setImportDialog(null);
|
|
1902
|
+
setStatus('clean');
|
|
1903
|
+
setNotice(`已导入「${name}」${width}×${height}`);
|
|
1904
|
+
await refreshDocs();
|
|
1905
|
+
window.requestAnimationFrame(() => fitToView());
|
|
1906
|
+
} catch (cause) {
|
|
1907
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1908
|
+
setImportDialog((value) => (value ? { ...value, busy: false, error: message } : value));
|
|
1909
|
+
}
|
|
1910
|
+
})();
|
|
1911
|
+
}, [fitToView, importDialog, refreshDocs]);
|
|
1912
|
+
|
|
1913
|
+
/* -------------------------------------------------------------- */
|
|
1914
|
+
/* canvas size / crop */
|
|
1915
|
+
/* -------------------------------------------------------------- */
|
|
1916
|
+
|
|
1917
|
+
const openResizeDialog = useCallback(() => {
|
|
1918
|
+
const current = docRef.current;
|
|
1919
|
+
if (!current) return;
|
|
1920
|
+
setResizeDialog({ w: current.w, h: current.h, anchor: 'c', error: '' });
|
|
1921
|
+
}, []);
|
|
1922
|
+
|
|
1923
|
+
const confirmResize = useCallback(() => {
|
|
1924
|
+
const current = docRef.current;
|
|
1925
|
+
const dialog = resizeDialog;
|
|
1926
|
+
if (!current || !dialog) return;
|
|
1927
|
+
const width = Math.round(Number(dialog.w));
|
|
1928
|
+
const height = Math.round(Number(dialog.h));
|
|
1929
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) {
|
|
1930
|
+
setResizeDialog({ ...dialog, error: '宽高要填 1 以上的整数' });
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
if (width > MAX_SIDE || height > MAX_SIDE) {
|
|
1934
|
+
setResizeDialog({ ...dialog, error: `最大 ${MAX_SIDE}×${MAX_SIDE}` });
|
|
1935
|
+
return;
|
|
1936
|
+
}
|
|
1937
|
+
setResizeDialog(null);
|
|
1938
|
+
if (width === current.w && height === current.h) {
|
|
1939
|
+
setNotice('画布尺寸没有变化');
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
const history = historyRef.current;
|
|
1943
|
+
history.undo.push(snapshotOf(current));
|
|
1944
|
+
if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
|
|
1945
|
+
history.redo = [];
|
|
1946
|
+
const layers = current.layers.map((layer) => ({
|
|
1947
|
+
...layer,
|
|
1948
|
+
rows: resizeRows(layer.rows, current.w, current.h, width, height, dialog.anchor),
|
|
1949
|
+
}));
|
|
1950
|
+
const next = { ...current, w: width, h: height, layers };
|
|
1951
|
+
docRef.current = next;
|
|
1952
|
+
setDoc(next);
|
|
1953
|
+
scheduleSave();
|
|
1954
|
+
setNotice(`画布已调整为 ${width}×${height}`);
|
|
1955
|
+
window.requestAnimationFrame(() => {
|
|
1956
|
+
fitToView();
|
|
1957
|
+
requestPaint();
|
|
1958
|
+
});
|
|
1959
|
+
}, [fitToView, requestPaint, resizeDialog, scheduleSave]);
|
|
1960
|
+
|
|
1474
1961
|
/* -------------------------------------------------------------- */
|
|
1475
1962
|
/* palette actions */
|
|
1476
1963
|
/* -------------------------------------------------------------- */
|
|
@@ -1479,10 +1966,7 @@ window.__ModuleLoader__.load({
|
|
|
1479
1966
|
const current = docRef.current;
|
|
1480
1967
|
if (!current) return;
|
|
1481
1968
|
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
|
-
});
|
|
1969
|
+
history.undo.push(snapshotOf(current));
|
|
1486
1970
|
if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
|
|
1487
1971
|
history.redo = [];
|
|
1488
1972
|
}, []);
|
|
@@ -1691,10 +2175,7 @@ window.__ModuleLoader__.load({
|
|
|
1691
2175
|
const current = docRef.current;
|
|
1692
2176
|
if (!current) return null;
|
|
1693
2177
|
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
|
-
});
|
|
2178
|
+
history.undo.push(snapshotOf(current));
|
|
1698
2179
|
if (history.undo.length > HISTORY_LIMIT) history.undo.shift();
|
|
1699
2180
|
history.redo = [];
|
|
1700
2181
|
return current;
|
|
@@ -1949,6 +2430,31 @@ window.__ModuleLoader__.load({
|
|
|
1949
2430
|
onClick: openNewDialog,
|
|
1950
2431
|
title: '新建作品(可选名字与尺寸)',
|
|
1951
2432
|
}, '新建'),
|
|
2433
|
+
h('button', {
|
|
2434
|
+
className: 'pxs-btn',
|
|
2435
|
+
onClick: () => {
|
|
2436
|
+
if (fileRef.current) fileRef.current.click();
|
|
2437
|
+
},
|
|
2438
|
+
title: '打开图片,转成像素画导入新作品',
|
|
2439
|
+
}, '打开图片'),
|
|
2440
|
+
h('input', {
|
|
2441
|
+
ref: fileRef,
|
|
2442
|
+
type: 'file',
|
|
2443
|
+
accept: 'image/png,image/jpeg,image/gif,image/webp,image/bmp',
|
|
2444
|
+
style: { display: 'none' },
|
|
2445
|
+
onChange: (event) => {
|
|
2446
|
+
const input = event.target;
|
|
2447
|
+
const picked = input.files && input.files[0];
|
|
2448
|
+
input.value = '';
|
|
2449
|
+
if (picked) openImportDialog(picked);
|
|
2450
|
+
},
|
|
2451
|
+
}),
|
|
2452
|
+
h('button', {
|
|
2453
|
+
className: 'pxs-btn',
|
|
2454
|
+
disabled: !doc,
|
|
2455
|
+
onClick: openResizeDialog,
|
|
2456
|
+
title: '调整画布尺寸 / 裁剪(可撤销)',
|
|
2457
|
+
}, '尺寸'),
|
|
1952
2458
|
h('span', { className: 'pxs-spacer' }),
|
|
1953
2459
|
h('button', {
|
|
1954
2460
|
className: 'pxs-btn pxs-btn-icon',
|
|
@@ -2396,6 +2902,25 @@ window.__ModuleLoader__.load({
|
|
|
2396
2902
|
}
|
|
2397
2903
|
},
|
|
2398
2904
|
})),
|
|
2905
|
+
h('div', { className: 'pxs-dialog-row', style: { flexWrap: 'wrap' } },
|
|
2906
|
+
h('span', { className: 'pxs-dialog-label' }, '倍率'),
|
|
2907
|
+
EXPORT_SCALES.map((value) => {
|
|
2908
|
+
const ceiling = maxScaleFor(doc ? doc.w : 1, doc ? doc.h : 1);
|
|
2909
|
+
const allowed = value <= ceiling;
|
|
2910
|
+
return h('button', {
|
|
2911
|
+
key: value,
|
|
2912
|
+
className: 'pxs-scale-chip',
|
|
2913
|
+
'data-active': Number(saveDialog.scale) === value ? 'true' : 'false',
|
|
2914
|
+
disabled: !allowed,
|
|
2915
|
+
title: allowed
|
|
2916
|
+
? `${value}× → ${(doc ? doc.w : 0) * value}×${(doc ? doc.h : 0) * value}`
|
|
2917
|
+
: '超出单张 PNG 上限',
|
|
2918
|
+
onClick: () => setSaveDialog((current) => (current ? { ...current, scale: value } : current)),
|
|
2919
|
+
}, `${value}×`);
|
|
2920
|
+
}),
|
|
2921
|
+
h('span', { className: 'pxs-hint' }, doc
|
|
2922
|
+
? `${saveDialog.scale}× = ${doc.w * saveDialog.scale}×${doc.h * saveDialog.scale}`
|
|
2923
|
+
: '')),
|
|
2399
2924
|
saveDialog.error ? h('div', { className: 'pxs-dialog-error' }, saveDialog.error) : null,
|
|
2400
2925
|
h('div', { className: 'pxs-dialog-actions' },
|
|
2401
2926
|
h('button', { className: 'pxs-btn', onClick: () => setSaveDialog(null) }, '取消'),
|
|
@@ -2490,6 +3015,139 @@ window.__ModuleLoader__.load({
|
|
|
2490
3015
|
onClick: confirmNewDoc,
|
|
2491
3016
|
}, creating ? '创建中…' : '创建')))) : null;
|
|
2492
3017
|
|
|
3018
|
+
const importDialogNode = importDialog ? h('div', {
|
|
3019
|
+
className: 'pxs-modal',
|
|
3020
|
+
onPointerDown: (event) => {
|
|
3021
|
+
if (event.target === event.currentTarget && !importDialog.busy) setImportDialog(null);
|
|
3022
|
+
},
|
|
3023
|
+
},
|
|
3024
|
+
h('div', { className: 'pxs-dialog pxs-import-dialog', role: 'dialog', 'aria-label': '打开图片' },
|
|
3025
|
+
h('div', { className: 'pxs-dialog-head' },
|
|
3026
|
+
h('strong', null, '打开图片'),
|
|
3027
|
+
h('button', {
|
|
3028
|
+
className: 'pxs-dialog-close',
|
|
3029
|
+
disabled: importDialog.busy,
|
|
3030
|
+
onClick: () => setImportDialog(null),
|
|
3031
|
+
}, '×')),
|
|
3032
|
+
h('div', { className: 'pxs-dialog-row' },
|
|
3033
|
+
h('span', { className: 'pxs-dialog-label' }, '名字'),
|
|
3034
|
+
h('input', {
|
|
3035
|
+
className: 'pxs-input',
|
|
3036
|
+
style: { flex: 1 },
|
|
3037
|
+
maxLength: 60,
|
|
3038
|
+
value: importDialog.name,
|
|
3039
|
+
onChange: (event) => {
|
|
3040
|
+
const value = event.target.value;
|
|
3041
|
+
setImportDialog((current) => (current ? { ...current, name: value } : current));
|
|
3042
|
+
},
|
|
3043
|
+
})),
|
|
3044
|
+
h('div', { className: 'pxs-dialog-row' },
|
|
3045
|
+
h('span', { className: 'pxs-dialog-label' }, '原图'),
|
|
3046
|
+
h('span', { className: 'pxs-hint' },
|
|
3047
|
+
`${importDialog.sourceW}×${importDialog.sourceH} → ${importDialog.width}×${importDialog.height}`
|
|
3048
|
+
+ ` · ${importDialog.palette.length} 色`
|
|
3049
|
+
+ (importDialog.note ? ` · ${importDialog.note}` : ''))),
|
|
3050
|
+
h('div', { className: 'pxs-dialog-row', style: { flexWrap: 'wrap' } },
|
|
3051
|
+
h('span', { className: 'pxs-dialog-label' }, '配色'),
|
|
3052
|
+
[
|
|
3053
|
+
{ id: 'auto', label: '自动提取' },
|
|
3054
|
+
{ id: 'current', label: '当前调色板', disabled: !(doc && doc.palette.length > 0) },
|
|
3055
|
+
{ id: 'pico8', label: 'PICO-8' },
|
|
3056
|
+
].map((mode) => h('button', {
|
|
3057
|
+
key: mode.id,
|
|
3058
|
+
className: 'pxs-scale-chip',
|
|
3059
|
+
'data-active': importDialog.mode === mode.id ? 'true' : 'false',
|
|
3060
|
+
disabled: mode.disabled === true || importDialog.busy,
|
|
3061
|
+
onClick: () => retuneImport({ mode: mode.id }),
|
|
3062
|
+
}, mode.label)),
|
|
3063
|
+
h('button', {
|
|
3064
|
+
className: 'pxs-scale-chip',
|
|
3065
|
+
'data-active': importDialog.dither ? 'true' : 'false',
|
|
3066
|
+
disabled: importDialog.busy,
|
|
3067
|
+
title: '误差扩散抖动,渐变更自然但像素更碎',
|
|
3068
|
+
onClick: () => retuneImport({ dither: !importDialog.dither }),
|
|
3069
|
+
}, '抖动')),
|
|
3070
|
+
importDialog.preview
|
|
3071
|
+
? h('div', { className: 'pxs-import-preview' },
|
|
3072
|
+
h('img', { src: importDialog.preview, alt: '导入预览' }))
|
|
3073
|
+
: null,
|
|
3074
|
+
importDialog.error ? h('div', { className: 'pxs-dialog-error' }, importDialog.error) : null,
|
|
3075
|
+
h('div', { className: 'pxs-dialog-actions' },
|
|
3076
|
+
h('button', {
|
|
3077
|
+
className: 'pxs-btn',
|
|
3078
|
+
disabled: importDialog.busy,
|
|
3079
|
+
onClick: () => setImportDialog(null),
|
|
3080
|
+
}, '取消'),
|
|
3081
|
+
h('button', {
|
|
3082
|
+
className: 'pxs-btn pxs-btn-primary',
|
|
3083
|
+
disabled: importDialog.busy || !Array.isArray(importDialog.rows),
|
|
3084
|
+
onClick: confirmImport,
|
|
3085
|
+
}, importDialog.busy ? '导入中…' : '导入')))) : null;
|
|
3086
|
+
|
|
3087
|
+
const resizeDialogNode = resizeDialog ? h('div', {
|
|
3088
|
+
className: 'pxs-modal',
|
|
3089
|
+
onPointerDown: (event) => {
|
|
3090
|
+
if (event.target === event.currentTarget) setResizeDialog(null);
|
|
3091
|
+
},
|
|
3092
|
+
},
|
|
3093
|
+
h('div', { className: 'pxs-dialog pxs-resize-dialog', role: 'dialog', 'aria-label': '画布尺寸' },
|
|
3094
|
+
h('div', { className: 'pxs-dialog-head' },
|
|
3095
|
+
h('strong', null, '画布尺寸 / 裁剪'),
|
|
3096
|
+
h('button', {
|
|
3097
|
+
className: 'pxs-dialog-close',
|
|
3098
|
+
onClick: () => setResizeDialog(null),
|
|
3099
|
+
}, '×')),
|
|
3100
|
+
h('div', { className: 'pxs-dialog-row' },
|
|
3101
|
+
h('span', { className: 'pxs-dialog-label' }, '当前'),
|
|
3102
|
+
h('span', { className: 'pxs-hint' },
|
|
3103
|
+
doc ? `${doc.w}×${doc.h} · 目标 ${Math.round(Number(resizeDialog.w) || 0)}×${Math.round(Number(resizeDialog.h) || 0)}` : '')),
|
|
3104
|
+
h('div', { className: 'pxs-dialog-row' },
|
|
3105
|
+
h('span', { className: 'pxs-dialog-label' }, '宽'),
|
|
3106
|
+
h('input', {
|
|
3107
|
+
className: 'pxs-input',
|
|
3108
|
+
style: { width: 84 },
|
|
3109
|
+
type: 'number',
|
|
3110
|
+
min: 1,
|
|
3111
|
+
max: MAX_SIDE,
|
|
3112
|
+
value: resizeDialog.w,
|
|
3113
|
+
onChange: (event) => {
|
|
3114
|
+
const value = event.target.value;
|
|
3115
|
+
setResizeDialog((current) => (current ? { ...current, w: value } : current));
|
|
3116
|
+
},
|
|
3117
|
+
}),
|
|
3118
|
+
h('span', { className: 'pxs-dialog-label' }, '高'),
|
|
3119
|
+
h('input', {
|
|
3120
|
+
className: 'pxs-input',
|
|
3121
|
+
style: { width: 84 },
|
|
3122
|
+
type: 'number',
|
|
3123
|
+
min: 1,
|
|
3124
|
+
max: MAX_SIDE,
|
|
3125
|
+
value: resizeDialog.h,
|
|
3126
|
+
onChange: (event) => {
|
|
3127
|
+
const value = event.target.value;
|
|
3128
|
+
setResizeDialog((current) => (current ? { ...current, h: value } : current));
|
|
3129
|
+
},
|
|
3130
|
+
}),
|
|
3131
|
+
h('span', { className: 'pxs-hint' }, `最大 ${MAX_SIDE}`)),
|
|
3132
|
+
h('div', { className: 'pxs-dialog-row' },
|
|
3133
|
+
h('span', { className: 'pxs-dialog-label' }, '锚点'),
|
|
3134
|
+
h('div', { className: 'pxs-anchor-grid' },
|
|
3135
|
+
ANCHORS.map((anchor) => h('button', {
|
|
3136
|
+
key: anchor.id,
|
|
3137
|
+
className: 'pxs-anchor-chip',
|
|
3138
|
+
'data-active': resizeDialog.anchor === anchor.id ? 'true' : 'false',
|
|
3139
|
+
title: `以${anchor.label}为基准保留画面`,
|
|
3140
|
+
onClick: () => setResizeDialog((current) => (current ? { ...current, anchor: anchor.id } : current)),
|
|
3141
|
+
}, anchor.label))),
|
|
3142
|
+
h('span', { className: 'pxs-hint' }, '扩边补透明,裁掉的部分丢弃')),
|
|
3143
|
+
resizeDialog.error ? h('div', { className: 'pxs-dialog-error' }, resizeDialog.error) : null,
|
|
3144
|
+
h('div', { className: 'pxs-dialog-actions' },
|
|
3145
|
+
h('button', { className: 'pxs-btn', onClick: () => setResizeDialog(null) }, '取消'),
|
|
3146
|
+
h('button', {
|
|
3147
|
+
className: 'pxs-btn pxs-btn-primary',
|
|
3148
|
+
onClick: confirmResize,
|
|
3149
|
+
}, '应用')))) : null;
|
|
3150
|
+
|
|
2493
3151
|
return h('div', { className: 'pxs-root' },
|
|
2494
3152
|
h('style', null, CSS),
|
|
2495
3153
|
barNode,
|
|
@@ -2517,7 +3175,9 @@ window.__ModuleLoader__.load({
|
|
|
2517
3175
|
rightPanel),
|
|
2518
3176
|
menuNode,
|
|
2519
3177
|
dialogNode,
|
|
2520
|
-
newDialogNode
|
|
3178
|
+
newDialogNode,
|
|
3179
|
+
importDialogNode,
|
|
3180
|
+
resizeDialogNode);
|
|
2521
3181
|
}
|
|
2522
3182
|
|
|
2523
3183
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muxiuesd/dsh-pixel-studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "在 DSH 里画像素画:三栏编辑器(左工具 / 中画布 / 右调色板),滚轮缩放,图层、调色板、导出 PNG 都在会话页签里。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"client.js",
|
|
40
40
|
"cordis.patch.yml",
|
|
41
41
|
"locale",
|
|
42
|
-
"icon.png"
|
|
42
|
+
"icon.png",
|
|
43
|
+
"CHANGELOG.md"
|
|
43
44
|
],
|
|
44
45
|
"dsh": {
|
|
45
46
|
"bundle": {
|