underpost 2.8.866 → 2.8.871

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.
Files changed (42) hide show
  1. package/README.md +52 -36
  2. package/bin/build.js +1 -0
  3. package/bin/deploy.js +30 -1
  4. package/bin/file.js +3 -0
  5. package/bin/util.js +1 -56
  6. package/cli.md +88 -86
  7. package/conf.js +1 -1
  8. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  9. package/manifests/deployment/mongo-express/deployment.yaml +12 -12
  10. package/manifests/maas/nvim.sh +91 -0
  11. package/package.json +1 -10
  12. package/src/api/file/file.service.js +28 -8
  13. package/src/api/user/user.router.js +24 -0
  14. package/src/api/user/user.service.js +3 -4
  15. package/src/cli/cluster.js +2 -13
  16. package/src/cli/cron.js +0 -1
  17. package/src/cli/db.js +0 -19
  18. package/src/cli/deploy.js +17 -26
  19. package/src/cli/fs.js +1 -0
  20. package/src/cli/index.js +1 -0
  21. package/src/cli/run.js +9 -2
  22. package/src/client/components/core/CalendarCore.js +1 -1
  23. package/src/client/components/core/CssCore.js +12 -0
  24. package/src/client/components/core/Docs.js +2 -2
  25. package/src/client/components/core/FullScreen.js +19 -28
  26. package/src/client/components/core/Input.js +1 -0
  27. package/src/client/components/core/Modal.js +66 -63
  28. package/src/client/components/core/ObjectLayerEngine.js +229 -4
  29. package/src/client/components/core/ObjectLayerEngineModal.js +441 -0
  30. package/src/client/components/core/Panel.js +4 -1
  31. package/src/client/components/core/PanelForm.js +1 -1
  32. package/src/client/components/core/Router.js +29 -25
  33. package/src/client/components/core/ToggleSwitch.js +15 -1
  34. package/src/client/components/core/VanillaJs.js +12 -13
  35. package/src/client/public/default/assets/mailer/api-user-default-avatar.png +0 -0
  36. package/src/index.js +1 -1
  37. package/src/server/client-build.js +3 -11
  38. package/src/server/client-icons.js +6 -78
  39. package/src/server/conf.js +20 -64
  40. package/src/server/process.js +2 -1
  41. package/test/api.test.js +3 -2
  42. package/bin/cyberia0.js +0 -78
@@ -51,6 +51,7 @@ const Modal = {
51
51
  RouterInstance: {},
52
52
  disableTools: [],
53
53
  observer: false,
54
+ disableBoxShadow: false,
54
55
  },
55
56
  ) {
56
57
  if (options.heightBottomBar === undefined) options.heightBottomBar = 50;
@@ -89,6 +90,11 @@ const Modal = {
89
90
  onHome: {},
90
91
  homeModals: options.homeModals ? options.homeModals : [],
91
92
  query: options.query ? `${window.location.search}` : undefined,
93
+ getTop: () => window.innerHeight - (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar),
94
+ getHeight: () =>
95
+ window.innerHeight -
96
+ (options.heightTopBar ? options.heightTopBar : heightDefaultTopBar) -
97
+ (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar),
92
98
  };
93
99
 
94
100
  if (idModal !== 'main-body' && options.mode !== 'view') {
@@ -117,12 +123,7 @@ const Modal = {
117
123
 
118
124
  Responsive.Event[`view-${idModal}`] = () => {
119
125
  if (!this.Data[idModal]) return delete Responsive.Event[`view-${idModal}`];
120
- if (this.Data[idModal].slideMenu)
121
- s(`.${idModal}`).style.height = `${
122
- window.innerHeight -
123
- (options.heightTopBar ? options.heightTopBar : heightDefaultTopBar) -
124
- (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar)
125
- }px`;
126
+ if (this.Data[idModal].slideMenu) s(`.${idModal}`).style.height = `${this.Data[idModal].getHeight()}px`;
126
127
  };
127
128
  Responsive.Event[`view-${idModal}`]();
128
129
 
@@ -207,11 +208,7 @@ const Modal = {
207
208
  const { barConfig } = options;
208
209
  options.style = {
209
210
  position: 'absolute',
210
- height: `${
211
- window.innerHeight -
212
- (options.heightTopBar ? options.heightTopBar : heightDefaultTopBar) -
213
- (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar)
214
- }px`,
211
+ height: `${Modal.Data[idModal].getHeight()}px`,
215
212
  width: `${slideMenuWidth}px`,
216
213
  // 'overflow-x': 'hidden',
217
214
  // overflow: 'visible', // required for tooltip
@@ -239,11 +236,7 @@ const Modal = {
239
236
  if (this.Data[_idModal].slideMenu && this.Data[_idModal].slideMenu.id === idModal)
240
237
  this.Data[_idModal].slideMenu.callBack();
241
238
  }
242
- s(`.${idModal}`).style.height = `${
243
- window.innerHeight -
244
- (options.heightTopBar ? options.heightTopBar : heightDefaultTopBar) -
245
- (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar)
246
- }px`;
239
+ s(`.${idModal}`).style.height = `${Modal.Data[idModal].getHeight()}px`;
247
240
  if (s(`.main-body-top`)) {
248
241
  if (Modal.mobileModal()) {
249
242
  if (s(`.btn-menu-${idModal}`).classList.contains('hide') && collapseSlideMenuWidth !== slideMenuWidth)
@@ -753,9 +746,12 @@ const Modal = {
753
746
  s(`.main-btn-${results[currentKeyBoardSearchBoxIndex].routerId}`).click();
754
747
  Modal.removeModal(searchBoxHistoryId);
755
748
  };
756
-
749
+ let boxHistoryDelayRender = 0;
757
750
  const searchBoxHistoryOpen = async () => {
758
- if (!s(`.${id}`)) {
751
+ if (boxHistoryDelayRender) return;
752
+ boxHistoryDelayRender = 1000;
753
+ setTimeout(() => (boxHistoryDelayRender = 0));
754
+ if (!s(`.${searchBoxHistoryId}`)) {
759
755
  const { barConfig } = await Themes[Css.currentTheme]();
760
756
  barConfig.buttons.maximize.disabled = true;
761
757
  barConfig.buttons.minimize.disabled = true;
@@ -763,7 +759,7 @@ const Modal = {
763
759
  barConfig.buttons.menu.disabled = true;
764
760
  barConfig.buttons.close.disabled = false;
765
761
  await Modal.Render({
766
- id,
762
+ id: searchBoxHistoryId,
767
763
  barConfig,
768
764
  title: html`<div class="search-box-recent-title">
769
765
  ${renderViewTitle({
@@ -964,6 +960,7 @@ const Modal = {
964
960
  heightBottomBar: originHeightBottomBar,
965
961
  barMode: options.barMode,
966
962
  observer: true,
963
+ disableBoxShadow: true,
967
964
  });
968
965
  const maxWidthInputSearchBox = 450;
969
966
  const paddingInputSearchBox = 5;
@@ -985,12 +982,7 @@ const Modal = {
985
982
  s(`.top-bar-search-box`).style.top = `${
986
983
  (originHeightTopBar - s(`.top-bar-search-box`).clientHeight) / 2
987
984
  }px`;
988
- if (this.Data[id].slideMenu)
989
- s(`.${id}`).style.height = `${
990
- window.innerHeight -
991
- (options.heightTopBar ? options.heightTopBar : heightDefaultTopBar) -
992
- (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar)
993
- }px`;
985
+ if (this.Data[id].slideMenu) s(`.${id}`).style.height = `${Modal.Data[id].getHeight()}px`;
994
986
  };
995
987
  Responsive.Event[`view-${id}`]();
996
988
  Keyboard.instanceMultiPressKey({
@@ -1119,9 +1111,7 @@ const Modal = {
1119
1111
  if (!this.Data[id] || !s(`.${id}`)) return delete Responsive.Event[`view-${id}`];
1120
1112
  // <div class="in fll right-offset-menu-bottom-bar" style="height: 100%"></div>
1121
1113
  // s(`.right-offset-menu-bottom-bar`).style.width = `${window.innerWidth - slideMenuWidth}px`;
1122
- s(`.${id}`).style.top = `${
1123
- window.innerHeight - (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar)
1124
- }px`;
1114
+ s(`.${id}`).style.top = `${Modal.Data[id].getTop()}px`;
1125
1115
  };
1126
1116
  Responsive.Event[`view-${id}`]();
1127
1117
  }
@@ -1293,11 +1283,7 @@ const Modal = {
1293
1283
  s(`.main-body-btn-ui-close`).classList.contains('hide') &&
1294
1284
  s(`.btn-restore-${id}`).style.display !== 'none'
1295
1285
  ? `${window.innerHeight}px`
1296
- : `${
1297
- window.innerHeight -
1298
- (options.heightTopBar ? options.heightTopBar : heightDefaultTopBar) -
1299
- (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar)
1300
- }px`;
1286
+ : `${Modal.Data[id].getHeight()}px`;
1301
1287
 
1302
1288
  if (
1303
1289
  s(`.main-body-btn-ui-close`).classList.contains('hide') &&
@@ -1405,7 +1391,11 @@ const Modal = {
1405
1391
  }
1406
1392
  </style>
1407
1393
  ${renderStyleTag(`style-${idModal}`, `.${idModal}`, options)}
1408
- <div class="fix ${options && options.class ? options.class : ''} modal box-shadow ${idModal}">
1394
+ <div
1395
+ class="fix ${options && options.class ? options.class : ''} modal ${options.disableBoxShadow
1396
+ ? ''
1397
+ : 'box-shadow'} ${idModal}"
1398
+ >
1409
1399
  <div class="abs modal-handle-${idModal}"></div>
1410
1400
  <div class="in modal-html-${idModal}">
1411
1401
  <div class="stq bar-default-modal bar-default-modal-${idModal}">
@@ -1545,18 +1535,6 @@ const Modal = {
1545
1535
  s(`.btn-icon-menu-back`).classList.add('hide');
1546
1536
  if (s(`.menu-btn-container-main`)) s(`.menu-btn-container-main`).classList.remove('hide');
1547
1537
  };
1548
- this.onHomeRouterEvent = async () => {
1549
- for (const keyModal of Object.keys(this.Data)) {
1550
- if (
1551
- ![idModal, 'main-body-top', 'main-body'].concat(this.Data[idModal]?.homeModals || []).includes(keyModal)
1552
- )
1553
- if (s(`.btn-close-${keyModal}`)) s(`.btn-close-${keyModal}`).click();
1554
- backMenuButtonEvent();
1555
- }
1556
- if (s(`.btn-close-modal-menu`)) s(`.btn-close-modal-menu`).click();
1557
- setPath(getProxyPath());
1558
- setDocTitle();
1559
- };
1560
1538
  s(`.main-btn-home`).onclick = async () => {
1561
1539
  // await this.onHomeRouterEvent();
1562
1540
  s(`.action-btn-home`).click();
@@ -1777,12 +1755,8 @@ const Modal = {
1777
1755
  if (!s(`.${idModal}`)) return;
1778
1756
  this.removeModal(idModal);
1779
1757
  // Handle modal route change
1780
- if (options.route) {
1781
- closeModalRouteChangeEvent({
1782
- route: options.route,
1783
- RouterInstance: options.RouterInstance,
1784
- homeCid: Modal.homeCid,
1785
- });
1758
+ if (options.route || options.query) {
1759
+ closeModalRouteChangeEvent({ closedId: idModal, homeCid: Modal.homeCid });
1786
1760
  }
1787
1761
  }, 300);
1788
1762
  };
@@ -1918,11 +1892,7 @@ const Modal = {
1918
1892
  if (s(`.btn-restore-${idModal}`) && s(`.btn-restore-${idModal}`).style.display !== 'none') {
1919
1893
  s(`.${idModal}`).style.height = s(`.main-body-btn-ui-close`).classList.contains('hide')
1920
1894
  ? `${window.innerHeight}px`
1921
- : `${
1922
- window.innerHeight -
1923
- (options.heightTopBar ? options.heightTopBar : heightDefaultTopBar) -
1924
- (options.heightBottomBar ? options.heightBottomBar : heightDefaultBottomBar)
1925
- }px`;
1895
+ : `${Modal.Data[idModal].getHeight()}px`;
1926
1896
  }
1927
1897
  s(`.${idModal}`).style.top = s(`.main-body-btn-ui-close`).classList.contains('hide')
1928
1898
  ? `0px`
@@ -1981,7 +1951,45 @@ const Modal = {
1981
1951
  ...this.Data[idModal],
1982
1952
  };
1983
1953
  },
1984
- onHomeRouterEvent: () => {},
1954
+ onHomeRouterEvent: async () => {
1955
+ // 1. Get list of modals to close.
1956
+ const modalsToClose = Object.keys(Modal.Data).filter((idModal) => {
1957
+ const modal = Modal.Data[idModal];
1958
+ if (!modal) return false;
1959
+ // Don't close the core UI elements
1960
+ const coreUI = ['modal-menu', 'main-body', 'main-body-top', 'bottom-bar', 'board-notification'];
1961
+ if (coreUI.find((id) => idModal.startsWith(id))) {
1962
+ return false;
1963
+ }
1964
+ // Don't close modals that are part of the "home" screen itself
1965
+ const homeModals = Modal.Data['modal-menu']?.homeModals || [];
1966
+ if (homeModals.includes(idModal)) {
1967
+ return false;
1968
+ }
1969
+ return true;
1970
+ });
1971
+
1972
+ // 2. Navigate to home first, creating a new history entry.
1973
+ setPath(getProxyPath());
1974
+ setDocTitle();
1975
+
1976
+ // 3. Close the modals without them affecting the URL.
1977
+ for (const id of modalsToClose) {
1978
+ Modal.removeModal(id);
1979
+ }
1980
+
1981
+ // 4. Finally, handle UI cleanup for the slide-menu.
1982
+ if (s(`.menu-btn-container-children`)) htmls(`.menu-btn-container-children`, '');
1983
+ if (s(`.nav-title-display-modal-menu`)) htmls(`.nav-title-display-modal-menu`, '');
1984
+ if (s(`.nav-path-display-modal-menu`)) htmls(`.nav-path-display-modal-menu`, '');
1985
+ if (s(`.btn-icon-menu-back`)) s(`.btn-icon-menu-back`).classList.add('hide');
1986
+ if (s(`.menu-btn-container-main`)) s(`.menu-btn-container-main`).classList.remove('hide');
1987
+
1988
+ // And close the slide menu if it's open
1989
+ if (s(`.btn-close-modal-menu`) && !s(`.btn-close-modal-menu`).classList.contains('hide')) {
1990
+ s(`.btn-close-modal-menu`).click();
1991
+ }
1992
+ },
1985
1993
  currentTopModalId: '',
1986
1994
  zIndexSync: function ({ idModal }) {
1987
1995
  setTimeout(() => {
@@ -2010,11 +2018,6 @@ const Modal = {
2010
2018
  setTopModalCallback: function (idModal) {
2011
2019
  s(`.${idModal}`).style.zIndex = '4';
2012
2020
  this.currentTopModalId = `${idModal}`;
2013
- if (
2014
- this.Data[idModal].query &&
2015
- `${location.pathname}${window.location.search}` !== `${location.pathname}${this.Data[idModal].query}`
2016
- )
2017
- setPath(`${location.pathname}${this.Data[idModal].query}`);
2018
2021
  },
2019
2022
  mobileModal: () => window.innerWidth < 600 || window.innerHeight < 600,
2020
2023
  writeHTML: ({ idModal, html }) => htmls(`.html-${idModal}`, html),
@@ -33,6 +33,22 @@ const templateHTML = html`
33
33
  top: 0;
34
34
  pointer-events: none;
35
35
  }
36
+ .toolbar {
37
+ display: flex;
38
+ gap: 8px;
39
+ flex-wrap: wrap;
40
+ align-items: center;
41
+ }
42
+ .toolbar label {
43
+ display: inline-flex;
44
+ gap: 6px;
45
+ align-items: center;
46
+ }
47
+ .group {
48
+ display: inline-flex;
49
+ gap: 6px;
50
+ align-items: center;
51
+ }
36
52
  </style>
37
53
 
38
54
  <div class="wrap">
@@ -48,8 +64,34 @@ const templateHTML = html`
48
64
  <label>brush <input type="number" part="brush-size" min="1" value="1" /></label>
49
65
  <label>pixel-size <input type="number" part="pixel-size" min="1" value="16" /></label>
50
66
 
67
+ <!-- New: cell dimensions (width x height) -->
68
+ <label
69
+ >cells <input type="number" part="cell-width" min="1" value="16" style="width:6ch" /> x
70
+ <input type="number" part="cell-height" min="1" value="16" style="width:6ch"
71
+ /></label>
72
+
51
73
  <label class="switch"> <input type="checkbox" part="toggle-grid" /> grid </label>
52
74
 
75
+ <!-- New: transform tools -->
76
+ <div class="group">
77
+ <button part="flip-h" title="Flip horizontally">Flip H</button>
78
+ <button part="flip-v" title="Flip vertically">Flip V</button>
79
+ <button part="rot-ccw" title="Rotate -90°">⟲</button>
80
+ <button part="rot-cw" title="Rotate +90°">⟳</button>
81
+ </div>
82
+
83
+ <label
84
+ >opacity <input type="range" part="opacity" min="0" max="255" value="255" style="width:10rem" /><input
85
+ type="number"
86
+ part="opacity-num"
87
+ min="0"
88
+ max="255"
89
+ value="255"
90
+ style="width:5ch;margin-left:4px"
91
+ /></label>
92
+
93
+ <button part="clear" title="Clear (make fully transparent)">Clear</button>
94
+
53
95
  <button part="export">Export PNG</button>
54
96
  <button part="export-json">Export JSON</button>
55
97
  <button part="import-json">Import JSON</button>
@@ -79,11 +121,24 @@ class ObjectLayerEngineElement extends HTMLElement {
79
121
  this._importJsonBtn = this.shadowRoot.querySelector('button[part="import-json"]');
80
122
  this._toggleGrid = this.shadowRoot.querySelector('input[part="toggle-grid"]');
81
123
 
124
+ // new controls
125
+ this._widthInput = this.shadowRoot.querySelector('input[part="cell-width"]');
126
+ this._heightInput = this.shadowRoot.querySelector('input[part="cell-height"]');
127
+ this._flipHBtn = this.shadowRoot.querySelector('button[part="flip-h"]');
128
+ this._flipVBtn = this.shadowRoot.querySelector('button[part="flip-v"]');
129
+ this._rotCCWBtn = this.shadowRoot.querySelector('button[part="rot-ccw"]');
130
+ this._rotCWBtn = this.shadowRoot.querySelector('button[part="rot-cw"]');
131
+ this._clearBtn = this.shadowRoot.querySelector('button[part="clear"]');
132
+ this._opacityRange = this.shadowRoot.querySelector('input[part="opacity"]');
133
+ this._opacityNumber = this.shadowRoot.querySelector('input[part="opacity-num"]');
134
+
82
135
  // internal state
83
136
  this._width = 16;
84
137
  this._height = 16;
85
138
  this._pixelSize = 16;
86
139
  this._brushSize = 1;
140
+ // brush color stored as [r,g,b,a]
141
+ this._brushColor = [0, 0, 0, 255];
87
142
  this._matrix = this._createEmptyMatrix(this._width, this._height);
88
143
 
89
144
  this._pixelCtx = null;
@@ -91,13 +146,18 @@ class ObjectLayerEngineElement extends HTMLElement {
91
146
 
92
147
  this._isPointerDown = false;
93
148
  this._tool = 'pencil';
94
- this._brushColor = [0, 0, 0, 255];
95
149
  this._showGrid = false;
96
150
 
97
151
  // binds
98
152
  this._onPointerDown = this._onPointerDown.bind(this);
99
153
  this._onPointerMove = this._onPointerMove.bind(this);
100
154
  this._onPointerUp = this._onPointerUp.bind(this);
155
+
156
+ // transform methods bound (useful if passing as callbacks)
157
+ this.flipHorizontal = this.flipHorizontal.bind(this);
158
+ this.flipVertical = this.flipVertical.bind(this);
159
+ this.rotateCW = this.rotateCW.bind(this);
160
+ this.rotateCCW = this.rotateCCW.bind(this);
101
161
  }
102
162
 
103
163
  static get observedAttributes() {
@@ -111,16 +171,29 @@ class ObjectLayerEngineElement extends HTMLElement {
111
171
  }
112
172
 
113
173
  connectedCallback() {
174
+ // respect attributes if present
114
175
  if (this.hasAttribute('width')) this._width = Math.max(1, parseInt(this.getAttribute('width'), 10));
115
176
  if (this.hasAttribute('height')) this._height = Math.max(1, parseInt(this.getAttribute('height'), 10));
116
177
  if (this.hasAttribute('pixel-size')) this._pixelSize = Math.max(1, parseInt(this.getAttribute('pixel-size'), 10));
117
178
 
118
179
  this._setupContextsAndSize();
119
180
 
181
+ // set initial UI control values (keeps in sync with attributes)
182
+ if (this._widthInput) this._widthInput.value = String(this._width);
183
+ if (this._heightInput) this._heightInput.value = String(this._height);
184
+ if (this._pixelSizeInput) this._pixelSizeInput.value = String(this._pixelSize);
185
+ if (this._brushSizeInput) this._brushSizeInput.value = String(this._brushSize);
186
+
187
+ // initialize color & opacity UI
188
+ if (this._colorInput) this._colorInput.value = this._rgbaToHex(this._brushColor);
189
+ if (this._opacityRange) this._opacityRange.value = String(this._brushColor[3]);
190
+ if (this._opacityNumber) this._opacityNumber.value = String(this._brushColor[3]);
191
+
120
192
  // UI events
121
193
  this._colorInput.addEventListener('input', (e) => {
122
- const rgba = this._hexToRgba(e.target.value);
123
- this.setBrushColor([rgba[0], rgba[1], rgba[2], 255]);
194
+ const rgb = this._hexToRgba(e.target.value);
195
+ // keep current alpha
196
+ this.setBrushColor([rgb[0], rgb[1], rgb[2], this._brushColor[3]]);
124
197
  });
125
198
  this._toolSelect.addEventListener('change', (e) => this.setTool(e.target.value));
126
199
  this._brushSizeInput.addEventListener('change', (e) => this.setBrushSize(parseInt(e.target.value, 10) || 1));
@@ -132,6 +205,42 @@ class ObjectLayerEngineElement extends HTMLElement {
132
205
  this._renderGrid();
133
206
  });
134
207
 
208
+ // opacity controls - keep range and number in sync
209
+ if (this._opacityRange) {
210
+ this._opacityRange.addEventListener('input', (e) => {
211
+ const v = Math.max(0, Math.min(255, parseInt(e.target.value, 10) || 0));
212
+ this.setBrushAlpha(v);
213
+ });
214
+ }
215
+ if (this._opacityNumber) {
216
+ this._opacityNumber.addEventListener('change', (e) => {
217
+ const v = Math.max(0, Math.min(255, parseInt(e.target.value, 10) || 0));
218
+ this.setBrushAlpha(v);
219
+ });
220
+ }
221
+
222
+ // width/height change -> resize (preserve existing content)
223
+ if (this._widthInput)
224
+ this._widthInput.addEventListener('change', (e) => {
225
+ const val = Math.max(1, parseInt(e.target.value, 10) || 1);
226
+ // keep value synced (will update input again in resize)
227
+ this.resize(val, this._height, { preserve: true });
228
+ });
229
+ if (this._heightInput)
230
+ this._heightInput.addEventListener('change', (e) => {
231
+ const val = Math.max(1, parseInt(e.target.value, 10) || 1);
232
+ this.resize(this._width, val, { preserve: true });
233
+ });
234
+
235
+ // transform buttons
236
+ if (this._flipHBtn) this._flipHBtn.addEventListener('click', this.flipHorizontal);
237
+ if (this._flipVBtn) this._flipVBtn.addEventListener('click', this.flipVertical);
238
+ if (this._rotCWBtn) this._rotCWBtn.addEventListener('click', this.rotateCW);
239
+ if (this._rotCCWBtn) this._rotCCWBtn.addEventListener('click', this.rotateCCW);
240
+
241
+ // clear button (makes canvas fully transparent)
242
+ if (this._clearBtn) this._clearBtn.addEventListener('click', () => this.clear([0, 0, 0, 0]));
243
+
135
244
  // Export/Import
136
245
  this._exportBtn.addEventListener('click', () => this.exportPNG());
137
246
  this._exportJsonBtn.addEventListener('click', () => {
@@ -169,6 +278,14 @@ class ObjectLayerEngineElement extends HTMLElement {
169
278
  this._pixelCanvas.removeEventListener('pointerdown', this._onPointerDown);
170
279
  window.removeEventListener('pointermove', this._onPointerMove);
171
280
  window.removeEventListener('pointerup', this._onPointerUp);
281
+
282
+ if (this._flipHBtn) this._flipHBtn.removeEventListener('click', this.flipHorizontal);
283
+ if (this._flipVBtn) this._flipVBtn.removeEventListener('click', this.flipVertical);
284
+ if (this._rotCWBtn) this._rotCWBtn.removeEventListener('click', this.rotateCW);
285
+ if (this._rotCCWBtn) this._rotCCWBtn.removeEventListener('click', this.rotateCCW);
286
+ if (this._clearBtn) this._clearBtn.removeEventListener('click', () => this.clear([0, 0, 0, 0]));
287
+ if (this._opacityRange) this._opacityRange.removeEventListener('input', () => {});
288
+ if (this._opacityNumber) this._opacityNumber.removeEventListener('change', () => {});
172
289
  }
173
290
 
174
291
  // ---------------- Matrix helpers ----------------
@@ -227,6 +344,13 @@ class ObjectLayerEngineElement extends HTMLElement {
227
344
  this._width = nw;
228
345
  this._height = nh;
229
346
  this._matrix = newMat;
347
+
348
+ // keep inputs and attributes in sync
349
+ if (this._widthInput) this._widthInput.value = String(this._width);
350
+ if (this._heightInput) this._heightInput.value = String(this._height);
351
+ this.setAttribute('width', String(this._width));
352
+ this.setAttribute('height', String(this._height));
353
+
230
354
  this._setupContextsAndSize();
231
355
  this.render();
232
356
  this.dispatchEvent(new CustomEvent('resize', { detail: { width: nw, height: nh } }));
@@ -365,10 +489,33 @@ class ObjectLayerEngineElement extends HTMLElement {
365
489
  this._tool = name;
366
490
  if (this._toolSelect) this._toolSelect.value = name;
367
491
  }
492
+
493
+ // set full RGBA brush color (alpha optional)
368
494
  setBrushColor(rgba) {
369
- this._brushColor = rgba.map((n) => this._clampInt(n));
495
+ if (!Array.isArray(rgba) || rgba.length < 3) return;
496
+ const r = this._clampInt(rgba[0]);
497
+ const g = this._clampInt(rgba[1]);
498
+ const b = this._clampInt(rgba[2]);
499
+ const a = typeof rgba[3] === 'number' ? this._clampInt(rgba[3]) : this._brushColor[3];
500
+ this._brushColor = [r, g, b, a];
370
501
  if (this._colorInput) this._colorInput.value = this._rgbaToHex(this._brushColor);
502
+ if (this._opacityRange) this._opacityRange.value = String(this._brushColor[3]);
503
+ if (this._opacityNumber) this._opacityNumber.value = String(this._brushColor[3]);
371
504
  }
505
+
506
+ // set brush alpha (0-255)
507
+ setBrushAlpha(a) {
508
+ const v = Math.max(0, Math.min(255, Math.floor(Number(a) || 0)));
509
+ this._brushColor[3] = v;
510
+ if (this._opacityRange) this._opacityRange.value = String(v);
511
+ if (this._opacityNumber) this._opacityNumber.value = String(v);
512
+ // keep color input (hex) representing rgb only
513
+ if (this._colorInput) this._colorInput.value = this._rgbaToHex(this._brushColor);
514
+ }
515
+ getBrushAlpha() {
516
+ return this._brushColor[3];
517
+ }
518
+
372
519
  setBrushSize(n) {
373
520
  this._brushSize = Math.max(1, Math.floor(n));
374
521
  if (this._brushSizeInput) this._brushSizeInput.value = this._brushSize;
@@ -584,6 +731,84 @@ class ObjectLayerEngineElement extends HTMLElement {
584
731
  .slice(1)}`;
585
732
  }
586
733
 
734
+ // ---------------- Transform helpers (flip/rotate) ----------------
735
+ flipHorizontal() {
736
+ // reverse each row (mirror horizontally)
737
+ for (let y = 0; y < this._height; y++) {
738
+ this._matrix[y].reverse();
739
+ }
740
+ this.render();
741
+ this.dispatchEvent(new CustomEvent('transform', { detail: { type: 'flip-horizontal' } }));
742
+ }
743
+
744
+ flipVertical() {
745
+ // reverse the order of rows (mirror vertically)
746
+ this._matrix.reverse();
747
+ this.render();
748
+ this.dispatchEvent(new CustomEvent('transform', { detail: { type: 'flip-vertical' } }));
749
+ }
750
+
751
+ rotateCW() {
752
+ // rotate +90 degrees (clockwise)
753
+ const oldH = this._height;
754
+ const oldW = this._width;
755
+ const newW = oldH;
756
+ const newH = oldW;
757
+ const newMat = this._createEmptyMatrix(newW, newH);
758
+ for (let y = 0; y < oldH; y++) {
759
+ for (let x = 0; x < oldW; x++) {
760
+ const px = this._matrix[y][x] ? this._matrix[y][x].slice() : [0, 0, 0, 0];
761
+ const newX = oldH - 1 - y; // column in new matrix
762
+ const newY = x; // row in new matrix
763
+ newMat[newY][newX] = px;
764
+ }
765
+ }
766
+ this._width = newW;
767
+ this._height = newH;
768
+ this._matrix = newMat;
769
+ // keep inputs/attributes in sync
770
+ if (this._widthInput) this._widthInput.value = String(this._width);
771
+ if (this._heightInput) this._heightInput.value = String(this._height);
772
+ this.setAttribute('width', String(this._width));
773
+ this.setAttribute('height', String(this._height));
774
+
775
+ this._setupContextsAndSize();
776
+ this.render();
777
+ this.dispatchEvent(
778
+ new CustomEvent('transform', { detail: { type: 'rotate-cw', width: this._width, height: this._height } }),
779
+ );
780
+ }
781
+
782
+ rotateCCW() {
783
+ // rotate -90 degrees (counter-clockwise)
784
+ const oldH = this._height;
785
+ const oldW = this._width;
786
+ const newW = oldH;
787
+ const newH = oldW;
788
+ const newMat = this._createEmptyMatrix(newW, newH);
789
+ for (let y = 0; y < oldH; y++) {
790
+ for (let x = 0; x < oldW; x++) {
791
+ const px = this._matrix[y][x] ? this._matrix[y][x].slice() : [0, 0, 0, 0];
792
+ const newX = y; // column in new matrix
793
+ const newY = oldW - 1 - x; // row in new matrix
794
+ newMat[newY][newX] = px;
795
+ }
796
+ }
797
+ this._width = newW;
798
+ this._height = newH;
799
+ this._matrix = newMat;
800
+ if (this._widthInput) this._widthInput.value = String(this._width);
801
+ if (this._heightInput) this._heightInput.value = String(this._height);
802
+ this.setAttribute('width', String(this._width));
803
+ this.setAttribute('height', String(this._height));
804
+
805
+ this._setupContextsAndSize();
806
+ this.render();
807
+ this.dispatchEvent(
808
+ new CustomEvent('transform', { detail: { type: 'rotate-ccw', width: this._width, height: this._height } }),
809
+ );
810
+ }
811
+
587
812
  // ---------------- Properties ----------------
588
813
  get width() {
589
814
  return this._width;