@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.
- package/.vscode/settings.json +7 -8
- package/README.md +2 -2
- package/bin/build.js +21 -5
- package/bin/deploy.js +10 -0
- package/bin/file.js +2 -1
- package/bin/util.js +0 -17
- package/cli.md +2 -2
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/dd-test-development/deployment.yaml +2 -2
- package/package.json +2 -4
- package/scripts/rocky-setup.sh +12 -39
- package/src/api/document/document.service.js +1 -1
- package/src/cli/cluster.js +5 -9
- package/src/cli/repository.js +9 -9
- package/src/cli/run.js +108 -106
- package/src/client/components/core/Content.js +54 -4
- package/src/client/components/core/FullScreen.js +202 -9
- package/src/client/components/core/Panel.js +91 -22
- package/src/client/components/core/PanelForm.js +5 -2
- package/src/client/components/core/Translate.js +8 -0
- package/src/client/components/core/VanillaJs.js +80 -29
- package/src/client/services/default/default.management.js +324 -136
- package/src/index.js +58 -20
- package/src/client/components/core/ObjectLayerEngine.js +0 -1520
- package/src/client/components/core/ObjectLayerEngineModal.js +0 -1245
- package/src/client/components/core/ObjectLayerEngineViewer.js +0 -880
- package/src/server/object-layer.js +0 -335
|
@@ -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
|
-
*/
|