@underpostnet/underpost 2.98.0 → 2.98.3

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.
@@ -1,1520 +0,0 @@
1
- import { darkTheme, renderChessPattern } from './Css.js';
2
- import { append, htmls } from './VanillaJs.js';
3
- import { NotificationManager } from './NotificationManager.js';
4
-
5
- class ObjectLayerEngineElement extends HTMLElement {
6
- constructor() {
7
- super();
8
- this.attachShadow({ mode: 'open' });
9
- this.shadowRoot.innerHTML = html`
10
- <style>
11
- :host {
12
- --border: 1px solid #bbb;
13
- --gap: 8px;
14
- display: inline-block;
15
- font-family:
16
- system-ui,
17
- -apple-system,
18
- 'Segoe UI',
19
- Roboto,
20
- 'Helvetica Neue',
21
- Arial;
22
- }
23
- .wrap {
24
- display: flex;
25
- flex-direction: column;
26
- gap: var(--gap);
27
- align-items: flex-start;
28
- }
29
- .canvas-frame {
30
- border: var(--border);
31
- display: inline-block;
32
- line-height: 0;
33
- position: relative;
34
- background: transparent;
35
- }
36
- canvas.canvas-layer {
37
- display: block;
38
- image-rendering: pixelated;
39
- touch-action: none;
40
- cursor: crosshair;
41
- }
42
- canvas.grid-layer {
43
- position: absolute;
44
- left: 0;
45
- top: 0;
46
- pointer-events: none;
47
- }
48
- .toolbar {
49
- display: flex;
50
- gap: 8px;
51
- flex-wrap: wrap;
52
- align-items: center;
53
- }
54
- .toolbar label {
55
- display: inline-flex;
56
- gap: 6px;
57
- align-items: center;
58
- }
59
- .group {
60
- display: inline-flex;
61
- gap: 6px;
62
- align-items: center;
63
- }
64
- </style>
65
-
66
- <div class="wrap">
67
- <div class="toolbar">
68
- <button part="undo" title="Undo" disabled>Undo</button>
69
- <button part="redo" title="Redo" disabled>Redo</button>
70
-
71
- <input type="color" part="color" title="Brush color" value="#000000" />
72
- <label
73
- >hex
74
- <input
75
- type="text"
76
- part="hex-input"
77
- title="Hex color (e.g., #FF0000 or #FF0000FF)"
78
- placeholder="#000000FF"
79
- style="width:9ch"
80
- /></label>
81
- <label
82
- >rgba
83
- <input type="number" part="r-input" min="0" max="255" value="0" title="Red (0-255)" style="width:5ch" />
84
- <input type="number" part="g-input" min="0" max="255" value="0" title="Green (0-255)" style="width:5ch" />
85
- <input type="number" part="b-input" min="0" max="255" value="0" title="Blue (0-255)" style="width:5ch" />
86
- <input type="number" part="a-input" min="0" max="255" value="255" title="Alpha (0-255)" style="width:5ch" />
87
- </label>
88
- <select part="tool">
89
- <option value="pencil">pencil</option>
90
- <option value="eraser">eraser</option>
91
- <option value="fill">fill</option>
92
- <option value="eyedropper">eyedropper</option>
93
- </select>
94
-
95
- <label>brush <input type="number" part="brush-size" min="1" value="1" /></label>
96
- <label>pixel-size <input type="number" part="pixel-size" min="1" value="16" /></label>
97
-
98
- <!-- New: cell dimensions (width x height) -->
99
- <label
100
- >cells <input type="number" part="cell-width" min="1" value="16" style="width:6ch" /> x
101
- <input type="number" part="cell-height" min="1" value="16" style="width:6ch"
102
- /></label>
103
-
104
- <label class="switch"> <input type="checkbox" part="toggle-grid" /> grid </label>
105
-
106
- <!-- New: transform tools -->
107
- <div class="group">
108
- <button part="flip-h" title="Flip horizontally">Flip H</button>
109
- <button part="flip-v" title="Flip vertically">Flip V</button>
110
- <button part="rot-ccw" title="Rotate -90°">⟲</button>
111
- <button part="rot-cw" title="Rotate +90°">⟳</button>
112
- </div>
113
-
114
- <label
115
- >opacity <input type="range" part="opacity" min="0" max="255" value="255" style="width:10rem" /><input
116
- type="number"
117
- part="opacity-num"
118
- min="0"
119
- max="255"
120
- value="255"
121
- style="width:5ch;margin-left:4px"
122
- /></label>
123
-
124
- <button part="clear" title="Clear (make fully transparent)">Clear</button>
125
-
126
- <button part="export">Export PNG</button>
127
- <button part="export-json">Export JSON</button>
128
- <button part="import-json">Import JSON</button>
129
- </div>
130
- <div class="canvas-frame" style="${renderChessPattern()}">
131
- <canvas part="canvas" class="canvas-layer"></canvas>
132
- <canvas part="grid" class="grid-layer"></canvas>
133
- </div>
134
- </div>
135
- `;
136
-
137
- // DOM
138
- this._pixelCanvas = this.shadowRoot.querySelector('canvas[part="canvas"]');
139
- this._gridCanvas = this.shadowRoot.querySelector('canvas[part="grid"]');
140
- this._colorInput = this.shadowRoot.querySelector('input[part="color"]');
141
- this._hexInput = this.shadowRoot.querySelector('input[part="hex-input"]');
142
- this._rInput = this.shadowRoot.querySelector('input[part="r-input"]');
143
- this._gInput = this.shadowRoot.querySelector('input[part="g-input"]');
144
- this._bInput = this.shadowRoot.querySelector('input[part="b-input"]');
145
- this._aInput = this.shadowRoot.querySelector('input[part="a-input"]');
146
- this._toolSelect = this.shadowRoot.querySelector('select[part="tool"]');
147
- this._brushSizeInput = this.shadowRoot.querySelector('input[part="brush-size"]');
148
- this._pixelSizeInput = this.shadowRoot.querySelector('input[part="pixel-size"]');
149
- this._exportBtn = this.shadowRoot.querySelector('button[part="export"]');
150
- this._exportJsonBtn = this.shadowRoot.querySelector('button[part="export-json"]');
151
- this._importJsonBtn = this.shadowRoot.querySelector('button[part="import-json"]');
152
- this._toggleGrid = this.shadowRoot.querySelector('input[part="toggle-grid"]');
153
-
154
- // new controls
155
- this._widthInput = this.shadowRoot.querySelector('input[part="cell-width"]');
156
- this._heightInput = this.shadowRoot.querySelector('input[part="cell-height"]');
157
- this._flipHBtn = this.shadowRoot.querySelector('button[part="flip-h"]');
158
- this._flipVBtn = this.shadowRoot.querySelector('button[part="flip-v"]');
159
- this._rotCCWBtn = this.shadowRoot.querySelector('button[part="rot-ccw"]');
160
- this._rotCWBtn = this.shadowRoot.querySelector('button[part="rot-cw"]');
161
- this._clearBtn = this.shadowRoot.querySelector('button[part="clear"]');
162
- this._opacityRange = this.shadowRoot.querySelector('input[part="opacity"]');
163
- this._opacityNumber = this.shadowRoot.querySelector('input[part="opacity-num"]');
164
-
165
- // undo/redo buttons
166
- this._undoBtn = this.shadowRoot.querySelector('button[part="undo"]');
167
- this._redoBtn = this.shadowRoot.querySelector('button[part="redo"]');
168
-
169
- // internal state
170
- this._width = 16;
171
- this._height = 16;
172
- this._pixelSize = 16;
173
- this._brushSize = 1;
174
- // brush color stored as [r,g,b,a]
175
- this._brushColor = [0, 0, 0, 255];
176
- this._matrix = this._createEmptyMatrix(this._width, this._height);
177
-
178
- this._pixelCtx = null;
179
- this._gridCtx = null;
180
-
181
- this._isPointerDown = false;
182
- this._tool = 'pencil';
183
- this._showGrid = false;
184
-
185
- // history (undo/redo)
186
- this._undoStack = [];
187
- this._redoStack = [];
188
- this._maxHistory = 200;
189
- this._transactionActive = false; // grouping for pointer drags
190
-
191
- // binds
192
- this._onPointerDown = this._onPointerDown.bind(this);
193
- this._onPointerMove = this._onPointerMove.bind(this);
194
- this._onPointerUp = this._onPointerUp.bind(this);
195
- this._onKeyDown = this._onKeyDown.bind(this);
196
-
197
- // transform methods bound (useful if passing as callbacks)
198
- this.flipHorizontal = this.flipHorizontal.bind(this);
199
- this.flipVertical = this.flipVertical.bind(this);
200
- this.rotateCW = this.rotateCW.bind(this);
201
- this.rotateCCW = this.rotateCCW.bind(this);
202
-
203
- // ensure keyboard handlers bound for undo/redo
204
- }
205
-
206
- static get observedAttributes() {
207
- return ['width', 'height', 'pixel-size'];
208
- }
209
- attributeChangedCallback(name, oldV, newV) {
210
- if (oldV === newV) return;
211
- if (name === 'width') this.width = parseInt(newV, 10) || this._width;
212
- if (name === 'height') this.height = parseInt(newV, 10) || this._height;
213
- if (name === 'pixel-size') this.pixelSize = parseInt(newV, 10) || this._pixelSize;
214
- }
215
-
216
- connectedCallback() {
217
- // respect attributes if present
218
- if (this.hasAttribute('width')) this._width = Math.max(1, parseInt(this.getAttribute('width'), 10));
219
- if (this.hasAttribute('height')) this._height = Math.max(1, parseInt(this.getAttribute('height'), 10));
220
- if (this.hasAttribute('pixel-size')) this._pixelSize = Math.max(1, parseInt(this.getAttribute('pixel-size'), 10));
221
-
222
- this._setupContextsAndSize();
223
-
224
- // set initial UI control values (keeps in sync with attributes)
225
- if (this._widthInput) this._widthInput.value = String(this._width);
226
- if (this._heightInput) this._heightInput.value = String(this._height);
227
- if (this._pixelSizeInput) this._pixelSizeInput.value = String(this._pixelSize);
228
- if (this._brushSizeInput) this._brushSizeInput.value = String(this._brushSize);
229
-
230
- // initialize color & opacity UI
231
- if (this._colorInput) this._colorInput.value = this._rgbaToHex(this._brushColor);
232
- if (this._hexInput) this._hexInput.value = this._rgbaToHexWithAlpha(this._brushColor);
233
- if (this._rInput) this._rInput.value = String(this._brushColor[0]);
234
- if (this._gInput) this._gInput.value = String(this._brushColor[1]);
235
- if (this._bInput) this._bInput.value = String(this._brushColor[2]);
236
- if (this._aInput) this._aInput.value = String(this._brushColor[3]);
237
- if (this._opacityRange) this._opacityRange.value = String(this._brushColor[3]);
238
- if (this._opacityNumber) this._opacityNumber.value = String(this._brushColor[3]);
239
-
240
- // UI events
241
- this._colorInput.addEventListener('input', (e) => {
242
- const rgb = this._hexToRgba(e.target.value);
243
- // keep current alpha
244
- this.setBrushColor([rgb[0], rgb[1], rgb[2], this._brushColor[3]]);
245
- });
246
-
247
- // hex text input with optional alpha
248
- if (this._hexInput) {
249
- this._hexInput.addEventListener('change', (e) => {
250
- const rgba = this._hexToRgbaWithAlpha(e.target.value);
251
- this.setBrushColor(rgba);
252
- });
253
- }
254
-
255
- // individual RGBA inputs
256
- const updateFromRGBAInputs = () => {
257
- const r = Math.max(0, Math.min(255, parseInt(this._rInput.value, 10) || 0));
258
- const g = Math.max(0, Math.min(255, parseInt(this._gInput.value, 10) || 0));
259
- const b = Math.max(0, Math.min(255, parseInt(this._bInput.value, 10) || 0));
260
- const a = Math.max(0, Math.min(255, parseInt(this._aInput.value, 10) || 0));
261
- this.setBrushColor([r, g, b, a]);
262
- };
263
- if (this._rInput) this._rInput.addEventListener('change', updateFromRGBAInputs);
264
- if (this._gInput) this._gInput.addEventListener('change', updateFromRGBAInputs);
265
- if (this._bInput) this._bInput.addEventListener('change', updateFromRGBAInputs);
266
- if (this._aInput) this._aInput.addEventListener('change', updateFromRGBAInputs);
267
-
268
- this._toolSelect.addEventListener('change', (e) => this.setTool(e.target.value));
269
- this._brushSizeInput.addEventListener('change', (e) => this.setBrushSize(parseInt(e.target.value, 10) || 1));
270
- this._pixelSizeInput.addEventListener('change', (e) => {
271
- this.pixelSize = Math.max(1, parseInt(e.target.value, 10) || 1);
272
- });
273
- this._toggleGrid.addEventListener('change', (e) => {
274
- this._showGrid = !!e.target.checked;
275
- this._renderGrid();
276
- });
277
-
278
- // opacity controls - keep range and number in sync
279
- if (this._opacityRange) {
280
- this._opacityRange.addEventListener('input', (e) => {
281
- const v = Math.max(0, Math.min(255, parseInt(e.target.value, 10) || 0));
282
- this.setBrushAlpha(v);
283
- });
284
- }
285
- if (this._opacityNumber) {
286
- this._opacityNumber.addEventListener('change', (e) => {
287
- const v = Math.max(0, Math.min(255, parseInt(e.target.value, 10) || 0));
288
- this.setBrushAlpha(v);
289
- });
290
- }
291
-
292
- // width/height change -> resize (preserve existing content)
293
- if (this._widthInput)
294
- this._widthInput.addEventListener('change', (e) => {
295
- const val = Math.max(1, parseInt(e.target.value, 10) || 1);
296
- // keep value synced (will update input again in resize)
297
- this.resize(val, this._height, { preserve: true });
298
- });
299
- if (this._heightInput)
300
- this._heightInput.addEventListener('change', (e) => {
301
- const val = Math.max(1, parseInt(e.target.value, 10) || 1);
302
- this.resize(this._width, val, { preserve: true });
303
- });
304
-
305
- // transform buttons
306
- if (this._flipHBtn)
307
- this._flipHBtn.addEventListener('click', () => {
308
- this._beginTransaction();
309
- this.flipHorizontal();
310
- this._endTransaction();
311
- });
312
- if (this._flipVBtn)
313
- this._flipVBtn.addEventListener('click', () => {
314
- this._beginTransaction();
315
- this.flipVertical();
316
- this._endTransaction();
317
- });
318
- if (this._rotCWBtn)
319
- this._rotCWBtn.addEventListener('click', () => {
320
- this._beginTransaction();
321
- this.rotateCW();
322
- this._endTransaction();
323
- });
324
- if (this._rotCCWBtn)
325
- this._rotCCWBtn.addEventListener('click', () => {
326
- this._beginTransaction();
327
- this.rotateCCW();
328
- this._endTransaction();
329
- });
330
-
331
- // clear button (makes canvas fully transparent)
332
- if (this._clearBtn)
333
- this._clearBtn.addEventListener('click', () => {
334
- this._beginTransaction();
335
- this.clear([0, 0, 0, 0]);
336
- this._endTransaction();
337
- });
338
-
339
- // Export/Import
340
- this._exportBtn.addEventListener('click', () => this.exportPNG());
341
- this._exportJsonBtn.addEventListener('click', () => {
342
- const json = this.exportMatrixJSON();
343
- const blob = new Blob([json], { type: 'application/json' });
344
- const url = URL.createObjectURL(blob);
345
- const a = document.createElement('a');
346
- a.href = url;
347
- a.download = 'object-layer.json';
348
- a.click();
349
- URL.revokeObjectURL(url);
350
- });
351
-
352
- this._importJsonBtn.addEventListener('click', async () => {
353
- const file = await this._pickFile();
354
- if (!file) return;
355
- const text = await file.text();
356
- try {
357
- this._beginTransaction();
358
- this.importMatrixJSON(text);
359
- this._endTransaction();
360
- } catch (err) {
361
- console.error(err);
362
- alert('Invalid JSON');
363
- }
364
- });
365
-
366
- // undo/redo
367
- if (this._undoBtn) this._undoBtn.addEventListener('click', () => this.undo());
368
- if (this._redoBtn) this._redoBtn.addEventListener('click', () => this.redo());
369
-
370
- // Pointer events
371
- this._pixelCanvas.addEventListener('pointerdown', this._onPointerDown);
372
- window.addEventListener('pointermove', this._onPointerMove);
373
- window.addEventListener('pointerup', this._onPointerUp);
374
-
375
- // keyboard for undo/redo
376
- window.addEventListener('keydown', this._onKeyDown);
377
-
378
- // initial render and clear history
379
- this.render();
380
- this._clearHistory();
381
- this._updateToolbarButtons();
382
- }
383
-
384
- disconnectedCallback() {
385
- this._pixelCanvas.removeEventListener('pointerdown', this._onPointerDown);
386
- window.removeEventListener('pointermove', this._onPointerMove);
387
- window.removeEventListener('pointerup', this._onPointerUp);
388
-
389
- if (this._flipHBtn) this._flipHBtn.removeEventListener('click', this.flipHorizontal);
390
- if (this._flipVBtn) this._flipVBtn.removeEventListener('click', this.flipVertical);
391
- if (this._rotCWBtn) this._rotCWBtn.removeEventListener('click', this.rotateCW);
392
- if (this._rotCCWBtn) this._rotCCWBtn.removeEventListener('click', this.rotateCCW);
393
- if (this._clearBtn) this._clearBtn.removeEventListener('click', () => this.clear([0, 0, 0, 0]));
394
- if (this._opacityRange) this._opacityRange.removeEventListener('input', () => {});
395
- if (this._opacityNumber) this._opacityNumber.removeEventListener('change', () => {});
396
-
397
- if (this._undoBtn) this._undoBtn.removeEventListener('click', () => this.undo());
398
- if (this._redoBtn) this._redoBtn.removeEventListener('click', () => this.redo());
399
-
400
- window.removeEventListener('keydown', this._onKeyDown);
401
- }
402
-
403
- // ---------------- Matrix helpers ----------------
404
- _createEmptyMatrix(w, h) {
405
- const mat = new Array(h);
406
- for (let y = 0; y < h; y++) {
407
- mat[y] = new Array(w);
408
- for (let x = 0; x < w; x++) mat[y][x] = [0, 0, 0, 0];
409
- }
410
- return mat;
411
- }
412
-
413
- createMatrix(width, height, fill = [0, 0, 0, 0]) {
414
- const w = Math.max(1, Math.floor(width));
415
- const h = Math.max(1, Math.floor(height));
416
- const mat = this._createEmptyMatrix(w, h);
417
- for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) mat[y][x] = fill.slice();
418
- return mat;
419
- }
420
-
421
- loadMatrix(matrix) {
422
- if (!Array.isArray(matrix) || matrix.length === 0) throw new TypeError('matrix must be non-empty 2D array');
423
- const h = matrix.length;
424
- const w = matrix[0].length;
425
- for (let y = 0; y < h; y++) {
426
- if (!Array.isArray(matrix[y]) || matrix[y].length !== w) throw new TypeError('matrix must be rectangular');
427
- for (let x = 0; x < w; x++) {
428
- const v = matrix[y][x];
429
- if (!Array.isArray(v) || v.length !== 4) throw new TypeError('each cell must be [r,g,b,a]');
430
- matrix[y][x] = v.map((n) => this._clampInt(n));
431
- }
432
- }
433
- this._width = w;
434
- this._height = h;
435
- this._matrix = matrix.map((r) => r.map((c) => c.slice()));
436
- this._setupContextsAndSize();
437
- this.render();
438
- this.dispatchEvent(new CustomEvent('matrixload', { detail: { width: w, height: h } }));
439
- }
440
-
441
- clear(fill = [0, 0, 0, 0]) {
442
- for (let y = 0; y < this._height; y++) for (let x = 0; x < this._width; x++) this._matrix[y][x] = fill.slice();
443
- this.render();
444
- this.dispatchEvent(new CustomEvent('clear'));
445
- }
446
-
447
- resize(w, h, { preserve = true } = {}) {
448
- const nw = Math.max(1, Math.floor(w));
449
- const nh = Math.max(1, Math.floor(h));
450
- const newMat = this._createEmptyMatrix(nw, nh);
451
- if (preserve) {
452
- const minW = Math.min(nw, this._width);
453
- const minH = Math.min(nh, this._height);
454
- for (let y = 0; y < minH; y++) for (let x = 0; x < minW; x++) newMat[y][x] = this._matrix[y][x].slice();
455
- }
456
- this._width = nw;
457
- this._height = nh;
458
- this._matrix = newMat;
459
-
460
- // keep inputs and attributes in sync
461
- if (this._widthInput) this._widthInput.value = String(this._width);
462
- if (this._heightInput) this._heightInput.value = String(this._height);
463
- this.setAttribute('width', String(this._width));
464
- this.setAttribute('height', String(this._height));
465
-
466
- this._setupContextsAndSize();
467
- this.render();
468
- this.dispatchEvent(new CustomEvent('resize', { detail: { width: nw, height: nh } }));
469
- }
470
-
471
- setPixel(x, y, rgba, renderNow = true) {
472
- if (!this._inBounds(x, y)) return false;
473
- this._matrix[y][x] = rgba.map((n) => this._clampInt(n));
474
- if (renderNow) this.render();
475
- this.dispatchEvent(new CustomEvent('pixelchange', { detail: { x, y, rgba: this._matrix[y][x].slice() } }));
476
- return true;
477
- }
478
-
479
- getPixel(x, y) {
480
- return this._inBounds(x, y) ? this._matrix[y][x].slice() : null;
481
- }
482
- _inBounds(x, y) {
483
- return x >= 0 && y >= 0 && x < this._width && y < this._height;
484
- }
485
- _clampInt(v) {
486
- const n = Number(v) || 0;
487
- return Math.min(255, Math.max(0, Math.floor(n)));
488
- }
489
-
490
- // ---------------- Canvas sizing and contexts ----------------
491
- _setupContextsAndSize() {
492
- // logical canvas (one logical pixel per image pixel). CSS scales by pixelSize.
493
- this._pixelCanvas.width = this._width;
494
- this._pixelCanvas.height = this._height;
495
- this._pixelCanvas.style.width = `${this._width * this._pixelSize}px`;
496
- this._pixelCanvas.style.height = `${this._height * this._pixelSize}px`;
497
-
498
- // grid overlay uses CSS pixel coordinates
499
- this._gridCanvas.width = this._width * this._pixelSize;
500
- this._gridCanvas.height = this._height * this._pixelSize;
501
- this._gridCanvas.style.width = this._pixelCanvas.style.width;
502
- this._gridCanvas.style.height = this._pixelCanvas.style.height;
503
-
504
- this._pixelCtx = this._pixelCanvas.getContext('2d');
505
- this._gridCtx = this._gridCanvas.getContext('2d');
506
- try {
507
- this._pixelCtx.imageSmoothingEnabled = false;
508
- this._gridCtx.imageSmoothingEnabled = false;
509
- } catch (e) {}
510
- this._renderGrid();
511
- }
512
-
513
- render() {
514
- // sanity: ensure matrix shape matches
515
- if (
516
- !Array.isArray(this._matrix) ||
517
- this._matrix.length !== this._height ||
518
- !Array.isArray(this._matrix[0]) ||
519
- this._matrix[0].length !== this._width
520
- ) {
521
- this._matrix = this._createEmptyMatrix(this._width, this._height);
522
- }
523
-
524
- // detect transparency (fast bailout)
525
- let hasTransparent = false;
526
- for (let y = 0; y < this._height && !hasTransparent; y++) {
527
- for (let x = 0; x < this._width; x++) {
528
- const a = this._matrix[y] && this._matrix[y][x] ? this._matrix[y][x][3] : 0;
529
- if (a !== 255) {
530
- hasTransparent = true;
531
- break;
532
- }
533
- }
534
- }
535
-
536
- // clear and optionally draw checkerboard (visual only)
537
- this._pixelCtx.clearRect(0, 0, this._pixelCanvas.width, this._pixelCanvas.height);
538
- if (hasTransparent) this._drawCheckerboard();
539
-
540
- // write image data
541
- const img = this._pixelCtx.createImageData(this._width, this._height);
542
- const data = img.data;
543
- let p = 0;
544
- for (let y = 0; y < this._height; y++) {
545
- for (let x = 0; x < this._width; x++) {
546
- const cell = this._matrix[y] && this._matrix[y][x] ? this._matrix[y][x] : [0, 0, 0, 0];
547
- data[p++] = this._clampInt(cell[0]);
548
- data[p++] = this._clampInt(cell[1]);
549
- data[p++] = this._clampInt(cell[2]);
550
- data[p++] = this._clampInt(cell[3]);
551
- }
552
- }
553
- this._pixelCtx.putImageData(img, 0, 0);
554
-
555
- if (this._showGrid) this._renderGrid();
556
- }
557
-
558
- _drawCheckerboard() {
559
- const ctx = this._pixelCtx;
560
- const w = this._width;
561
- const h = this._height;
562
- const light = '#e9e9e9',
563
- dark = '#cfcfcf';
564
- // draw one logical pixel per matrix cell
565
- for (let y = 0; y < h; y++) {
566
- for (let x = 0; x < w; x++) {
567
- ctx.fillStyle = ((x + y) & 1) === 0 ? light : dark;
568
- ctx.fillRect(x, y, 1, 1);
569
- }
570
- }
571
- }
572
-
573
- _renderGrid() {
574
- const ctx = this._gridCtx;
575
- if (!ctx) return;
576
- const w = this._gridCanvas.width;
577
- const h = this._gridCanvas.height;
578
- ctx.clearRect(0, 0, w, h);
579
- if (!this._showGrid) return;
580
- const ps = this._pixelSize;
581
- ctx.save();
582
- ctx.strokeStyle = darkTheme ? '#e1e1e1' : '#272727';
583
- ctx.lineWidth = 2;
584
- ctx.beginPath();
585
- for (let x = 0; x <= this._width; x++) {
586
- const xx = x * ps + 0.5;
587
- ctx.moveTo(xx, 0);
588
- ctx.lineTo(xx, h);
589
- }
590
- for (let y = 0; y <= this._height; y++) {
591
- const yy = y * ps + 0.5;
592
- ctx.moveTo(0, yy);
593
- ctx.lineTo(w, yy);
594
- }
595
- ctx.stroke();
596
- ctx.restore();
597
- }
598
-
599
- // ---------------- Tools & painting ----------------
600
- setTool(name) {
601
- this._tool = name;
602
- if (this._toolSelect) this._toolSelect.value = name;
603
- }
604
-
605
- // set full RGBA brush color (alpha optional)
606
- setBrushColor(rgba) {
607
- if (!Array.isArray(rgba) || rgba.length < 3) return;
608
- const r = this._clampInt(rgba[0]);
609
- const g = this._clampInt(rgba[1]);
610
- const b = this._clampInt(rgba[2]);
611
- const a = typeof rgba[3] === 'number' ? this._clampInt(rgba[3]) : this._brushColor[3];
612
- this._brushColor = [r, g, b, a];
613
- if (this._colorInput) this._colorInput.value = this._rgbaToHex(this._brushColor);
614
- if (this._hexInput) this._hexInput.value = this._rgbaToHexWithAlpha(this._brushColor);
615
- if (this._rInput) this._rInput.value = String(this._brushColor[0]);
616
- if (this._gInput) this._gInput.value = String(this._brushColor[1]);
617
- if (this._bInput) this._bInput.value = String(this._brushColor[2]);
618
- if (this._aInput) this._aInput.value = String(this._brushColor[3]);
619
- if (this._opacityRange) this._opacityRange.value = String(this._brushColor[3]);
620
- if (this._opacityNumber) this._opacityNumber.value = String(this._brushColor[3]);
621
- }
622
-
623
- // set brush alpha (0-255)
624
- setBrushAlpha(a) {
625
- const v = Math.max(0, Math.min(255, Math.floor(Number(a) || 0)));
626
- this._brushColor[3] = v;
627
- if (this._opacityRange) this._opacityRange.value = String(v);
628
- if (this._opacityNumber) this._opacityNumber.value = String(v);
629
- if (this._aInput) this._aInput.value = String(v);
630
- // keep color input (hex) representing rgb only
631
- if (this._colorInput) this._colorInput.value = this._rgbaToHex(this._brushColor);
632
- if (this._hexInput) this._hexInput.value = this._rgbaToHexWithAlpha(this._brushColor);
633
- }
634
- getBrushAlpha() {
635
- return this._brushColor[3];
636
- }
637
-
638
- setBrushSize(n) {
639
- this._brushSize = Math.max(1, Math.floor(n));
640
- if (this._brushSizeInput) this._brushSizeInput.value = this._brushSize;
641
- }
642
-
643
- _applyBrush(x, y, color, renderAfter = false) {
644
- const half = Math.floor(this._brushSize / 2);
645
- for (let oy = -half; oy <= half; oy++)
646
- for (let ox = -half; ox <= half; ox++) {
647
- const tx = x + ox,
648
- ty = y + oy;
649
- if (this._inBounds(tx, ty)) this._matrix[ty][tx] = color.slice();
650
- }
651
- if (renderAfter) this.render();
652
- }
653
-
654
- fillBucket(x, y, targetColor = null) {
655
- if (!this._inBounds(x, y)) return;
656
- this._beginTransaction();
657
- const src = this.getPixel(x, y);
658
- const newColor = targetColor ? targetColor.slice() : this._brushColor.slice();
659
- if (this._colorsEqual(src, newColor)) {
660
- this._endTransaction();
661
- return;
662
- }
663
- const stack = [[x, y]];
664
- while (stack.length) {
665
- const [cx, cy] = stack.pop();
666
- if (!this._inBounds(cx, cy)) continue;
667
- const cur = this.getPixel(cx, cy);
668
- if (!this._colorsEqual(cur, src)) continue;
669
- this._matrix[cy][cx] = newColor.slice();
670
- stack.push([cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]);
671
- }
672
- this.render();
673
- this.dispatchEvent(new CustomEvent('fill', { detail: { x, y } }));
674
- this._endTransaction();
675
- }
676
-
677
- _colorsEqual(a, b) {
678
- if (!a || !b) return false;
679
- return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3];
680
- }
681
-
682
- // ---------------- Pointer handling ----------------
683
- _toGridCoords(evt) {
684
- const rect = this._pixelCanvas.getBoundingClientRect();
685
- const cssX = evt.clientX - rect.left;
686
- const cssY = evt.clientY - rect.top;
687
- const scaleX = this._pixelCanvas.width / rect.width;
688
- const scaleY = this._pixelCanvas.height / rect.height;
689
- const x = Math.floor(cssX * scaleX);
690
- const y = Math.floor(cssY * scaleY);
691
- return [x, y];
692
- }
693
-
694
- _onPointerDown(evt) {
695
- evt.preventDefault();
696
- this._isPointerDown = true;
697
- try {
698
- this._pixelCanvas.setPointerCapture(evt.pointerId);
699
- } catch (e) {}
700
- const [x, y] = this._toGridCoords(evt);
701
- // start transaction for continuous stroke
702
- this._beginTransaction();
703
- this._applyToolAt(x, y, evt);
704
- }
705
- _onPointerMove(evt) {
706
- if (!this._isPointerDown) return;
707
- const [x, y] = this._toGridCoords(evt);
708
- this._applyToolAt(x, y, evt, true);
709
- }
710
- _onPointerUp(evt) {
711
- this._isPointerDown = false;
712
- try {
713
- this._pixelCanvas.releasePointerCapture(evt.pointerId);
714
- } catch (e) {}
715
- // finish transaction for the stroke
716
- this._endTransaction();
717
- }
718
-
719
- _applyToolAt(x, y, evt, continuous = false) {
720
- if (!this._inBounds(x, y)) return;
721
- switch (this._tool) {
722
- case 'pencil':
723
- this._applyBrush(x, y, this._brushColor, true);
724
- break;
725
- case 'eraser':
726
- this._applyBrush(x, y, [0, 0, 0, 0], true);
727
- break;
728
- case 'fill':
729
- if (!continuous) this.fillBucket(x, y);
730
- break;
731
- case 'eyedropper':
732
- const picked = this.getPixel(x, y);
733
- if (picked) this.setBrushColor(picked);
734
- break;
735
- }
736
- }
737
-
738
- // ---------------- Import / Export ----------------
739
- exportMatrixJSON() {
740
- return JSON.stringify({ width: this._width, height: this._height, matrix: this._matrix });
741
- }
742
- importMatrixJSON(json) {
743
- const data = typeof json === 'string' ? JSON.parse(json) : json;
744
- if (!data || !Array.isArray(data.matrix)) throw new TypeError('Invalid matrix JSON');
745
- // wrap import as transactional change
746
- this.loadMatrix(data.matrix);
747
- }
748
-
749
- async _pickFile() {
750
- return new Promise((resolve) => {
751
- const input = document.createElement('input');
752
- input.type = 'file';
753
- input.accept = 'application/json';
754
- input.addEventListener('change', () => {
755
- resolve(input.files && input.files[0] ? input.files[0] : null);
756
- });
757
- input.click();
758
- });
759
- }
760
-
761
- // Create a PNG data URL at the requested scale (scale = number of CSS pixels per logical pixel)
762
- toDataURL(scale = this._pixelSize) {
763
- const w = this._width,
764
- h = this._height;
765
- const outW = Math.max(1, Math.floor(w * scale));
766
- const outH = Math.max(1, Math.floor(h * scale));
767
-
768
- // create logical image at native resolution
769
- const src = document.createElement('canvas');
770
- src.width = w;
771
- src.height = h;
772
- const sctx = src.getContext('2d');
773
- const img = sctx.createImageData(w, h);
774
- const data = img.data;
775
- let p = 0;
776
- for (let y = 0; y < h; y++)
777
- for (let x = 0; x < w; x++) {
778
- const c = this._matrix[y][x] || [0, 0, 0, 0];
779
- data[p++] = this._clampInt(c[0]);
780
- data[p++] = this._clampInt(c[1]);
781
- data[p++] = this._clampInt(c[2]);
782
- data[p++] = this._clampInt(c[3]);
783
- }
784
- sctx.putImageData(img, 0, 0);
785
-
786
- // scale into output canvas (nearest-neighbor)
787
- const out = document.createElement('canvas');
788
- out.width = outW;
789
- out.height = outH;
790
- const octx = out.getContext('2d');
791
- try {
792
- octx.imageSmoothingEnabled = false;
793
- } catch (e) {}
794
- octx.drawImage(src, 0, 0, outW, outH);
795
- return out.toDataURL('image/png');
796
- }
797
-
798
- // Async blob version (recommended for large images)
799
- toBlob(scale = this._pixelSize) {
800
- return new Promise((resolve) => {
801
- const w = this._width,
802
- h = this._height;
803
- const outW = Math.max(1, Math.floor(w * scale));
804
- const outH = Math.max(1, Math.floor(h * scale));
805
- const src = document.createElement('canvas');
806
- src.width = w;
807
- src.height = h;
808
- const sctx = src.getContext('2d');
809
- const img = sctx.createImageData(w, h);
810
- const data = img.data;
811
- let p = 0;
812
- for (let y = 0; y < h; y++)
813
- for (let x = 0; x < w; x++) {
814
- const c = this._matrix[y][x] || [0, 0, 0, 0];
815
- data[p++] = this._clampInt(c[0]);
816
- data[p++] = this._clampInt(c[1]);
817
- data[p++] = this._clampInt(c[2]);
818
- data[p++] = this._clampInt(c[3]);
819
- }
820
- sctx.putImageData(img, 0, 0);
821
- const out = document.createElement('canvas');
822
- out.width = outW;
823
- out.height = outH;
824
- const octx = out.getContext('2d');
825
- try {
826
- octx.imageSmoothingEnabled = false;
827
- } catch (e) {}
828
- octx.drawImage(src, 0, 0, outW, outH);
829
- out.toBlob((b) => resolve(b), 'image/png');
830
- });
831
- }
832
-
833
- // Trigger download of PNG (uses blob to avoid huge data URLs on big exports)
834
- async exportPNG(filename = 'object-layer.png', scale = this._pixelSize) {
835
- const blob = await this.toBlob(scale);
836
- const url = URL.createObjectURL(blob);
837
- const a = document.createElement('a');
838
- a.href = url;
839
- a.download = filename;
840
- a.click();
841
- // revoke after a tick to ensure download started
842
- setTimeout(() => URL.revokeObjectURL(url), 5000);
843
- }
844
-
845
- // ---------------- Undo/Redo helpers ----------------
846
- _snapshot() {
847
- return {
848
- width: this._width,
849
- height: this._height,
850
- matrix: this._matrix.map((r) => r.map((c) => c.slice())),
851
- };
852
- }
853
-
854
- _loadSnapshot(snap) {
855
- if (!snap) return;
856
- this._width = snap.width;
857
- this._height = snap.height;
858
- this._matrix = snap.matrix.map((r) => r.map((c) => c.slice()));
859
- if (this._widthInput) this._widthInput.value = String(this._width);
860
- if (this._heightInput) this._heightInput.value = String(this._height);
861
- this.setAttribute('width', String(this._width));
862
- this.setAttribute('height', String(this._height));
863
- this._setupContextsAndSize();
864
- this.render();
865
- this.dispatchEvent(new CustomEvent('matrixload', { detail: { width: this._width, height: this._height } }));
866
- }
867
-
868
- _matricesEqual(a, b) {
869
- if (!a || !b) return false;
870
- if (a.width !== b.width || a.height !== b.height) return false;
871
- const h = a.height;
872
- for (let y = 0; y < h; y++) {
873
- for (let x = 0; x < a.width; x++) {
874
- const ac = a.matrix[y][x];
875
- const bc = b.matrix[y][x];
876
- for (let i = 0; i < 4; i++) if (ac[i] !== bc[i]) return false;
877
- }
878
- }
879
- return true;
880
- }
881
-
882
- _pushUndo(snap) {
883
- this._undoStack.push(snap);
884
- if (this._undoStack.length > this._maxHistory) this._undoStack.shift();
885
- // clear redo
886
- this._redoStack.length = 0;
887
- this._updateToolbarButtons();
888
- }
889
-
890
- _clearHistory() {
891
- this._undoStack.length = 0;
892
- this._redoStack.length = 0;
893
- this._updateToolbarButtons();
894
- }
895
-
896
- _beginTransaction() {
897
- if (this._transactionActive) return;
898
- this._transactionActive = true;
899
- const before = this._snapshot();
900
- this._pushUndo(before);
901
- }
902
-
903
- _endTransaction() {
904
- if (!this._transactionActive) return;
905
- this._transactionActive = false;
906
- // if the last undo state is identical to current (no-op), remove it
907
- const last = this._undoStack[this._undoStack.length - 1];
908
- const now = this._snapshot();
909
- if (this._matricesEqual(last, now)) this._undoStack.pop();
910
- this._updateToolbarButtons();
911
- }
912
-
913
- undo() {
914
- if (!this._undoStack.length) return;
915
- const snap = this._undoStack.pop();
916
- // push current to redo
917
- this._redoStack.push(this._snapshot());
918
- this._loadSnapshot(snap);
919
- this._updateToolbarButtons();
920
- this.dispatchEvent(new CustomEvent('undo'));
921
- }
922
-
923
- redo() {
924
- if (!this._redoStack.length) return;
925
- const snap = this._redoStack.pop();
926
- // push current to undo
927
- this._undoStack.push(this._snapshot());
928
- this._loadSnapshot(snap);
929
- this._updateToolbarButtons();
930
- this.dispatchEvent(new CustomEvent('redo'));
931
- }
932
-
933
- _updateToolbarButtons() {
934
- if (this._undoBtn) this._undoBtn.disabled = this._undoStack.length === 0;
935
- if (this._redoBtn) this._redoBtn.disabled = this._redoStack.length === 0;
936
- }
937
-
938
- _onKeyDown(e) {
939
- const meta = e.ctrlKey || e.metaKey;
940
- if (!meta) return;
941
- // ctrl/cmd+z -> undo, ctrl/cmd+shift+z or ctrl+ y -> redo
942
- if (e.key === 'z' || e.key === 'Z') {
943
- if (e.shiftKey) {
944
- e.preventDefault();
945
- this.redo();
946
- } else {
947
- e.preventDefault();
948
- this.undo();
949
- }
950
- } else if (e.key === 'y' || e.key === 'Y') {
951
- e.preventDefault();
952
- this.redo();
953
- }
954
- }
955
-
956
- // ---------------- Helpers ----------------
957
- _hexToRgba(hex) {
958
- const h = (hex || '').replace('#', '');
959
- if (h.length === 3) {
960
- return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16), 255];
961
- }
962
- if (h.length === 6) {
963
- return [parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16), parseInt(h.substring(4, 6), 16), 255];
964
- }
965
- return [0, 0, 0, 255];
966
- }
967
- _rgbaToHex(rgba) {
968
- const [r, g, b] = rgba;
969
- return `#${((1 << 24) + (this._clampInt(r) << 16) + (this._clampInt(g) << 8) + this._clampInt(b))
970
- .toString(16)
971
- .slice(1)}`;
972
- }
973
-
974
- // convert RGBA to hex with alpha channel
975
- _rgbaToHexWithAlpha(rgba) {
976
- const [r, g, b, a] = rgba;
977
- const rHex = this._clampInt(r).toString(16).padStart(2, '0');
978
- const gHex = this._clampInt(g).toString(16).padStart(2, '0');
979
- const bHex = this._clampInt(b).toString(16).padStart(2, '0');
980
- const aHex = this._clampInt(a).toString(16).padStart(2, '0');
981
- return `#${rHex}${gHex}${bHex}${aHex}`.toUpperCase();
982
- }
983
-
984
- // convert hex to RGBA with optional alpha channel support
985
- _hexToRgbaWithAlpha(hex) {
986
- const h = (hex || '').replace('#', '');
987
- if (h.length === 3) {
988
- // #RGB -> expand to RRGGBB
989
- return [parseInt(h[0] + h[0], 16), parseInt(h[1] + h[1], 16), parseInt(h[2] + h[2], 16), 255];
990
- }
991
- if (h.length === 4) {
992
- // #RGBA -> expand to RRGGBBAA
993
- return [
994
- parseInt(h[0] + h[0], 16),
995
- parseInt(h[1] + h[1], 16),
996
- parseInt(h[2] + h[2], 16),
997
- parseInt(h[3] + h[3], 16),
998
- ];
999
- }
1000
- if (h.length === 6) {
1001
- // #RRGGBB
1002
- return [parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16), parseInt(h.substring(4, 6), 16), 255];
1003
- }
1004
- if (h.length === 8) {
1005
- // #RRGGBBAA
1006
- return [
1007
- parseInt(h.substring(0, 2), 16),
1008
- parseInt(h.substring(2, 4), 16),
1009
- parseInt(h.substring(4, 6), 16),
1010
- parseInt(h.substring(6, 8), 16),
1011
- ];
1012
- }
1013
- return [0, 0, 0, 255];
1014
- }
1015
-
1016
- // ---------------- Transform helpers (flip/rotate) ----------------
1017
- flipHorizontal() {
1018
- // reverse each row (mirror horizontally)
1019
- for (let y = 0; y < this._height; y++) {
1020
- this._matrix[y].reverse();
1021
- }
1022
- this.render();
1023
- this.dispatchEvent(new CustomEvent('transform', { detail: { type: 'flip-horizontal' } }));
1024
- }
1025
-
1026
- flipVertical() {
1027
- // reverse the order of rows (mirror vertically)
1028
- this._matrix.reverse();
1029
- this.render();
1030
- this.dispatchEvent(new CustomEvent('transform', { detail: { type: 'flip-vertical' } }));
1031
- }
1032
-
1033
- rotateCW() {
1034
- // rotate +90 degrees (clockwise)
1035
- const oldH = this._height;
1036
- const oldW = this._width;
1037
- const newW = oldH;
1038
- const newH = oldW;
1039
- const newMat = this._createEmptyMatrix(newW, newH);
1040
- for (let y = 0; y < oldH; y++) {
1041
- for (let x = 0; x < oldW; x++) {
1042
- const px = this._matrix[y][x] ? this._matrix[y][x].slice() : [0, 0, 0, 0];
1043
- const newX = oldH - 1 - y; // column in new matrix
1044
- const newY = x; // row in new matrix
1045
- newMat[newY][newX] = px;
1046
- }
1047
- }
1048
- this._width = newW;
1049
- this._height = newH;
1050
- this._matrix = newMat;
1051
- // keep inputs/attributes in sync
1052
- if (this._widthInput) this._widthInput.value = String(this._width);
1053
- if (this._heightInput) this._heightInput.value = String(this._height);
1054
- this.setAttribute('width', String(this._width));
1055
- this.setAttribute('height', String(this._height));
1056
-
1057
- this._setupContextsAndSize();
1058
- this.render();
1059
- this.dispatchEvent(
1060
- new CustomEvent('transform', { detail: { type: 'rotate-cw', width: this._width, height: this._height } }),
1061
- );
1062
- }
1063
-
1064
- rotateCCW() {
1065
- // rotate -90 degrees (counter-clockwise)
1066
- const oldH = this._height;
1067
- const oldW = this._width;
1068
- const newW = oldH;
1069
- const newH = oldW;
1070
- const newMat = this._createEmptyMatrix(newW, newH);
1071
- for (let y = 0; y < oldH; y++) {
1072
- for (let x = 0; x < oldW; x++) {
1073
- const px = this._matrix[y][x] ? this._matrix[y][x].slice() : [0, 0, 0, 0];
1074
- const newX = y; // column in new matrix
1075
- const newY = oldW - 1 - x; // row in new matrix
1076
- newMat[newY][newX] = px;
1077
- }
1078
- }
1079
- this._width = newW;
1080
- this._height = newH;
1081
- this._matrix = newMat;
1082
- if (this._widthInput) this._widthInput.value = String(this._width);
1083
- if (this._heightInput) this._heightInput.value = String(this._height);
1084
- this.setAttribute('width', String(this._width));
1085
- this.setAttribute('height', String(this._height));
1086
-
1087
- this._setupContextsAndSize();
1088
- this.render();
1089
- this.dispatchEvent(
1090
- new CustomEvent('transform', { detail: { type: 'rotate-ccw', width: this._width, height: this._height } }),
1091
- );
1092
- }
1093
-
1094
- // ---------------- Properties ----------------
1095
- get width() {
1096
- return this._width;
1097
- }
1098
- set width(v) {
1099
- this._width = Math.max(1, Math.floor(v));
1100
- this.setAttribute('width', String(this._width));
1101
- this._setupContextsAndSize();
1102
- this.render();
1103
- }
1104
- get height() {
1105
- return this._height;
1106
- }
1107
- set height(v) {
1108
- this._height = Math.max(1, Math.floor(v));
1109
- this.setAttribute('height', String(this._height));
1110
- this._setupContextsAndSize();
1111
- this.render();
1112
- }
1113
- get pixelSize() {
1114
- return this._pixelSize;
1115
- }
1116
- set pixelSize(v) {
1117
- this._pixelSize = Math.max(1, Math.floor(v));
1118
- this.setAttribute('pixel-size', String(this._pixelSize));
1119
- this._setupContextsAndSize();
1120
- this.render();
1121
- }
1122
- get brushSize() {
1123
- return this._brushSize;
1124
- }
1125
- set brushSize(v) {
1126
- this.setBrushSize(v);
1127
- }
1128
- get matrix() {
1129
- return this._matrix.map((row) => row.map((c) => c.slice()));
1130
- }
1131
-
1132
- exportJSON() {
1133
- return this.exportMatrixJSON();
1134
- }
1135
- importJSON(json) {
1136
- return this.importMatrixJSON(json);
1137
- }
1138
- }
1139
-
1140
- customElements.define('object-layer-engine', ObjectLayerEngineElement);
1141
-
1142
- /*
1143
- Example usage:
1144
- <object-layer-engine id="ole" width="20" height="12" pixel-size="20"></object-layer-engine>
1145
- */
1146
-
1147
- class ObjectLayerPngLoader extends HTMLElement {
1148
- constructor() {
1149
- super();
1150
- this.attachShadow({ mode: 'open' });
1151
- this.shadowRoot.innerHTML = html`
1152
- <style>
1153
- :host {
1154
- display: block;
1155
- font-family:
1156
- system-ui,
1157
- -apple-system,
1158
- 'Segoe UI',
1159
- Roboto,
1160
- Arial;
1161
- }
1162
- .wrap {
1163
- display: flex;
1164
- flex-direction: column;
1165
- gap: 8px;
1166
- }
1167
- .controls {
1168
- display: flex;
1169
- gap: 8px;
1170
- align-items: center;
1171
- flex-wrap: wrap;
1172
- }
1173
- .drop-area {
1174
- border: 2px dashed #999;
1175
- padding: 12px;
1176
- border-radius: 8px;
1177
- text-align: center;
1178
- color: #555;
1179
- user-select: none;
1180
- }
1181
- .drop-area.dragover {
1182
- border-color: #4a90e2;
1183
- color: #1a73e8;
1184
- background: rgba(74, 144, 226, 0.04);
1185
- }
1186
- input[type='file'] {
1187
- display: inline-block;
1188
- }
1189
- .hint {
1190
- font-size: 0.9rem;
1191
- color: #666;
1192
- }
1193
- </style>
1194
-
1195
- <div class="wrap">
1196
- <div class="controls">
1197
- <label title="Load PNG file">
1198
- <input type="file" accept="image/png" part="file-input" />
1199
- <span class="btn">Choose PNG</span>
1200
- </label>
1201
- <div class="hint">Only PNG images accepted. Drop PNG onto the box below.</div>
1202
- </div>
1203
-
1204
- <div class="drop-area" part="drop-area">Drop PNG here or click "Choose PNG"</div>
1205
- </div>
1206
- `;
1207
-
1208
- this._fileInput = this.shadowRoot.querySelector('input[type="file"]');
1209
- this._dropArea = this.shadowRoot.querySelector('.drop-area');
1210
-
1211
- this._editor = null; // will hold external editor instance
1212
- this._options = { fitMode: 'contain' };
1213
-
1214
- // Bind handlers
1215
- this._onFileChange = this._onFileChange.bind(this);
1216
- this._onDrop = this._onDrop.bind(this);
1217
- this._onDragOver = this._onDragOver.bind(this);
1218
- this._onDragLeave = this._onDragLeave.bind(this);
1219
- }
1220
-
1221
- static get observedAttributes() {
1222
- return ['editor-selector', 'fit-mode', 'target-cells-x', 'target-cells-y'];
1223
- }
1224
-
1225
- attributeChangedCallback(name, oldVal, newVal) {
1226
- if (name === 'editor-selector' && newVal) {
1227
- const el = document.querySelector(newVal);
1228
- if (el) this.setEditor(el);
1229
- }
1230
- if (name === 'fit-mode') {
1231
- this._options.fitMode = newVal || 'contain';
1232
- }
1233
- }
1234
-
1235
- connectedCallback() {
1236
- this._fileInput.addEventListener('change', this._onFileChange);
1237
- this._dropArea.addEventListener('dragover', this._onDragOver);
1238
- this._dropArea.addEventListener('dragleave', this._onDragLeave);
1239
- this._dropArea.addEventListener('drop', this._onDrop);
1240
- this.addEventListener('dragover', this._onDragOver);
1241
- this.addEventListener('dragleave', this._onDragLeave);
1242
- this.addEventListener('drop', this._onDrop);
1243
-
1244
- // If editor-selector attribute was present at creation, try to resolve
1245
- const sel = this.getAttribute('editor-selector');
1246
- if (sel) {
1247
- const target = document.querySelector(sel);
1248
- if (target) this.setEditor(target);
1249
- }
1250
-
1251
- // read fit-mode
1252
- const fit = this.getAttribute('fit-mode');
1253
- if (fit) this._options.fitMode = fit;
1254
- }
1255
-
1256
- disconnectedCallback() {
1257
- this._fileInput.removeEventListener('change', this._onFileChange);
1258
- this._dropArea.removeEventListener('dragover', this._onDragOver);
1259
- this._dropArea.removeEventListener('dragleave', this._onDragLeave);
1260
- this._dropArea.removeEventListener('drop', this._onDrop);
1261
- this.removeEventListener('dragover', this._onDragOver);
1262
- this.removeEventListener('dragleave', this._onDragLeave);
1263
- this.removeEventListener('drop', this._onDrop);
1264
- }
1265
-
1266
- // ----------------- Public API -----------------
1267
- setEditor(editor) {
1268
- if (!editor) throw new Error('Editor cannot be null/undefined');
1269
- if (typeof editor.loadMatrix !== 'function') {
1270
- throw new Error('Provided editor does not expose loadMatrix(matrix)');
1271
- }
1272
- this._editor = editor;
1273
- this.dispatchEvent(new CustomEvent('editorconnected', { detail: { editor } }));
1274
- }
1275
-
1276
- setOptions(options = {}) {
1277
- if (options.fitMode) this._options.fitMode = options.fitMode;
1278
- if (options.targetCellsX) this.setAttribute('target-cells-x', String(options.targetCellsX));
1279
- if (options.targetCellsY) this.setAttribute('target-cells-y', String(options.targetCellsY));
1280
- }
1281
-
1282
- get editor() {
1283
- return this._editor;
1284
- }
1285
-
1286
- // ----------------- Events -----------------
1287
- _onFileChange(e) {
1288
- const file = e.target.files && e.target.files[0] ? e.target.files[0] : null;
1289
- if (!file) return;
1290
- this._handleFile(file);
1291
- this._fileInput.value = '';
1292
- }
1293
-
1294
- _onDragOver(e) {
1295
- e.preventDefault();
1296
- e.dataTransfer.dropEffect = 'copy';
1297
- this._dropArea.classList.add('dragover');
1298
- }
1299
- _onDragLeave(e) {
1300
- e.preventDefault();
1301
- this._dropArea.classList.remove('dragover');
1302
- }
1303
-
1304
- _onDrop(e) {
1305
- e.preventDefault();
1306
- this._dropArea.classList.remove('dragover');
1307
- const file = e.dataTransfer.files && e.dataTransfer.files[0] ? e.dataTransfer.files[0] : null;
1308
- if (!file) return;
1309
- this._handleFile(file);
1310
- }
1311
-
1312
- // ----------------- File handling -----------------
1313
- async _handleFile(file) {
1314
- const isPngByType = file.type === 'image/png';
1315
- const isPngByName = file.name && file.name.toLowerCase().endsWith('.png');
1316
- if (!isPngByType && !isPngByName) {
1317
- this._showError('Only PNG files are supported.');
1318
- return;
1319
- }
1320
-
1321
- if (!this._editor) {
1322
- this._showError('No editor connected. Use setEditor(editor) or provide editor-selector attribute.');
1323
- return;
1324
- }
1325
-
1326
- try {
1327
- await this._loadPngToEditorAdaptive(file);
1328
- this._dispatchLoadedEvent(file.name);
1329
- } catch (err) {
1330
- console.error('Failed to load PNG', err);
1331
- this._showError('Failed to load PNG (see console).');
1332
- }
1333
- }
1334
-
1335
- _showError(msg) {
1336
- NotificationManager.Push({
1337
- status: 'error',
1338
- html: msg,
1339
- });
1340
- }
1341
- _dispatchLoadedEvent(filename) {
1342
- this.dispatchEvent(new CustomEvent('pngloaded', { detail: { filename } }));
1343
- }
1344
-
1345
- // ----------------- Adaptive load -----------------
1346
- _readEditorConfig() {
1347
- const ed = this._editor;
1348
- const cfg = { pixelSize: null, cellsX: null, cellsY: null };
1349
-
1350
- if (!ed) return cfg;
1351
-
1352
- // pixel size detection (try multiple forms)
1353
- cfg.pixelSize = ed.pixelSize || ed.pixel_size || null;
1354
- if (!cfg.pixelSize) {
1355
- const attr = ed.getAttribute && (ed.getAttribute('pixel-size') || ed.getAttribute('pixelSize'));
1356
- if (attr) cfg.pixelSize = parseInt(attr, 10);
1357
- }
1358
- if (typeof cfg.pixelSize === 'string') cfg.pixelSize = parseInt(cfg.pixelSize, 10);
1359
-
1360
- // cells detection (common attribute names: width/height on engine represent cells)
1361
- const widthAttr = ed.getAttribute && ed.getAttribute('width');
1362
- const heightAttr = ed.getAttribute && ed.getAttribute('height');
1363
- if (widthAttr && heightAttr) {
1364
- cfg.cellsX = parseInt(widthAttr, 10);
1365
- cfg.cellsY = parseInt(heightAttr, 10);
1366
- }
1367
-
1368
- // alternative property names
1369
- cfg.cellsX = cfg.cellsX || ed.cellsX || ed.cellCountX || (ed.cells && ed.cells.x) || null;
1370
- cfg.cellsY = cfg.cellsY || ed.cellsY || ed.cellCountY || (ed.cells && ed.cells.y) || null;
1371
-
1372
- // if editor exposes getCells() prefer that
1373
- try {
1374
- if ((!cfg.cellsX || !cfg.cellsY) && typeof ed.getCells === 'function') {
1375
- const c = ed.getCells();
1376
- if (c && c.x && c.y) {
1377
- cfg.cellsX = cfg.cellsX || c.x;
1378
- cfg.cellsY = cfg.cellsY || c.y;
1379
- }
1380
- }
1381
- } catch (e) {
1382
- /* ignore */
1383
- }
1384
-
1385
- return cfg;
1386
- }
1387
-
1388
- // core adaptive loader: scales image to editor cells (or computes fallback)
1389
- async _loadPngToEditorAdaptive(blobOrFile) {
1390
- const imgBitmap = await createImageBitmap(blobOrFile);
1391
- const srcW = imgBitmap.width;
1392
- const srcH = imgBitmap.height;
1393
-
1394
- // read editor config and loader explicit overrides
1395
- const editorCfg = this._readEditorConfig();
1396
- const overrideX = this.getAttribute('target-cells-x');
1397
- const overrideY = this.getAttribute('target-cells-y');
1398
-
1399
- let targetCellsX = overrideX ? parseInt(overrideX, 10) : editorCfg.cellsX || null;
1400
- let targetCellsY = overrideY ? parseInt(overrideY, 10) : editorCfg.cellsY || null;
1401
-
1402
- // if cells unknown but pixelSize known, compute approximate cells from image dimensions
1403
- if ((!targetCellsX || !targetCellsY) && editorCfg.pixelSize) {
1404
- const px = parseInt(editorCfg.pixelSize, 10);
1405
- if (px > 0) {
1406
- if (!targetCellsX) targetCellsX = Math.max(1, Math.round(srcW / px));
1407
- if (!targetCellsY) targetCellsY = Math.max(1, Math.round(srcH / px));
1408
- }
1409
- }
1410
-
1411
- // if still missing, fallback to native image pixels
1412
- if (!targetCellsX) targetCellsX = srcW;
1413
- if (!targetCellsY) targetCellsY = srcH;
1414
-
1415
- // Decide fit mode
1416
- const fitMode = this._options.fitMode || this.getAttribute('fit-mode') || 'contain';
1417
-
1418
- // Create a small canvas sized to the target cells (we will render the image into this canvas
1419
- // with smoothing disabled to preserve blocky/pixel look). Then read each pixel as a cell.
1420
- const small = document.createElement('canvas');
1421
- small.width = targetCellsX;
1422
- small.height = targetCellsY;
1423
- const sctx = small.getContext('2d');
1424
-
1425
- // nearest-neighbour / crisp scaling
1426
- sctx.imageSmoothingEnabled = false;
1427
- sctx.clearRect(0, 0, small.width, small.height);
1428
-
1429
- if (fitMode === 'stretch') {
1430
- // non-uniform scale to fill exactly
1431
- sctx.drawImage(imgBitmap, 0, 0, srcW, srcH, 0, 0, small.width, small.height);
1432
- } else {
1433
- // compute uniform scale to contain or cover
1434
- let scaleX = small.width / srcW;
1435
- let scaleY = small.height / srcH;
1436
- let scale = fitMode === 'cover' ? Math.max(scaleX, scaleY) : Math.min(scaleX, scaleY);
1437
- // compute destination size in small-canvas pixels
1438
- const destW = Math.max(1, Math.round(srcW * scale));
1439
- const destH = Math.max(1, Math.round(srcH * scale));
1440
- const dx = Math.floor((small.width - destW) / 2);
1441
- const dy = Math.floor((small.height - destH) / 2);
1442
- sctx.drawImage(imgBitmap, 0, 0, srcW, srcH, dx, dy, destW, destH);
1443
- }
1444
-
1445
- // read pixel data from the small canvas
1446
- const imageData = sctx.getImageData(0, 0, small.width, small.height).data;
1447
-
1448
- // build matrix[y][x] = [r,g,b,a]
1449
- const matrix = new Array(small.height);
1450
- let p = 0;
1451
- for (let y = 0; y < small.height; y++) {
1452
- const row = new Array(small.width);
1453
- for (let x = 0; x < small.width; x++) {
1454
- const r = imageData[p++];
1455
- const g = imageData[p++];
1456
- const b = imageData[p++];
1457
- const a = imageData[p++];
1458
- row[x] = [r, g, b, a];
1459
- }
1460
- matrix[y] = row;
1461
- }
1462
-
1463
- // attempt to optionally align editor settings (best-effort)
1464
- try {
1465
- // if editor has setCells(x,y) or setCellCount, call it
1466
- if (typeof this._editor.setCells === 'function') {
1467
- this._editor.setCells(small.width, small.height);
1468
- } else if (typeof this._editor.setCellCount === 'function') {
1469
- this._editor.setCellCount(small.width, small.height);
1470
- } else {
1471
- // try common attribute setter
1472
- if (this._editor.setAttribute) {
1473
- this._editor.setAttribute('width', String(small.width));
1474
- this._editor.setAttribute('height', String(small.height));
1475
- }
1476
- }
1477
-
1478
- // if editor has setPixelSize and editorCfg.pixelSize exists, keep it
1479
- if (editorCfg.pixelSize && typeof this._editor.setPixelSize === 'function') {
1480
- this._editor.setPixelSize(parseInt(editorCfg.pixelSize, 10));
1481
- }
1482
- } catch (e) {
1483
- // non-critical; continue
1484
- console.warn('Failed to align editor config:', e);
1485
- }
1486
-
1487
- // finally, hand matrix to editor
1488
- if (!this._editor || typeof this._editor.loadMatrix !== 'function') {
1489
- throw new Error('Editor disconnected or does not expose loadMatrix');
1490
- }
1491
-
1492
- this._editor.loadMatrix(matrix);
1493
- }
1494
-
1495
- // Public helpers
1496
- async loadPngBlob(blob) {
1497
- return this._handleFile(blob);
1498
- }
1499
- async loadPngUrl(url) {
1500
- const resp = await fetch(url);
1501
- const blob = await resp.blob();
1502
- return this._handleFile(blob);
1503
- }
1504
- }
1505
-
1506
- customElements.define('object-layer-png-loader', ObjectLayerPngLoader);
1507
-
1508
- /* Example wiring (NOT code repeated in canvas):
1509
-
1510
- // HTML
1511
- <object-layer-engine id="editor"></object-layer-engine>
1512
- <object-layer-png-loader id="loader" editor-selector="#editor"></object-layer-png-loader>
1513
-
1514
- // JS (programmatic)
1515
- const editor = document.getElementById('editor');
1516
- const loader = document.getElementById('loader');
1517
- // Alternatively: loader.setEditor(editor);
1518
- loader.addEventListener('pngloaded', (e) => console.log('Loaded', e.detail.filename));
1519
-
1520
- */