@underpostnet/cyberia 3.3.73 → 3.3.77

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 (269) hide show
  1. package/.env.example +17 -5
  2. package/.github/workflows/coverall.cyberia.ci.yml +10 -0
  3. package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
  4. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
  5. package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
  6. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
  7. package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
  8. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
  9. package/.github/workflows/engine-cyberia.ci.yml +1 -1
  10. package/.github/workflows/publish.cyberia.ci.yml +44 -0
  11. package/AGENTS.md +61 -12
  12. package/CHANGELOG.md +159 -161
  13. package/CLI-HELP.md +20 -13
  14. package/Dockerfile +1 -1
  15. package/Dockerfile.dev +1 -1
  16. package/Dockerfile.test +2 -2
  17. package/README.md +28 -28
  18. package/bin/build.js +96 -21
  19. package/bin/cyberia.js +1247 -1373
  20. package/bin/index.js +1247 -1373
  21. package/compose.env +17 -5
  22. package/conf.js +7 -4
  23. package/deploy/cyberia-client/deploy.sh +10 -11
  24. package/deploy/cyberia-client/package.sh +0 -1
  25. package/deploy/cyberia-client/state.sh +3 -2
  26. package/deploy/cyberia-server/deploy.sh +10 -11
  27. package/deploy/cyberia-server/package.sh +0 -1
  28. package/deploy/cyberia-server/state.sh +3 -2
  29. package/deploy/dd-cyberia/deploy.sh +5 -5
  30. package/deploy/dd-cyberia/init.sh +15 -14
  31. package/deploy/dd-cyberia/package.sh +0 -1
  32. package/deploy/dd-cyberia/state.sh +2 -2
  33. package/deploy/dd-cyberia/sync-deploy.sh +139 -75
  34. package/deploy/lib/config.sh +29 -0
  35. package/deploy/lib/github-actions-logging.sh +92 -76
  36. package/deploy/lib/host.sh +28 -19
  37. package/deploy/lib/state.sh +4 -1
  38. package/deploy/pwa-microservices-template/deploy.sh +10 -8
  39. package/deploy/release/deploy.sh +0 -2
  40. package/deployment.yaml +1 -1
  41. package/docker-compose.yml +23 -17
  42. package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1591 -0
  43. package/docs/coverage/cyberia/api/cyberia-instance/index.html +116 -0
  44. package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +4138 -0
  45. package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +116 -0
  46. package/docs/coverage/cyberia/api/object-layer/index.html +116 -0
  47. package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1393 -0
  48. package/docs/coverage/cyberia/base.css +362 -0
  49. package/docs/coverage/cyberia/block-navigation.js +82 -0
  50. package/docs/coverage/cyberia/favicon.png +0 -0
  51. package/docs/coverage/cyberia/index.html +161 -0
  52. package/docs/coverage/cyberia/prettify.css +101 -0
  53. package/docs/coverage/cyberia/prettify.js +937 -0
  54. package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1315 -0
  55. package/docs/coverage/cyberia/projects/cyberia/index.html +176 -0
  56. package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +715 -0
  57. package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +2182 -0
  58. package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +3265 -0
  59. package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +328 -0
  60. package/docs/coverage/cyberia/sort-arrow-sprite.png +0 -0
  61. package/docs/coverage/cyberia/sorter.js +205 -0
  62. package/hardhat/package-lock.json +2 -2
  63. package/hardhat/package.json +1 -1
  64. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  65. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
  66. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
  67. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  68. package/manifests/deployment/mongo-express/deployment.yaml +46 -27
  69. package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
  70. package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
  71. package/manifests/deployment/mongo-express/service.yaml +16 -0
  72. package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
  73. package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
  74. package/package.json +19 -17
  75. package/scripts/test-monitor.sh +3 -3
  76. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +14 -9
  77. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
  78. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
  79. package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +88 -184
  80. package/src/api/cyberia-action/cyberia-action.router.js +2 -2
  81. package/src/api/cyberia-action/cyberia-action.service.js +3 -4
  82. package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
  83. package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
  84. package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
  85. package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
  86. package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -3
  87. package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
  88. package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +12 -34
  89. package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +15 -32
  90. package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
  91. package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
  92. package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
  93. package/src/api/cyberia-entity/cyberia-entity.service.js +1 -1
  94. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
  95. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
  96. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +16 -1
  97. package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +466 -3
  98. package/src/api/cyberia-instance/cyberia-fallback-capture.js +87 -51
  99. package/src/api/cyberia-instance/cyberia-fallback-world.js +21 -40
  100. package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
  101. package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
  102. package/src/api/cyberia-instance/cyberia-instance-map.service.js +34 -141
  103. package/src/api/cyberia-instance/cyberia-instance.controller.js +1 -4
  104. package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
  105. package/src/api/cyberia-instance/cyberia-instance.router.js +3 -16
  106. package/src/api/cyberia-instance/cyberia-instance.service.js +3 -43
  107. package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
  108. package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
  109. package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +74 -1
  110. package/src/api/cyberia-map/cyberia-map.service.js +6 -2
  111. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
  112. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
  113. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
  114. package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
  115. package/src/api/cyberia-quest/cyberia-quest.router.js +3 -3
  116. package/src/api/cyberia-quest/cyberia-quest.service.js +3 -3
  117. package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
  118. package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -188
  119. package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
  120. package/src/api/file/file.ref.js +153 -0
  121. package/src/api/file/file.ref.json +11 -2
  122. package/src/api/object-layer/object-layer.model.js +47 -12
  123. package/src/api/object-layer/object-layer.router.js +10 -3
  124. package/src/api/object-layer/object-layer.service.js +113 -120
  125. package/src/cli/app.js +12 -1
  126. package/src/cli/client.js +2 -2
  127. package/src/cli/cluster.js +80 -18
  128. package/src/cli/deploy.js +215 -34
  129. package/src/cli/docker-compose.js +1 -1
  130. package/src/cli/fs-selection.js +115 -0
  131. package/src/cli/fs.js +158 -342
  132. package/src/cli/index.js +22 -10
  133. package/src/cli/release.js +3 -1
  134. package/src/cli/repository.js +35 -9
  135. package/src/cli/run.js +80 -199
  136. package/src/cli/test.js +4 -0
  137. package/src/cli/wireguard.js +32 -31
  138. package/src/client/components/core/Badge.js +0 -1
  139. package/src/client/components/core/CssCore.js +8 -1
  140. package/src/client/components/core/Docs.js +77 -100
  141. package/src/client/components/core/Modal.js +77 -23
  142. package/src/client/components/core/ToolTip.js +15 -0
  143. package/src/client/components/cryptokoyn/AppShellCryptokoyn.js +0 -2
  144. package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
  145. package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
  146. package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
  147. package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
  148. package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
  149. package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
  150. package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
  151. package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
  152. package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
  153. package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +0 -34
  154. package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
  155. package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
  156. package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +0 -4
  157. package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
  158. package/src/client/components/default/AppShellDefault.js +1 -4
  159. package/src/client/components/itemledger/AppShellItemledger.js +0 -2
  160. package/src/client/components/underpost/AppShellUnderpost.js +1 -1
  161. package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
  162. package/src/client/public/cyberia-docs/CYBERIA-CLI.md +280 -24
  163. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
  164. package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
  165. package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
  166. package/src/client/public/cyberia-docs/CYBERIA.md +28 -28
  167. package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
  168. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  169. package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
  170. package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
  171. package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
  172. package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
  173. package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
  174. package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
  175. package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
  176. package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
  177. package/src/client/services/object-layer/object-layer.management.js +6 -10
  178. package/src/client/services/object-layer/object-layer.service.js +3 -1
  179. package/src/client/ssr/views/Cyberia404.js +3 -3
  180. package/src/client-builder/client-build-docs.js +52 -52
  181. package/src/client-builder/client-build.js +67 -44
  182. package/src/client-builder/client-bundle.js +347 -0
  183. package/src/db/mongo/MongoExpress.js +187 -0
  184. package/src/grpc/cyberia/grpc-server.js +5 -7
  185. package/src/index.js +1 -1
  186. package/src/projects/cyberia/atlas-sprite-sheet-generator.js +143 -146
  187. package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
  188. package/src/projects/cyberia/besu-genesis-generator.js +5 -14
  189. package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
  190. package/src/projects/cyberia/catalog-cyberia.js +3 -2
  191. package/src/projects/cyberia/gemini-client.js +3 -3
  192. package/src/projects/cyberia/generate-saga.js +4 -11
  193. package/src/projects/cyberia/hot-reload-trigger.js +17 -28
  194. package/src/projects/cyberia/instance-backup.js +210 -0
  195. package/src/projects/cyberia/instance-data.js +177 -257
  196. package/src/projects/cyberia/map-preview-generator.js +32 -68
  197. package/src/projects/cyberia/object-layer.js +152 -319
  198. package/src/projects/cyberia/seed-audio.js +139 -0
  199. package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
  200. package/src/projects/cyberia/semantic-layer-generator.js +2 -18
  201. package/src/projects/cyberia/stat-balance.js +81 -0
  202. package/src/projects/cyberia/stat-commands.js +23 -0
  203. package/src/projects/cyberia/stat-contract-generator.js +101 -0
  204. package/src/runtime/cyberia-client/Dockerfile +8 -4
  205. package/src/runtime/cyberia-client/Dockerfile.dev +5 -2
  206. package/src/runtime/cyberia-server/Dockerfile +9 -2
  207. package/src/runtime/cyberia-server/Dockerfile.dev +8 -1
  208. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  209. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  210. package/src/runtime/engine-cyberia/Dockerfile.test +2 -2
  211. package/src/runtime/engine-cyberia/compose.env +17 -5
  212. package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
  213. package/src/server/build/coverage.js +113 -42
  214. package/src/server/build/package.js +55 -12
  215. package/src/server/build/testing.js +59 -9
  216. package/src/server/network/middlewares.js +8 -2
  217. package/src/server/network/underpost-gateway.js +113 -14
  218. package/src/server/ops/logger.js +70 -35
  219. package/src/server/runtime/conf.js +40 -9
  220. package/src/server/runtime/start.js +11 -2
  221. package/src/server/storage/data-query.js +16 -0
  222. package/src/server/storage/downloader.js +62 -14
  223. package/src/server/storage/zip.js +153 -0
  224. package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
  225. package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
  226. package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +34 -0
  227. package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
  228. package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
  229. package/test/integration/app/cyberia/cyberia-instance-conf-defaults.test.js +16 -22
  230. package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
  231. package/test/integration/app/cyberia/cyberia-load.test.js +6 -7
  232. package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
  233. package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
  234. package/test/integration/app/cyberia/fallback-world-capture.test.js +87 -2
  235. package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
  236. package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
  237. package/test/integration/app/cyberia/seed-audio.test.js +275 -0
  238. package/test/integration/infra/2-network/wireguard-cli.test.js +33 -9
  239. package/test/integration/infra/2-network/wireguard-edge.test.js +58 -18
  240. package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
  241. package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
  242. package/test/integration/infra/4-ingress/underpost-ingress.test.js +1 -1
  243. package/test/unit/catalog.test.js +13 -2
  244. package/test/unit/client-build-docs.test.js +97 -70
  245. package/test/unit/client-bundle.test.js +313 -0
  246. package/test/unit/conf-resolution.test.js +58 -7
  247. package/test/unit/coverage-artifact.test.js +120 -29
  248. package/test/unit/cyberia/instance-backup.test.js +183 -0
  249. package/test/unit/cyberia/instance-data.test.js +314 -0
  250. package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
  251. package/test/unit/cyberia/publish-workflow.test.js +22 -0
  252. package/test/unit/cyberia/stat-balance.test.js +69 -0
  253. package/test/unit/deploy-legacy-gateway-sweep.test.js +63 -0
  254. package/test/unit/deploy-log-table.test.js +76 -5
  255. package/test/unit/downloader-integrity.test.js +98 -0
  256. package/test/unit/file-reference-registry.test.js +141 -0
  257. package/test/unit/fs-storage-paths.test.js +596 -0
  258. package/test/unit/gateway-pod-fetch.test.js +95 -0
  259. package/test/unit/letsencrypt-issuer.test.js +101 -0
  260. package/test/unit/logger-redaction.test.js +29 -0
  261. package/test/unit/package.test.js +70 -16
  262. package/test/unit/prepare-host.test.js +84 -1
  263. package/test/unit/release-bump.test.js +3 -1
  264. package/test/unit/start-options.test.js +12 -0
  265. package/test/unit/test-tiers.test.js +27 -0
  266. package/test/unit/zip-archive.test.js +142 -0
  267. package/vitest.config.js +11 -3
  268. package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
  269. package/src/client/components/cyberia/FallbackWorldEngineCyberia.js +0 -368
@@ -1,6 +1,45 @@
1
1
  import { darkTheme, renderChessPattern } from '../core/Css.js';
2
2
  import { NotificationManager } from '../core/NotificationManager.js';
3
3
 
4
+ /* Stroked 24x24 glyphs drawn in currentColor. Inline rather than a font: the toolbar lives in a
5
+ * shadow root, where an external icon stylesheet never lands. */
6
+ const ICONS = {
7
+ undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h11a5 5 0 0 1 0 10h-4"/>',
8
+ redo: '<path d="m15 14 5-5-5-5"/><path d="M20 9H9a5 5 0 0 0 0 10h4"/>',
9
+ pencil: '<path d="M4 20h4L19.5 8.5a2 2 0 0 0-3-3L5 17v3z"/><path d="m14.5 6.5 3 3"/>',
10
+ eraser: '<path d="M9 20h11"/><path d="M5.5 16.5 13 9l5.5 5.5L14 19H8z"/>',
11
+ fill: '<path d="M11 4 5.5 9.5a2 2 0 0 0 0 3l4.5 4.5a2 2 0 0 0 3 0L18.5 12z"/><path d="M20 15s2 2.5 2 4a2 2 0 0 1-4 0c0-1.5 2-4 2-4z"/>',
12
+ eyedropper: '<path d="M16.8 3.2a2.5 2.5 0 0 1 4 3l-2 2-3.5-3.5z"/><path d="m14.5 6.5-10 10L4 20l3.5-.5 10-10z"/>',
13
+ marquee: '<rect x="3.5" y="3.5" width="17" height="17" rx="1" stroke-dasharray="3.5 3"/>',
14
+ move: '<path d="M12 3v18M3 12h18"/><path d="m9 6 3-3 3 3M9 18l3 3 3-3M6 9l-3 3 3 3M18 9l3 3-3 3"/>',
15
+ copy: '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/>',
16
+ cut: '<circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M7.5 16 18 4M16.5 16 6 4"/>',
17
+ paste: '<rect x="5" y="5" width="14" height="16" rx="2"/><path d="M9 5V3.5h6V5"/><path d="M9 11h6M9 15h6"/>',
18
+ trash: '<path d="M4 7h16M9 7V4h6v3"/><path d="m6.5 7 1 13h9l1-13"/>',
19
+ flipH: '<path d="M12 3v18" stroke-dasharray="3 3"/><path d="M9 7 4.5 12 9 17z"/><path d="m15 7 4.5 5-4.5 5z"/>',
20
+ flipV: '<path d="M3 12h18" stroke-dasharray="3 3"/><path d="M7 9 12 4.5 17 9z"/><path d="m7 15 5 4.5 5-4.5z"/>',
21
+ rotateCW: '<path d="M20.5 12a8.5 8.5 0 1 1-2.8-6.3"/><path d="M21 4v5h-5"/>',
22
+ rotateCCW: '<path d="M3.5 12a8.5 8.5 0 1 0 2.8-6.3"/><path d="M3 4v5h5"/>',
23
+ clear: '<rect x="4" y="4" width="16" height="16" rx="2" stroke-dasharray="3 3"/><path d="m9 9 6 6M15 9l-6 6"/>',
24
+ selectAll:
25
+ '<rect x="3.5" y="3.5" width="17" height="17" rx="1" stroke-dasharray="3.5 3"/><rect x="8" y="8" width="8" height="8" rx="1" fill="currentColor" stroke="none"/>',
26
+ rescale: '<path d="M4 10V4h6"/><path d="M20 14v6h-6"/><path d="m4 4 7 7M20 20l-7-7"/>',
27
+ };
28
+
29
+ /* One icon button: an inline SVG plus the accessible name, since the glyph carries no text. */
30
+ const iconButton = (part, name, label, { active = false } = {}) => html`
31
+ <button
32
+ part="${part}"
33
+ class="icon-btn"
34
+ type="button"
35
+ title="${label}"
36
+ aria-label="${label}"
37
+ ${active ? 'data-active' : ''}
38
+ >
39
+ <svg viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>
40
+ </button>
41
+ `;
42
+
4
43
  class ObjectLayerEngineElement extends HTMLElement {
5
44
  constructor() {
6
45
  super();
@@ -25,7 +64,27 @@ class ObjectLayerEngineElement extends HTMLElement {
25
64
  gap: var(--gap);
26
65
  align-items: flex-start;
27
66
  }
67
+ /* Rulers on two sides of the frame: the X axis above, the Y axis on the left, and the
68
+ frame's own border padded onto each so the ticks line up with the cells inside it. */
69
+ .board {
70
+ display: grid;
71
+ grid-template-columns: auto auto;
72
+ grid-template-rows: auto auto;
73
+ line-height: 0;
74
+ }
75
+ canvas.ruler-x {
76
+ grid-column: 2;
77
+ border-left: var(--border);
78
+ border-left-color: transparent;
79
+ }
80
+ canvas.ruler-y {
81
+ grid-row: 2;
82
+ border-top: var(--border);
83
+ border-top-color: transparent;
84
+ }
28
85
  .canvas-frame {
86
+ grid-column: 2;
87
+ grid-row: 2;
29
88
  border: var(--border);
30
89
  display: inline-block;
31
90
  line-height: 0;
@@ -33,6 +92,12 @@ class ObjectLayerEngineElement extends HTMLElement {
33
92
  background: transparent;
34
93
  image-rendering: pixelated;
35
94
  }
95
+ .cursor-info {
96
+ font-size: 12px;
97
+ opacity: 0.75;
98
+ min-height: 1.2em;
99
+ font-variant-numeric: tabular-nums;
100
+ }
36
101
  canvas.canvas-layer {
37
102
  display: block;
38
103
  touch-action: none;
@@ -60,13 +125,164 @@ class ObjectLayerEngineElement extends HTMLElement {
60
125
  gap: 6px;
61
126
  align-items: center;
62
127
  }
128
+ .sel-info {
129
+ font-size: 12px;
130
+ opacity: 0.75;
131
+ min-width: 11ch;
132
+ font-variant-numeric: tabular-nums;
133
+ }
134
+
135
+ /* Icon buttons: square, quiet until hovered, ringed while active — the same state
136
+ language the palette element uses for a chosen swatch. */
137
+ .icon-btn {
138
+ appearance: none;
139
+ background: transparent;
140
+ border: 1px solid transparent;
141
+ border-radius: 6px;
142
+ color: inherit;
143
+ cursor: pointer;
144
+ display: inline-flex;
145
+ align-items: center;
146
+ justify-content: center;
147
+ width: 30px;
148
+ height: 30px;
149
+ padding: 0;
150
+ transition:
151
+ background-color 120ms ease,
152
+ border-color 120ms ease,
153
+ box-shadow 120ms ease;
154
+ }
155
+ .icon-btn svg {
156
+ width: 18px;
157
+ height: 18px;
158
+ fill: none;
159
+ stroke: currentColor;
160
+ stroke-width: 2;
161
+ stroke-linecap: round;
162
+ stroke-linejoin: round;
163
+ pointer-events: none;
164
+ }
165
+ .icon-btn:hover:not(:disabled) {
166
+ background: ${darkTheme ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.08)'};
167
+ }
168
+ .icon-btn:disabled {
169
+ opacity: 0.35;
170
+ cursor: default;
171
+ }
172
+ .icon-btn[data-active] {
173
+ background: ${darkTheme ? 'rgba(255, 255, 255, 0.16)' : 'rgba(0, 0, 0, 0.1)'};
174
+ border-color: currentColor;
175
+ box-shadow: 0 0 0 2px ${darkTheme ? 'rgba(255, 255, 255, 0.22)' : 'rgba(0, 0, 0, 0.16)'};
176
+ }
177
+
178
+ /* A rule between groups, so the toolbar reads as sections rather than one long row. */
179
+ .sep {
180
+ width: 1px;
181
+ align-self: stretch;
182
+ min-height: 24px;
183
+ background: ${darkTheme ? 'rgba(255, 255, 255, 0.18)' : 'rgba(0, 0, 0, 0.15)'};
184
+ }
185
+
186
+ /* The selection bar only exists while select mode is on, so the default toolbar stays
187
+ as short as it was. */
188
+ .sel-bar[hidden] {
189
+ display: none;
190
+ }
191
+ .sel-bar {
192
+ display: flex;
193
+ gap: 6px;
194
+ flex-wrap: wrap;
195
+ align-items: center;
196
+ padding: 4px 6px;
197
+ border-radius: 8px;
198
+ background: ${darkTheme ? 'rgba(255, 255, 255, 0.07)' : 'rgba(0, 0, 0, 0.05)'};
199
+ }
200
+ .hint {
201
+ font-size: 12px;
202
+ opacity: 0.7;
203
+ }
204
+
205
+ /* Right-click menu, positioned over the canvas frame at the pointer. */
206
+ .ctx-menu {
207
+ position: absolute;
208
+ z-index: 5;
209
+ min-width: 148px;
210
+ padding: 4px;
211
+ border-radius: 8px;
212
+ line-height: normal;
213
+ background: ${darkTheme ? '#242424' : '#ffffff'};
214
+ border: 1px solid ${darkTheme ? 'rgba(255, 255, 255, 0.18)' : 'rgba(0, 0, 0, 0.18)'};
215
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
216
+ }
217
+ .ctx-menu[hidden] {
218
+ display: none;
219
+ }
220
+ .ctx-item {
221
+ appearance: none;
222
+ background: transparent;
223
+ border: 0;
224
+ border-radius: 5px;
225
+ color: inherit;
226
+ cursor: pointer;
227
+ display: flex;
228
+ align-items: center;
229
+ gap: 8px;
230
+ width: 100%;
231
+ padding: 6px 8px;
232
+ font: inherit;
233
+ font-size: 13px;
234
+ text-align: left;
235
+ }
236
+ .ctx-item svg {
237
+ width: 15px;
238
+ height: 15px;
239
+ fill: none;
240
+ stroke: currentColor;
241
+ stroke-width: 2;
242
+ stroke-linecap: round;
243
+ stroke-linejoin: round;
244
+ flex: none;
245
+ }
246
+ .ctx-item:hover:not(:disabled) {
247
+ background: ${darkTheme ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.08)'};
248
+ }
249
+ .ctx-item:disabled {
250
+ opacity: 0.35;
251
+ cursor: default;
252
+ }
253
+ .ctx-key {
254
+ margin-left: auto;
255
+ opacity: 0.55;
256
+ font-size: 11px;
257
+ }
63
258
  </style>
64
259
 
65
260
  <div class="wrap">
66
261
  <div class="toolbar">
67
- <button part="undo" title="Undo" disabled>Undo</button>
68
- <button part="redo" title="Redo" disabled>Redo</button>
262
+ ${iconButton('undo', 'undo', 'Undo (Ctrl+Z)')} ${iconButton('redo', 'redo', 'Redo (Ctrl+Shift+Z)')}
263
+
264
+ <span class="sep"></span>
265
+
266
+ <!-- Tools: one button each, so the active tool is visible without opening anything. -->
267
+ ${iconButton('tool-pencil', 'pencil', 'Pencil', { active: true })}
268
+ ${iconButton('tool-eraser', 'eraser', 'Eraser')} ${iconButton('tool-fill', 'fill', 'Fill')}
269
+ ${iconButton('tool-eyedropper', 'eyedropper', 'Pick colour')}
270
+
271
+ <span class="sep"></span>
272
+
273
+ <!-- Select mode: while it is on the canvas selects and moves instead of drawing. -->
274
+ ${iconButton('tool-select', 'marquee', 'Select mode: drag to select, then drag to move')}
275
+
276
+ <span class="sep"></span>
69
277
 
278
+ <div class="group">
279
+ ${iconButton('flip-h', 'flipH', 'Flip horizontally')} ${iconButton('flip-v', 'flipV', 'Flip vertically')}
280
+ ${iconButton('rot-ccw', 'rotateCCW', 'Rotate -90°')} ${iconButton('rot-cw', 'rotateCW', 'Rotate +90°')}
281
+ ${iconButton('clear', 'clear', 'Clear (make fully transparent)')}
282
+ </div>
283
+ </div>
284
+
285
+ <div class="toolbar">
70
286
  <input type="color" part="color" title="Brush color" value="#000000" />
71
287
  <label
72
288
  >hex
@@ -84,31 +300,6 @@ class ObjectLayerEngineElement extends HTMLElement {
84
300
  <input type="number" part="b-input" min="0" max="255" value="0" title="Blue (0-255)" style="width:5ch" />
85
301
  <input type="number" part="a-input" min="0" max="255" value="255" title="Alpha (0-255)" style="width:5ch" />
86
302
  </label>
87
- <select part="tool">
88
- <option value="pencil">pencil</option>
89
- <option value="eraser">eraser</option>
90
- <option value="fill">fill</option>
91
- <option value="eyedropper">eyedropper</option>
92
- </select>
93
-
94
- <label>brush <input type="number" part="brush-size" min="1" value="1" /></label>
95
- <label>pixel-size <input type="number" part="pixel-size" min="1" value="16" /></label>
96
-
97
- <!-- New: cell dimensions (width x height) -->
98
- <label
99
- >cells <input type="number" part="cell-width" min="1" value="16" style="width:6ch" /> x
100
- <input type="number" part="cell-height" min="1" value="16" style="width:6ch"
101
- /></label>
102
-
103
- <label class="switch"> <input type="checkbox" part="toggle-grid" /> grid </label>
104
-
105
- <!-- New: transform tools -->
106
- <div class="group">
107
- <button part="flip-h" title="Flip horizontally">Flip H</button>
108
- <button part="flip-v" title="Flip vertically">Flip V</button>
109
- <button part="rot-ccw" title="Rotate -90°">⟲</button>
110
- <button part="rot-cw" title="Rotate +90°">⟳</button>
111
- </div>
112
303
 
113
304
  <label
114
305
  >opacity <input type="range" part="opacity" min="0" max="255" value="255" style="width:10rem" /><input
@@ -120,29 +311,75 @@ class ObjectLayerEngineElement extends HTMLElement {
120
311
  style="width:5ch;margin-left:4px"
121
312
  /></label>
122
313
 
123
- <button part="clear" title="Clear (make fully transparent)">Clear</button>
314
+ <label>brush <input type="number" part="brush-size" min="1" value="1" /></label>
315
+ </div>
316
+
317
+ <div class="toolbar">
318
+ <label>pixel-size <input type="number" part="pixel-size" min="1" value="16" /></label>
319
+ <label
320
+ >cells <input type="number" part="cell-width" min="1" value="16" style="width:6ch" /> x
321
+ <input type="number" part="cell-height" min="1" value="16" style="width:6ch"
322
+ /></label>
323
+ <label class="switch"> <input type="checkbox" part="toggle-grid" /> grid </label>
324
+
325
+ <span class="sep"></span>
124
326
 
125
327
  <button part="export">Export PNG</button>
126
328
  <button part="export-json">Export JSON</button>
127
329
  <button part="import-json">Import JSON</button>
128
330
  </div>
129
- <div class="canvas-frame" style="${renderChessPattern()}">
130
- <canvas part="canvas" class="canvas-layer"></canvas>
131
- <canvas part="grid" class="grid-layer"></canvas>
331
+
332
+ <!-- Everything the selection can do, shown only while select mode is on. -->
333
+ <div class="sel-bar" part="sel-bar" hidden>
334
+ ${iconButton('select-all', 'selectAll', 'Select all (Ctrl+A)')}
335
+ ${iconButton('reselect', 'marquee', 'Reselect: drop this one and drag a new area (Esc)')}
336
+ <span class="sep"></span>
337
+ ${iconButton('copy', 'copy', 'Copy (Ctrl+C)')} ${iconButton('cut', 'cut', 'Cut (Ctrl+X)')}
338
+ ${iconButton('paste', 'paste', 'Paste (Ctrl+V)')} ${iconButton('delete', 'trash', 'Delete (Del)')}
339
+ <span class="sep"></span>
340
+ <label
341
+ >rescale <input type="number" part="scale-width" min="1" value="16" style="width:6ch" /> x
342
+ <input type="number" part="scale-height" min="1" value="16" style="width:6ch"
343
+ /></label>
344
+ ${iconButton('scale-apply', 'rescale', 'Resample to that size, keeping the source palette')}
345
+ <span class="sep"></span>
346
+ <span part="sel-info" class="sel-info">no selection</span>
347
+ <span part="sel-hint" class="hint">Drag to select</span>
348
+ </div>
349
+
350
+ <div class="board">
351
+ <canvas part="ruler-x" class="ruler-x"></canvas>
352
+ <canvas part="ruler-y" class="ruler-y"></canvas>
353
+ <div class="canvas-frame" style="${renderChessPattern()}">
354
+ <canvas part="canvas" class="canvas-layer"></canvas>
355
+ <canvas part="grid" class="grid-layer"></canvas>
356
+ <div class="ctx-menu" part="ctx-menu" hidden></div>
357
+ </div>
132
358
  </div>
359
+ <span part="cursor-info" class="cursor-info"></span>
133
360
  </div>
134
361
  `;
135
362
 
136
363
  // DOM
137
364
  this._pixelCanvas = this.shadowRoot.querySelector('canvas[part="canvas"]');
138
365
  this._gridCanvas = this.shadowRoot.querySelector('canvas[part="grid"]');
366
+ this._rulerX = this.shadowRoot.querySelector('canvas[part="ruler-x"]');
367
+ this._rulerY = this.shadowRoot.querySelector('canvas[part="ruler-y"]');
368
+ this._cursorInfo = this.shadowRoot.querySelector('span[part="cursor-info"]');
139
369
  this._colorInput = this.shadowRoot.querySelector('input[part="color"]');
140
370
  this._hexInput = this.shadowRoot.querySelector('input[part="hex-input"]');
141
371
  this._rInput = this.shadowRoot.querySelector('input[part="r-input"]');
142
372
  this._gInput = this.shadowRoot.querySelector('input[part="g-input"]');
143
373
  this._bInput = this.shadowRoot.querySelector('input[part="b-input"]');
144
374
  this._aInput = this.shadowRoot.querySelector('input[part="a-input"]');
145
- this._toolSelect = this.shadowRoot.querySelector('select[part="tool"]');
375
+ // Tool buttons, keyed by the tool they select.
376
+ this._toolButtons = {
377
+ pencil: this.shadowRoot.querySelector('button[part="tool-pencil"]'),
378
+ eraser: this.shadowRoot.querySelector('button[part="tool-eraser"]'),
379
+ fill: this.shadowRoot.querySelector('button[part="tool-fill"]'),
380
+ eyedropper: this.shadowRoot.querySelector('button[part="tool-eyedropper"]'),
381
+ };
382
+ this._selectModeBtn = this.shadowRoot.querySelector('button[part="tool-select"]');
146
383
  this._brushSizeInput = this.shadowRoot.querySelector('input[part="brush-size"]');
147
384
  this._pixelSizeInput = this.shadowRoot.querySelector('input[part="pixel-size"]');
148
385
  this._exportBtn = this.shadowRoot.querySelector('button[part="export"]');
@@ -165,6 +402,21 @@ class ObjectLayerEngineElement extends HTMLElement {
165
402
  this._undoBtn = this.shadowRoot.querySelector('button[part="undo"]');
166
403
  this._redoBtn = this.shadowRoot.querySelector('button[part="redo"]');
167
404
 
405
+ // selection + rescale controls
406
+ this._selBar = this.shadowRoot.querySelector('div[part="sel-bar"]');
407
+ this._selectAllBtn = this.shadowRoot.querySelector('button[part="select-all"]');
408
+ this._reselectBtn = this.shadowRoot.querySelector('button[part="reselect"]');
409
+ this._copyBtn = this.shadowRoot.querySelector('button[part="copy"]');
410
+ this._cutBtn = this.shadowRoot.querySelector('button[part="cut"]');
411
+ this._pasteBtn = this.shadowRoot.querySelector('button[part="paste"]');
412
+ this._deleteBtn = this.shadowRoot.querySelector('button[part="delete"]');
413
+ this._selInfo = this.shadowRoot.querySelector('span[part="sel-info"]');
414
+ this._selHint = this.shadowRoot.querySelector('span[part="sel-hint"]');
415
+ this._ctxMenu = this.shadowRoot.querySelector('div[part="ctx-menu"]');
416
+ this._scaleWidthInput = this.shadowRoot.querySelector('input[part="scale-width"]');
417
+ this._scaleHeightInput = this.shadowRoot.querySelector('input[part="scale-height"]');
418
+ this._scaleApplyBtn = this.shadowRoot.querySelector('button[part="scale-apply"]');
419
+
168
420
  // internal state
169
421
  this._width = 16;
170
422
  this._height = 16;
@@ -187,6 +439,19 @@ class ObjectLayerEngineElement extends HTMLElement {
187
439
  this._maxHistory = 200;
188
440
  this._transactionActive = false; // grouping for pointer drags
189
441
 
442
+ // Select mode replaces drawing with selecting and moving; off, the canvas draws as before.
443
+ this._selectMode = false;
444
+ // selection: {x, y, w, h} in cells, or null. _selAnchor holds the marquee drag origin.
445
+ this._selection = null;
446
+ this._selAnchor = null;
447
+ // While a move is under way the cells travel here, lifted out of the matrix so what they
448
+ // covered shows through, and are written back on drop.
449
+ this._floating = null;
450
+ this._moveGrab = null;
451
+ this._clipboard = null; // matrix copied out of a selection
452
+ this._antsPhase = 0; // marching-ants offset, advanced while a selection stands
453
+ this._antsFrame = 0;
454
+
190
455
  // binds
191
456
  this._onPointerDown = this._onPointerDown.bind(this);
192
457
  this._onPointerMove = this._onPointerMove.bind(this);
@@ -198,6 +463,9 @@ class ObjectLayerEngineElement extends HTMLElement {
198
463
  this.flipVertical = this.flipVertical.bind(this);
199
464
  this.rotateCW = this.rotateCW.bind(this);
200
465
  this.rotateCCW = this.rotateCCW.bind(this);
466
+ this._tickAnts = this._tickAnts.bind(this);
467
+ this._onContextMenu = this._onContextMenu.bind(this);
468
+ this._onDocumentPointerDown = this._onDocumentPointerDown.bind(this);
201
469
 
202
470
  // ensure keyboard handlers bound for undo/redo
203
471
  }
@@ -206,7 +474,9 @@ class ObjectLayerEngineElement extends HTMLElement {
206
474
  return ['width', 'height', 'pixel-size'];
207
475
  }
208
476
  attributeChangedCallback(name, oldV, newV) {
209
- if (oldV === newV) return;
477
+ // An attribute this element wrote itself is already applied: reacting to it would read the
478
+ // sibling axis before its own write lands and cut the matrix to the stale size.
479
+ if (oldV === newV || this._reflecting) return;
210
480
  if (name === 'width') this.width = parseInt(newV, 10) || this._width;
211
481
  if (name === 'height') this.height = parseInt(newV, 10) || this._height;
212
482
  if (name === 'pixel-size') this.pixelSize = parseInt(newV, 10) || this._pixelSize;
@@ -265,7 +535,10 @@ class ObjectLayerEngineElement extends HTMLElement {
265
535
  if (this._bInput) this._bInput.addEventListener('change', updateFromRGBAInputs);
266
536
  if (this._aInput) this._aInput.addEventListener('change', updateFromRGBAInputs);
267
537
 
268
- this._toolSelect.addEventListener('change', (e) => this.setTool(e.target.value));
538
+ for (const [name, button] of Object.entries(this._toolButtons)) {
539
+ if (button) button.addEventListener('click', () => this.setTool(name));
540
+ }
541
+ if (this._selectModeBtn) this._selectModeBtn.addEventListener('click', () => this.toggleSelectMode());
269
542
  this._brushSizeInput.addEventListener('change', (e) => this.setBrushSize(parseInt(e.target.value, 10) || 1));
270
543
  this._pixelSizeInput.addEventListener('change', (e) => {
271
544
  this.pixelSize = Math.max(1, parseInt(e.target.value, 10) || 1);
@@ -367,6 +640,26 @@ class ObjectLayerEngineElement extends HTMLElement {
367
640
  if (this._undoBtn) this._undoBtn.addEventListener('click', () => this.undo());
368
641
  if (this._redoBtn) this._redoBtn.addEventListener('click', () => this.redo());
369
642
 
643
+ // selection + clipboard
644
+ if (this._selectAllBtn) this._selectAllBtn.addEventListener('click', () => this.selectAll());
645
+ if (this._reselectBtn) this._reselectBtn.addEventListener('click', () => this.reselect());
646
+ if (this._copyBtn) this._copyBtn.addEventListener('click', () => this.copySelection());
647
+ if (this._cutBtn) this._cutBtn.addEventListener('click', () => this.cutSelection());
648
+ if (this._pasteBtn) this._pasteBtn.addEventListener('click', () => this.paste());
649
+ if (this._deleteBtn) this._deleteBtn.addEventListener('click', () => this.deleteSelection());
650
+
651
+ // Right-click opens the clipboard menu over the canvas, and any press elsewhere closes it.
652
+ this._pixelCanvas.addEventListener('contextmenu', this._onContextMenu);
653
+ document.addEventListener('pointerdown', this._onDocumentPointerDown, true);
654
+
655
+ // rescale: the inputs track whatever the Apply button would act on
656
+ if (this._scaleApplyBtn)
657
+ this._scaleApplyBtn.addEventListener('click', () => {
658
+ const w = Math.max(1, parseInt(this._scaleWidthInput?.value, 10) || 1);
659
+ const h = Math.max(1, parseInt(this._scaleHeightInput?.value, 10) || 1);
660
+ this.rescale(w, h);
661
+ });
662
+
370
663
  // Pointer events
371
664
  this._pixelCanvas.addEventListener('pointerdown', this._onPointerDown);
372
665
  window.addEventListener('pointermove', this._onPointerMove);
@@ -379,6 +672,8 @@ class ObjectLayerEngineElement extends HTMLElement {
379
672
  this.render();
380
673
  this._clearHistory();
381
674
  this._updateToolbarButtons();
675
+ this._updateToolButtons();
676
+ this._updateSelectionUI();
382
677
  }
383
678
 
384
679
  disconnectedCallback() {
@@ -398,6 +693,13 @@ class ObjectLayerEngineElement extends HTMLElement {
398
693
  if (this._redoBtn) this._redoBtn.removeEventListener('click', () => this.redo());
399
694
 
400
695
  window.removeEventListener('keydown', this._onKeyDown);
696
+
697
+ this._pixelCanvas.removeEventListener('contextmenu', this._onContextMenu);
698
+ document.removeEventListener('pointerdown', this._onDocumentPointerDown, true);
699
+
700
+ // the ants loop holds a frame handle across renders; it must not outlive the element
701
+ if (this._antsFrame) cancelAnimationFrame(this._antsFrame);
702
+ this._antsFrame = 0;
401
703
  }
402
704
 
403
705
  // ---------------- Matrix helpers ----------------
@@ -418,6 +720,22 @@ class ObjectLayerEngineElement extends HTMLElement {
418
720
  return mat;
419
721
  }
420
722
 
723
+ /* Takes new cell dimensions and keeps the size inputs and attributes saying the same thing,
724
+ * so a matrix loaded at another size cannot leave stale numbers for the next change to apply. */
725
+ _setDimensions(w, h) {
726
+ this._width = w;
727
+ this._height = h;
728
+ if (this._widthInput) this._widthInput.value = String(w);
729
+ if (this._heightInput) this._heightInput.value = String(h);
730
+ this._reflecting = true;
731
+ try {
732
+ this.setAttribute('width', String(w));
733
+ this.setAttribute('height', String(h));
734
+ } finally {
735
+ this._reflecting = false;
736
+ }
737
+ }
738
+
421
739
  loadMatrix(matrix) {
422
740
  if (!Array.isArray(matrix) || matrix.length === 0) throw new TypeError('matrix must be non-empty 2D array');
423
741
  const h = matrix.length;
@@ -430,9 +748,10 @@ class ObjectLayerEngineElement extends HTMLElement {
430
748
  matrix[y][x] = v.map((n) => this._clampInt(n));
431
749
  }
432
750
  }
433
- this._width = w;
434
- this._height = h;
435
751
  this._matrix = matrix.map((r) => r.map((c) => c.slice()));
752
+ this._setDimensions(w, h);
753
+ this.clearSelection();
754
+ this._updateSelectionUI();
436
755
  this._setupContextsAndSize();
437
756
  this.render();
438
757
  this.dispatchEvent(new CustomEvent('matrixload', { detail: { width: w, height: h } }));
@@ -453,16 +772,11 @@ class ObjectLayerEngineElement extends HTMLElement {
453
772
  const minH = Math.min(nh, this._height);
454
773
  for (let y = 0; y < minH; y++) for (let x = 0; x < minW; x++) newMat[y][x] = this._matrix[y][x].slice();
455
774
  }
456
- this._width = nw;
457
- this._height = nh;
458
775
  this._matrix = newMat;
776
+ this._setDimensions(nw, nh);
459
777
 
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
-
778
+ this._clampSelection();
779
+ this._updateSelectionUI();
466
780
  this._setupContextsAndSize();
467
781
  this.render();
468
782
  this.dispatchEvent(new CustomEvent('resize', { detail: { width: nw, height: nh } }));
@@ -507,9 +821,62 @@ class ObjectLayerEngineElement extends HTMLElement {
507
821
  this._pixelCtx.imageSmoothingEnabled = false;
508
822
  this._gridCtx.imageSmoothingEnabled = false;
509
823
  } catch (e) {}
824
+ this._renderRulers();
510
825
  this._renderGrid();
511
826
  }
512
827
 
828
+ /* Cell indices along each axis. Every cell gets a tick; a number goes on every cell it fits
829
+ * on, and on every Nth otherwise, so the rulers stay readable at any pixel size. */
830
+ _renderRulers() {
831
+ const ps = this._pixelSize;
832
+ const font = '10px ui-monospace, Menlo, monospace';
833
+ const color = darkTheme ? '#e1e1e1' : '#272727';
834
+ const thickness = 16;
835
+ const measure = this._rulerX.getContext('2d');
836
+ measure.font = font;
837
+ const digits = measure.measureText(String(Math.max(this._width, this._height) - 1)).width;
838
+ const step = Math.max(1, Math.ceil((digits + 4) / ps));
839
+
840
+ const draw = (canvas, count, horizontal) => {
841
+ canvas.width = horizontal ? count * ps : Math.ceil(digits) + 8;
842
+ canvas.height = horizontal ? thickness : count * ps;
843
+ canvas.style.width = `${canvas.width}px`;
844
+ canvas.style.height = `${canvas.height}px`;
845
+ const ctx = canvas.getContext('2d');
846
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
847
+ ctx.font = font;
848
+ ctx.fillStyle = color;
849
+ ctx.strokeStyle = color;
850
+ ctx.lineWidth = 1;
851
+ ctx.textBaseline = 'middle';
852
+ ctx.textAlign = horizontal ? 'center' : 'right';
853
+ ctx.beginPath();
854
+ for (let i = 0; i < count; i++) {
855
+ const at = i * ps + 0.5;
856
+ const labelled = i % step === 0;
857
+ const tick = labelled ? 5 : 2;
858
+ if (horizontal) {
859
+ ctx.moveTo(at, thickness);
860
+ ctx.lineTo(at, thickness - tick);
861
+ if (labelled) ctx.fillText(String(i), at + ps / 2, (thickness - tick) / 2);
862
+ } else {
863
+ ctx.moveTo(canvas.width, at);
864
+ ctx.lineTo(canvas.width - tick, at);
865
+ if (labelled) ctx.fillText(String(i), canvas.width - tick - 2, at + ps / 2);
866
+ }
867
+ }
868
+ ctx.stroke();
869
+ };
870
+ draw(this._rulerX, this._width, true);
871
+ draw(this._rulerY, this._height, false);
872
+ }
873
+
874
+ /* The cell under the pointer, or nothing once it leaves the canvas. */
875
+ _updateCursorInfo(x, y) {
876
+ const inside = x >= 0 && y >= 0 && x < this._width && y < this._height;
877
+ this._cursorInfo.textContent = inside ? `x: ${x} y: ${y}` : '';
878
+ }
879
+
513
880
  render() {
514
881
  // sanity: ensure matrix shape matches
515
882
  if (
@@ -543,7 +910,7 @@ class ObjectLayerEngineElement extends HTMLElement {
543
910
  let p = 0;
544
911
  for (let y = 0; y < this._height; y++) {
545
912
  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];
913
+ const cell = this._floatingCellAt(x, y) || (this._matrix[y] && this._matrix[y][x]) || [0, 0, 0, 0];
547
914
  data[p++] = this._clampInt(cell[0]);
548
915
  data[p++] = this._clampInt(cell[1]);
549
916
  data[p++] = this._clampInt(cell[2]);
@@ -555,6 +922,17 @@ class ObjectLayerEngineElement extends HTMLElement {
555
922
  if (this._showGrid) this._renderGrid();
556
923
  }
557
924
 
925
+ /* The travelling cell covering (x, y) during a move, or null. A fully transparent one still
926
+ * counts: it is what the moved block carries there, not a hole to see the canvas through. */
927
+ _floatingCellAt(x, y) {
928
+ const sel = this._selection;
929
+ if (!this._floating || !sel) return null;
930
+ const fx = x - sel.x;
931
+ const fy = y - sel.y;
932
+ if (fy < 0 || fx < 0 || fy >= this._floating.length || fx >= this._floating[0].length) return null;
933
+ return this._floating[fy][fx];
934
+ }
935
+
558
936
  _drawCheckerboard() {
559
937
  const ctx = this._pixelCtx;
560
938
  const w = this._width;
@@ -576,30 +954,137 @@ class ObjectLayerEngineElement extends HTMLElement {
576
954
  const w = this._gridCanvas.width;
577
955
  const h = this._gridCanvas.height;
578
956
  ctx.clearRect(0, 0, w, h);
579
- if (!this._showGrid) return;
580
957
  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);
958
+ if (this._showGrid) {
959
+ ctx.save();
960
+ ctx.strokeStyle = darkTheme ? '#e1e1e1' : '#272727';
961
+ ctx.lineWidth = 2;
962
+ ctx.beginPath();
963
+ for (let x = 0; x <= this._width; x++) {
964
+ const xx = x * ps + 0.5;
965
+ ctx.moveTo(xx, 0);
966
+ ctx.lineTo(xx, h);
967
+ }
968
+ for (let y = 0; y <= this._height; y++) {
969
+ const yy = y * ps + 0.5;
970
+ ctx.moveTo(0, yy);
971
+ ctx.lineTo(w, yy);
972
+ }
973
+ ctx.stroke();
974
+ ctx.restore();
589
975
  }
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);
976
+ this._drawSelection();
977
+ }
978
+
979
+ /* Marching ants: a white rule under a moving dark dash, so the marquee reads against both a
980
+ * light and a dark drawing, and never reads as painted content. */
981
+ _drawSelection() {
982
+ const ctx = this._gridCtx;
983
+ const rect = this._selection;
984
+ if (!ctx || !rect) return;
985
+ const ps = this._pixelSize;
986
+ const box = [rect.x * ps + 0.5, rect.y * ps + 0.5, rect.w * ps - 1, rect.h * ps - 1];
987
+ ctx.save();
988
+ ctx.lineWidth = 1;
989
+ ctx.setLineDash([]);
990
+ ctx.strokeStyle = 'rgba(255,255,255,0.9)';
991
+ ctx.strokeRect(...box);
992
+ ctx.setLineDash([4, 4]);
993
+ ctx.lineDashOffset = -this._antsPhase;
994
+ ctx.strokeStyle = 'rgba(0,0,0,0.9)';
995
+ ctx.strokeRect(...box);
996
+
997
+ // Cell rules inside the rectangle: the selection reads as the grid of cells it is, whether
998
+ // or not the canvas grid is on. Skipped once the cells are too small to tell apart.
999
+ if (ps >= 6) {
1000
+ ctx.setLineDash([]);
1001
+ ctx.lineWidth = 1;
1002
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
1003
+ ctx.beginPath();
1004
+ for (let i = 1; i < rect.w; i++) {
1005
+ const xx = (rect.x + i) * ps + 0.5;
1006
+ ctx.moveTo(xx, rect.y * ps);
1007
+ ctx.lineTo(xx, (rect.y + rect.h) * ps);
1008
+ }
1009
+ for (let i = 1; i < rect.h; i++) {
1010
+ const yy = (rect.y + i) * ps + 0.5;
1011
+ ctx.moveTo(rect.x * ps, yy);
1012
+ ctx.lineTo((rect.x + rect.w) * ps, yy);
1013
+ }
1014
+ ctx.stroke();
594
1015
  }
595
- ctx.stroke();
596
1016
  ctx.restore();
597
1017
  }
598
1018
 
1019
+ _tickAnts() {
1020
+ if (!this._selection) {
1021
+ this._antsFrame = 0;
1022
+ return;
1023
+ }
1024
+ this._antsPhase = (this._antsPhase + 0.25) % 8;
1025
+ this._renderGrid();
1026
+ this._antsFrame = requestAnimationFrame(this._tickAnts);
1027
+ }
1028
+
1029
+ _startAnts() {
1030
+ if (this._antsFrame || !this._selection) return;
1031
+ this._antsFrame = requestAnimationFrame(this._tickAnts);
1032
+ }
1033
+
599
1034
  // ---------------- Tools & painting ----------------
1035
+ /* Choosing a drawing tool leaves select mode, so the canvas draws again straight away. */
600
1036
  setTool(name) {
1037
+ if (!this._toolButtons[name]) return;
601
1038
  this._tool = name;
602
- if (this._toolSelect) this._toolSelect.value = name;
1039
+ if (this._selectMode) this.setSelectMode(false);
1040
+ this._updateToolButtons();
1041
+ }
1042
+
1043
+ /* Select mode: the canvas selects and moves instead of drawing. Leaving it drops the
1044
+ * selection and hands the canvas back to the current drawing tool. */
1045
+ setSelectMode(on) {
1046
+ const next = !!on;
1047
+ if (next === this._selectMode) return;
1048
+ this._selectMode = next;
1049
+ if (!next) {
1050
+ this._dropFloating();
1051
+ this.clearSelection();
1052
+ this._closeContextMenu();
1053
+ }
1054
+ if (this._selBar) this._selBar.hidden = !next;
1055
+ this._updateToolButtons();
1056
+ this._updateSelectionUI();
1057
+ this._updateCursor();
1058
+ this.dispatchEvent(new CustomEvent('selectmodechange', { detail: { active: next } }));
1059
+ }
1060
+
1061
+ toggleSelectMode() {
1062
+ this.setSelectMode(!this._selectMode);
1063
+ }
1064
+
1065
+ /* Drops the current rectangle and waits for the next drag, without leaving select mode. */
1066
+ reselect() {
1067
+ this._dropFloating();
1068
+ this.clearSelection();
1069
+ this._updateCursor();
1070
+ }
1071
+
1072
+ _updateToolButtons() {
1073
+ for (const [name, button] of Object.entries(this._toolButtons)) {
1074
+ if (!button) continue;
1075
+ button.toggleAttribute('data-active', !this._selectMode && this._tool === name);
1076
+ }
1077
+ if (this._selectModeBtn) this._selectModeBtn.toggleAttribute('data-active', this._selectMode);
1078
+ }
1079
+
1080
+ /* Crosshair to draw or to start a rectangle, the move cursor once the pointer is over one. */
1081
+ _updateCursor(overSelection = false) {
1082
+ this._pixelCanvas.style.cursor = this._selectMode && overSelection ? 'move' : 'crosshair';
1083
+ }
1084
+
1085
+ _inSelection(x, y) {
1086
+ const sel = this._selection;
1087
+ return !!sel && x >= sel.x && y >= sel.y && x < sel.x + sel.w && y < sel.y + sel.h;
603
1088
  }
604
1089
 
605
1090
  // set full RGBA brush color (alpha optional)
@@ -710,30 +1195,110 @@ class ObjectLayerEngineElement extends HTMLElement {
710
1195
  }
711
1196
 
712
1197
  _onPointerDown(evt) {
1198
+ if (evt.button === 2) return; // the context menu owns the right button
1199
+ this._closeContextMenu();
713
1200
  evt.preventDefault();
714
1201
  this._isPointerDown = true;
715
1202
  try {
716
1203
  this._pixelCanvas.setPointerCapture(evt.pointerId);
717
1204
  } catch (e) {}
718
1205
  const [x, y] = this._toGridCoords(evt);
1206
+
1207
+ // Select mode: a press inside the rectangle moves it, anywhere else starts a new one.
1208
+ // Neither opens an undo transaction here — the marquee changes no cell, and a move opens
1209
+ // one when it commits.
1210
+ if (this._selectMode) {
1211
+ if (this._inSelection(x, y)) this._beginMove(x, y);
1212
+ else {
1213
+ this._dropFloating();
1214
+ this._selAnchor = [x, y];
1215
+ this.setSelection(x, y, x, y);
1216
+ }
1217
+ return;
1218
+ }
719
1219
  // start transaction for continuous stroke
720
1220
  this._beginTransaction();
721
1221
  this._applyToolAt(x, y, evt);
722
1222
  }
1223
+
723
1224
  _onPointerMove(evt) {
724
- if (!this._isPointerDown) return;
725
1225
  const [x, y] = this._toGridCoords(evt);
1226
+ this._updateCursorInfo(x, y);
1227
+ if (!this._isPointerDown) {
1228
+ // Hover feedback: the cursor says whether the next press moves or selects.
1229
+ if (this._selectMode) this._updateCursor(this._inSelection(x, y));
1230
+ return;
1231
+ }
1232
+ if (this._selectMode) {
1233
+ if (this._moveGrab) this._dragMove(x, y);
1234
+ else if (this._selAnchor) this.setSelection(this._selAnchor[0], this._selAnchor[1], x, y);
1235
+ return;
1236
+ }
726
1237
  this._applyToolAt(x, y, evt, true);
727
1238
  }
1239
+
728
1240
  _onPointerUp(evt) {
1241
+ const wasSelecting = this._isPointerDown && this._selectMode;
729
1242
  this._isPointerDown = false;
730
1243
  try {
731
1244
  this._pixelCanvas.releasePointerCapture(evt.pointerId);
732
1245
  } catch (e) {}
1246
+ if (wasSelecting) {
1247
+ // A finished rectangle hands straight over to moving: the next drag inside it moves the
1248
+ // cells, with no tool to switch to first.
1249
+ this._selAnchor = null;
1250
+ if (this._moveGrab) this._endMove();
1251
+ this._updateSelectionUI();
1252
+ this._updateCursor(true);
1253
+ return;
1254
+ }
733
1255
  // finish transaction for the stroke
734
1256
  this._endTransaction();
735
1257
  }
736
1258
 
1259
+ // ---------------- Moving a selection ----------------
1260
+ /* Lifts the selected cells out of the matrix so what they covered shows through while they
1261
+ * travel. They ride in _floating until the drop writes them back. */
1262
+ _beginMove(x, y) {
1263
+ if (!this._selection) return;
1264
+ if (!this._floating) {
1265
+ this._floating = this._readRegion(this._selection);
1266
+ const { x: sx, y: sy, w, h } = this._selection;
1267
+ for (let ry = 0; ry < h; ry++) for (let rx = 0; rx < w; rx++) this._matrix[sy + ry][sx + rx] = [0, 0, 0, 0];
1268
+ }
1269
+ this._moveGrab = { dx: x - this._selection.x, dy: y - this._selection.y };
1270
+ this._updateCursor(true);
1271
+ this.render();
1272
+ }
1273
+
1274
+ _dragMove(x, y) {
1275
+ if (!this._moveGrab || !this._selection) return;
1276
+ this._selection = { ...this._selection, x: x - this._moveGrab.dx, y: y - this._moveGrab.dy };
1277
+ this._updateSelectionUI();
1278
+ this.render();
1279
+ this._renderGrid();
1280
+ }
1281
+
1282
+ _endMove() {
1283
+ this._moveGrab = null;
1284
+ if (!this._floating) return;
1285
+ // Committing is one undo step: the lift and every drag frame collapse into the drop.
1286
+ this._beginTransaction();
1287
+ this._writeRegion(this._floating, this._selection.x, this._selection.y);
1288
+ this._floating = null;
1289
+ this.render();
1290
+ this._endTransaction();
1291
+ this._clampSelection();
1292
+ this.dispatchEvent(new CustomEvent('move', { detail: this.getSelection() }));
1293
+ }
1294
+
1295
+ /* Puts travelling cells back where they currently sit, for anything that ends a move without
1296
+ * a pointer release — leaving select mode, reselecting, a new rectangle. */
1297
+ _dropFloating() {
1298
+ if (this._floating) this._endMove();
1299
+ this._moveGrab = null;
1300
+ }
1301
+
737
1302
  _applyToolAt(x, y, evt, continuous = false) {
738
1303
  if (!this._inBounds(x, y)) return;
739
1304
  switch (this._tool) {
@@ -753,9 +1318,310 @@ class ObjectLayerEngineElement extends HTMLElement {
753
1318
  }
754
1319
  }
755
1320
 
1321
+ // ---------------- Selection ----------------
1322
+ /* Cells the marquee covers, clamped to the canvas, or null. */
1323
+ getSelection() {
1324
+ return this._selection ? { ...this._selection } : null;
1325
+ }
1326
+
1327
+ /* Takes two corners in cell coordinates and keeps whatever part lands on the canvas. */
1328
+ setSelection(ax, ay, bx, by) {
1329
+ const x0 = Math.max(0, Math.min(this._width - 1, Math.min(ax, bx)));
1330
+ const y0 = Math.max(0, Math.min(this._height - 1, Math.min(ay, by)));
1331
+ const x1 = Math.max(0, Math.min(this._width - 1, Math.max(ax, bx)));
1332
+ const y1 = Math.max(0, Math.min(this._height - 1, Math.max(ay, by)));
1333
+ this._selection = { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 };
1334
+ this._updateSelectionUI();
1335
+ this._startAnts();
1336
+ this._renderGrid();
1337
+ this.dispatchEvent(new CustomEvent('selectionchange', { detail: this.getSelection() }));
1338
+ return this.getSelection();
1339
+ }
1340
+
1341
+ selectAll() {
1342
+ return this.setSelection(0, 0, this._width - 1, this._height - 1);
1343
+ }
1344
+
1345
+ clearSelection() {
1346
+ if (!this._selection) return;
1347
+ this._selection = null;
1348
+ this._updateSelectionUI();
1349
+ this._renderGrid();
1350
+ this.dispatchEvent(new CustomEvent('selectionchange', { detail: null }));
1351
+ }
1352
+
1353
+ /* Keeps the marquee on the canvas after the canvas itself changed shape. */
1354
+ _clampSelection() {
1355
+ if (!this._selection) return;
1356
+ const { x, y, w, h } = this._selection;
1357
+ if (x >= this._width || y >= this._height) return this.clearSelection();
1358
+ this._selection = {
1359
+ x,
1360
+ y,
1361
+ w: Math.min(w, this._width - x),
1362
+ h: Math.min(h, this._height - y),
1363
+ };
1364
+ this._updateSelectionUI();
1365
+ }
1366
+
1367
+ _updateSelectionUI() {
1368
+ const sel = this._selection;
1369
+ if (this._selInfo) this._selInfo.textContent = sel ? `${sel.x},${sel.y} ${sel.w}×${sel.h}` : 'no selection';
1370
+ // The hint tracks the one thing the canvas will do next, so the handover to moving is stated
1371
+ // rather than left to be discovered.
1372
+ if (this._selHint)
1373
+ this._selHint.textContent = sel ? 'Drag inside to move · right-click for actions' : 'Drag to select';
1374
+ if (this._copyBtn) this._copyBtn.disabled = !sel;
1375
+ if (this._cutBtn) this._cutBtn.disabled = !sel;
1376
+ if (this._deleteBtn) this._deleteBtn.disabled = !sel;
1377
+ if (this._reselectBtn) this._reselectBtn.disabled = !sel;
1378
+ if (this._pasteBtn) this._pasteBtn.disabled = !this._clipboard;
1379
+ // The rescale inputs always show what Apply would act on: the selection, else the whole image.
1380
+ if (this._scaleWidthInput) this._scaleWidthInput.value = String(sel ? sel.w : this._width);
1381
+ if (this._scaleHeightInput) this._scaleHeightInput.value = String(sel ? sel.h : this._height);
1382
+ }
1383
+
1384
+ // ---------------- Context menu ----------------
1385
+ /* Right-click over the canvas, in select mode, opens the clipboard actions where the pointer
1386
+ * is. Outside select mode the browser menu is left alone. */
1387
+ _onContextMenu(evt) {
1388
+ if (!this._selectMode) return;
1389
+ evt.preventDefault();
1390
+ const rect = this._pixelCanvas.getBoundingClientRect();
1391
+ const [x, y] = this._toGridCoords(evt);
1392
+ // A right-click outside the rectangle moves it there first, so the menu always acts on
1393
+ // what was just pointed at.
1394
+ if (!this._inSelection(x, y) && !this._floating) this.setSelection(x, y, x, y);
1395
+ this._openContextMenu(evt.clientX - rect.left, evt.clientY - rect.top);
1396
+ }
1397
+
1398
+ _openContextMenu(left, top) {
1399
+ if (!this._ctxMenu) return;
1400
+ const sel = !!this._selection;
1401
+ const items = [
1402
+ { label: 'Copy', icon: 'copy', key: 'Ctrl+C', enabled: sel, run: () => this.copySelection() },
1403
+ { label: 'Cut', icon: 'cut', key: 'Ctrl+X', enabled: sel, run: () => this.cutSelection() },
1404
+ { label: 'Paste', icon: 'paste', key: 'Ctrl+V', enabled: !!this._clipboard, run: () => this.paste() },
1405
+ { label: 'Delete', icon: 'trash', key: 'Del', enabled: sel, run: () => this.deleteSelection() },
1406
+ { label: 'Select all', icon: 'selectAll', key: 'Ctrl+A', enabled: true, run: () => this.selectAll() },
1407
+ { label: 'Reselect', icon: 'marquee', key: 'Esc', enabled: sel, run: () => this.reselect() },
1408
+ ];
1409
+ this._ctxMenu.innerHTML = items
1410
+ .map(
1411
+ (item, index) => html`
1412
+ <button class="ctx-item" type="button" data-index="${index}" ${item.enabled ? '' : 'disabled'}>
1413
+ <svg viewBox="0 0 24 24" aria-hidden="true">${ICONS[item.icon]}</svg>
1414
+ <span>${item.label}</span><span class="ctx-key">${item.key}</span>
1415
+ </button>
1416
+ `,
1417
+ )
1418
+ .join('');
1419
+ this._ctxMenu.onclick = (event) => {
1420
+ const button = event.target.closest('button[data-index]');
1421
+ if (!button || button.disabled) return;
1422
+ this._closeContextMenu();
1423
+ items[Number(button.dataset.index)].run();
1424
+ };
1425
+ this._ctxMenu.hidden = false;
1426
+ // Placed after unhiding, so the measured size is the real one and the menu stays on canvas.
1427
+ const frame = this._ctxMenu.parentElement.getBoundingClientRect();
1428
+ const box = this._ctxMenu.getBoundingClientRect();
1429
+ this._ctxMenu.style.left = `${Math.max(0, Math.min(left, frame.width - box.width))}px`;
1430
+ this._ctxMenu.style.top = `${Math.max(0, Math.min(top, frame.height - box.height))}px`;
1431
+ }
1432
+
1433
+ _closeContextMenu() {
1434
+ if (this._ctxMenu && !this._ctxMenu.hidden) this._ctxMenu.hidden = true;
1435
+ }
1436
+
1437
+ /* Any press that is not on the menu dismisses it, including one in the page outside. */
1438
+ _onDocumentPointerDown(evt) {
1439
+ if (!this._ctxMenu || this._ctxMenu.hidden) return;
1440
+ if (evt.composedPath().includes(this._ctxMenu)) return;
1441
+ this._closeContextMenu();
1442
+ }
1443
+
1444
+ // ---------------- Clipboard ----------------
1445
+ _readRegion({ x, y, w, h }) {
1446
+ const out = this._createEmptyMatrix(w, h);
1447
+ for (let ry = 0; ry < h; ry++)
1448
+ for (let rx = 0; rx < w; rx++) {
1449
+ const cell = this._matrix[y + ry] && this._matrix[y + ry][x + rx];
1450
+ out[ry][rx] = cell ? cell.slice() : [0, 0, 0, 0];
1451
+ }
1452
+ return out;
1453
+ }
1454
+
1455
+ /* Writes a matrix with its top-left at (x, y), dropping whatever falls off the canvas. */
1456
+ _writeRegion(src, x, y) {
1457
+ for (let ry = 0; ry < src.length; ry++)
1458
+ for (let rx = 0; rx < src[ry].length; rx++) {
1459
+ const tx = x + rx;
1460
+ const ty = y + ry;
1461
+ if (this._inBounds(tx, ty)) this._matrix[ty][tx] = src[ry][rx].slice();
1462
+ }
1463
+ }
1464
+
1465
+ copySelection() {
1466
+ if (!this._selection) return null;
1467
+ this._clipboard = this._readRegion(this._selection);
1468
+ this._updateSelectionUI();
1469
+ this.dispatchEvent(new CustomEvent('copy', { detail: { width: this._selection.w, height: this._selection.h } }));
1470
+ return this._clipboard.map((r) => r.map((c) => c.slice()));
1471
+ }
1472
+
1473
+ cutSelection() {
1474
+ const copied = this.copySelection();
1475
+ if (copied) this.deleteSelection();
1476
+ return copied;
1477
+ }
1478
+
1479
+ /* Clears the selected cells to fully transparent. */
1480
+ deleteSelection() {
1481
+ if (!this._selection) return;
1482
+ this._beginTransaction();
1483
+ const { x, y, w, h } = this._selection;
1484
+ for (let ry = 0; ry < h; ry++) for (let rx = 0; rx < w; rx++) this._matrix[y + ry][x + rx] = [0, 0, 0, 0];
1485
+ this.render();
1486
+ this._endTransaction();
1487
+ this.dispatchEvent(new CustomEvent('delete', { detail: { width: w, height: h } }));
1488
+ }
1489
+
1490
+ /* Pastes at the selection corner, or at `at`, and selects what landed — so the next rescale
1491
+ * or move acts on the pasted cells without hunting for them. */
1492
+ paste(at = null) {
1493
+ if (!this._clipboard) return null;
1494
+ // Pasted cells arrive selected, so select mode comes on with them and they can be moved.
1495
+ this.setSelectMode(true);
1496
+ const x = at ? Math.floor(at.x) : this._selection ? this._selection.x : 0;
1497
+ const y = at ? Math.floor(at.y) : this._selection ? this._selection.y : 0;
1498
+ this._beginTransaction();
1499
+ this._writeRegion(this._clipboard, x, y);
1500
+ this.render();
1501
+ this._endTransaction();
1502
+ const w = this._clipboard[0].length;
1503
+ const h = this._clipboard.length;
1504
+ this.setSelection(x, y, x + w - 1, y + h - 1);
1505
+ this.dispatchEvent(new CustomEvent('paste', { detail: { x, y, width: w, height: h } }));
1506
+ return this.getSelection();
1507
+ }
1508
+
1509
+ // ---------------- Resampling ----------------
1510
+ /* A destination cell keeps content when at least this much of what it covers is opaque. A
1511
+ * quarter is one source cell under a 2x reduction, so a one-cell detail survives being halved
1512
+ * instead of being averaged away. */
1513
+ static RESAMPLE_COVERAGE_FLOOR = 0.25;
1514
+
1515
+ /**
1516
+ * Resamples a matrix to a new cell size, keeping as much of the original as the new size allows.
1517
+ *
1518
+ * Every output cell is a colour the source actually used — never a blend. Growing is
1519
+ * nearest-neighbour, exact at integer factors. Shrinking gives each destination cell the
1520
+ * heaviest colour among the source cells it covers, weighted by alpha so transparent cells
1521
+ * never outvote drawn ones, and clears the cell only when its coverage falls under
1522
+ * {@link RESAMPLE_COVERAGE_FLOOR}. Edges stay hard and the palette the atlas pipeline indexes
1523
+ * stays exactly as authored.
1524
+ *
1525
+ * @param {Array<Array<number[]>>} src - Source matrix of [r,g,b,a] cells.
1526
+ * @param {number} newW - Target width in cells.
1527
+ * @param {number} newH - Target height in cells.
1528
+ * @returns {Array<Array<number[]>>} The resampled matrix.
1529
+ */
1530
+ resampleMatrix(src, newW, newH) {
1531
+ const h = src.length;
1532
+ const w = src[0].length;
1533
+ const outW = Math.max(1, Math.floor(newW));
1534
+ const outH = Math.max(1, Math.floor(newH));
1535
+ const out = this._createEmptyMatrix(outW, outH);
1536
+
1537
+ if (outW >= w && outH >= h) {
1538
+ for (let y = 0; y < outH; y++)
1539
+ for (let x = 0; x < outW; x++)
1540
+ out[y][x] =
1541
+ src[Math.min(h - 1, Math.floor((y * h) / outH))][Math.min(w - 1, Math.floor((x * w) / outW))].slice();
1542
+ return out;
1543
+ }
1544
+
1545
+ const floor = ObjectLayerEngineElement.RESAMPLE_COVERAGE_FLOOR;
1546
+ for (let y = 0; y < outH; y++) {
1547
+ const sy0 = Math.floor((y * h) / outH);
1548
+ const sy1 = Math.min(h, Math.max(sy0 + 1, Math.ceil(((y + 1) * h) / outH)));
1549
+ for (let x = 0; x < outW; x++) {
1550
+ const sx0 = Math.floor((x * w) / outW);
1551
+ const sx1 = Math.min(w, Math.max(sx0 + 1, Math.ceil(((x + 1) * w) / outW)));
1552
+ // Insertion order breaks ties, so an equal split resolves the same way every run.
1553
+ const weights = new Map();
1554
+ let alphaSum = 0;
1555
+ let cells = 0;
1556
+ let best = null;
1557
+ let bestWeight = 0;
1558
+ for (let sy = sy0; sy < sy1; sy++)
1559
+ for (let sx = sx0; sx < sx1; sx++) {
1560
+ const c = src[sy][sx];
1561
+ alphaSum += c[3];
1562
+ cells++;
1563
+ if (c[3] === 0) continue;
1564
+ const key = (c[0] << 24) | (c[1] << 16) | (c[2] << 8) | c[3];
1565
+ const weight = (weights.get(key) || 0) + c[3];
1566
+ weights.set(key, weight);
1567
+ if (weight > bestWeight) {
1568
+ bestWeight = weight;
1569
+ best = c;
1570
+ }
1571
+ }
1572
+ const coverage = cells > 0 ? alphaSum / (cells * 255) : 0;
1573
+ out[y][x] = best && coverage >= floor ? best.slice() : [0, 0, 0, 0];
1574
+ }
1575
+ }
1576
+ return out;
1577
+ }
1578
+
1579
+ /**
1580
+ * Resamples the selection in place, or the whole image when nothing is selected.
1581
+ *
1582
+ * A resized selection keeps its top-left corner: the old cells clear and the new ones land
1583
+ * there, clipped to the canvas, and the marquee follows what actually fits.
1584
+ *
1585
+ * @param {number} newW - Target width in cells.
1586
+ * @param {number} newH - Target height in cells.
1587
+ */
1588
+ rescale(newW, newH) {
1589
+ const w = Math.max(1, Math.floor(newW));
1590
+ const h = Math.max(1, Math.floor(newH));
1591
+ if (!this._selection) {
1592
+ if (w === this._width && h === this._height) return;
1593
+ this._beginTransaction();
1594
+ this._matrix = this.resampleMatrix(this._matrix, w, h);
1595
+ this._setDimensions(w, h);
1596
+ this._setupContextsAndSize();
1597
+ this.render();
1598
+ this._endTransaction();
1599
+ this._updateSelectionUI();
1600
+ this.dispatchEvent(new CustomEvent('rescale', { detail: { scope: 'image', width: w, height: h } }));
1601
+ return;
1602
+ }
1603
+
1604
+ const sel = this._selection;
1605
+ if (w === sel.w && h === sel.h) return;
1606
+ this._beginTransaction();
1607
+ const resampled = this.resampleMatrix(this._readRegion(sel), w, h);
1608
+ for (let ry = 0; ry < sel.h; ry++)
1609
+ for (let rx = 0; rx < sel.w; rx++) this._matrix[sel.y + ry][sel.x + rx] = [0, 0, 0, 0];
1610
+ this._writeRegion(resampled, sel.x, sel.y);
1611
+ this.render();
1612
+ this._endTransaction();
1613
+ this.setSelection(
1614
+ sel.x,
1615
+ sel.y,
1616
+ sel.x + Math.min(w, this._width - sel.x) - 1,
1617
+ sel.y + Math.min(h, this._height - sel.y) - 1,
1618
+ );
1619
+ this.dispatchEvent(new CustomEvent('rescale', { detail: { scope: 'selection', width: w, height: h } }));
1620
+ }
1621
+
756
1622
  // ---------------- Import / Export ----------------
757
1623
  exportMatrixJSON() {
758
- return JSON.stringify({ width: this._width, height: this._height, matrix: this._matrix });
1624
+ return ObjectLayerEngineElement.matrixJSON(this._matrix);
759
1625
  }
760
1626
  importMatrixJSON(json) {
761
1627
  const data = typeof json === 'string' ? JSON.parse(json) : json;
@@ -815,9 +1681,15 @@ class ObjectLayerEngineElement extends HTMLElement {
815
1681
 
816
1682
  // Async blob version (recommended for large images)
817
1683
  toBlob(scale = this._pixelSize) {
1684
+ return ObjectLayerEngineElement.matrixToBlob(this._matrix, scale);
1685
+ }
1686
+
1687
+ /* A PNG of any matrix at `scale` px per cell, with no editor involved: a frame can be
1688
+ * pictured from stored data without passing through — or racing — the canvas. */
1689
+ static matrixToBlob(matrix, scale) {
818
1690
  return new Promise((resolve) => {
819
- const w = this._width,
820
- h = this._height;
1691
+ const h = matrix.length;
1692
+ const w = matrix[0].length;
821
1693
  const outW = Math.max(1, Math.floor(w * scale));
822
1694
  const outH = Math.max(1, Math.floor(h * scale));
823
1695
  const src = document.createElement('canvas');
@@ -829,11 +1701,11 @@ class ObjectLayerEngineElement extends HTMLElement {
829
1701
  let p = 0;
830
1702
  for (let y = 0; y < h; y++)
831
1703
  for (let x = 0; x < w; x++) {
832
- const c = this._matrix[y][x] || [0, 0, 0, 0];
833
- data[p++] = this._clampInt(c[0]);
834
- data[p++] = this._clampInt(c[1]);
835
- data[p++] = this._clampInt(c[2]);
836
- data[p++] = this._clampInt(c[3]);
1704
+ const c = matrix[y][x] || [0, 0, 0, 0];
1705
+ data[p++] = Math.min(255, Math.max(0, Math.floor(c[0]) || 0));
1706
+ data[p++] = Math.min(255, Math.max(0, Math.floor(c[1]) || 0));
1707
+ data[p++] = Math.min(255, Math.max(0, Math.floor(c[2]) || 0));
1708
+ data[p++] = Math.min(255, Math.max(0, Math.floor(c[3]) || 0));
837
1709
  }
838
1710
  sctx.putImageData(img, 0, 0);
839
1711
  const out = document.createElement('canvas');
@@ -848,6 +1720,11 @@ class ObjectLayerEngineElement extends HTMLElement {
848
1720
  });
849
1721
  }
850
1722
 
1723
+ /* The frame JSON the editor exports and imports, for any matrix. */
1724
+ static matrixJSON(matrix) {
1725
+ return JSON.stringify({ width: matrix[0].length, height: matrix.length, matrix });
1726
+ }
1727
+
851
1728
  // Trigger download of PNG (uses blob to avoid huge data URLs on big exports)
852
1729
  async exportPNG(filename = 'object-layer.png', scale = this._pixelSize) {
853
1730
  const blob = await this.toBlob(scale);
@@ -871,13 +1748,10 @@ class ObjectLayerEngineElement extends HTMLElement {
871
1748
 
872
1749
  _loadSnapshot(snap) {
873
1750
  if (!snap) return;
874
- this._width = snap.width;
875
- this._height = snap.height;
876
1751
  this._matrix = snap.matrix.map((r) => r.map((c) => c.slice()));
877
- if (this._widthInput) this._widthInput.value = String(this._width);
878
- if (this._heightInput) this._heightInput.value = String(this._height);
879
- this.setAttribute('width', String(this._width));
880
- this.setAttribute('height', String(this._height));
1752
+ this._setDimensions(snap.width, snap.height);
1753
+ this._clampSelection();
1754
+ this._updateSelectionUI();
881
1755
  this._setupContextsAndSize();
882
1756
  this.render();
883
1757
  this.dispatchEvent(new CustomEvent('matrixload', { detail: { width: this._width, height: this._height } }));
@@ -953,9 +1827,31 @@ class ObjectLayerEngineElement extends HTMLElement {
953
1827
  if (this._redoBtn) this._redoBtn.disabled = this._redoStack.length === 0;
954
1828
  }
955
1829
 
1830
+ /* True while a field has focus, where these keys belong to the field rather than the canvas. */
1831
+ _typingInField() {
1832
+ const active = this.shadowRoot.activeElement || document.activeElement;
1833
+ return !!active && /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName);
1834
+ }
1835
+
956
1836
  _onKeyDown(e) {
1837
+ if (this._typingInField()) return;
957
1838
  const meta = e.ctrlKey || e.metaKey;
958
- if (!meta) return;
1839
+ if (!meta) {
1840
+ if (e.key === 'Escape') {
1841
+ if (!this._ctxMenu || this._ctxMenu.hidden) {
1842
+ if (!this._selection) return;
1843
+ e.preventDefault();
1844
+ this.reselect();
1845
+ return;
1846
+ }
1847
+ e.preventDefault();
1848
+ this._closeContextMenu();
1849
+ } else if ((e.key === 'Delete' || e.key === 'Backspace') && this._selection) {
1850
+ e.preventDefault();
1851
+ this.deleteSelection();
1852
+ }
1853
+ return;
1854
+ }
959
1855
  // ctrl/cmd+z -> undo, ctrl/cmd+shift+z or ctrl+ y -> redo
960
1856
  if (e.key === 'z' || e.key === 'Z') {
961
1857
  if (e.shiftKey) {
@@ -968,6 +1864,22 @@ class ObjectLayerEngineElement extends HTMLElement {
968
1864
  } else if (e.key === 'y' || e.key === 'Y') {
969
1865
  e.preventDefault();
970
1866
  this.redo();
1867
+ } else if (e.key === 'a' || e.key === 'A') {
1868
+ e.preventDefault();
1869
+ this.setSelectMode(true);
1870
+ this.selectAll();
1871
+ } else if (e.key === 'c' || e.key === 'C') {
1872
+ if (!this._selection) return;
1873
+ e.preventDefault();
1874
+ this.copySelection();
1875
+ } else if (e.key === 'x' || e.key === 'X') {
1876
+ if (!this._selection) return;
1877
+ e.preventDefault();
1878
+ this.cutSelection();
1879
+ } else if (e.key === 'v' || e.key === 'V') {
1880
+ if (!this._clipboard) return;
1881
+ e.preventDefault();
1882
+ this.paste();
971
1883
  }
972
1884
  }
973
1885
 
@@ -1063,15 +1975,11 @@ class ObjectLayerEngineElement extends HTMLElement {
1063
1975
  newMat[newY][newX] = px;
1064
1976
  }
1065
1977
  }
1066
- this._width = newW;
1067
- this._height = newH;
1068
1978
  this._matrix = newMat;
1069
- // keep inputs/attributes in sync
1070
- if (this._widthInput) this._widthInput.value = String(this._width);
1071
- if (this._heightInput) this._heightInput.value = String(this._height);
1072
- this.setAttribute('width', String(this._width));
1073
- this.setAttribute('height', String(this._height));
1979
+ this._setDimensions(newW, newH);
1074
1980
 
1981
+ this.clearSelection();
1982
+ this._updateSelectionUI();
1075
1983
  this._setupContextsAndSize();
1076
1984
  this.render();
1077
1985
  this.dispatchEvent(
@@ -1094,14 +2002,11 @@ class ObjectLayerEngineElement extends HTMLElement {
1094
2002
  newMat[newY][newX] = px;
1095
2003
  }
1096
2004
  }
1097
- this._width = newW;
1098
- this._height = newH;
1099
2005
  this._matrix = newMat;
1100
- if (this._widthInput) this._widthInput.value = String(this._width);
1101
- if (this._heightInput) this._heightInput.value = String(this._height);
1102
- this.setAttribute('width', String(this._width));
1103
- this.setAttribute('height', String(this._height));
2006
+ this._setDimensions(newW, newH);
1104
2007
 
2008
+ this.clearSelection();
2009
+ this._updateSelectionUI();
1105
2010
  this._setupContextsAndSize();
1106
2011
  this.render();
1107
2012
  this.dispatchEvent(
@@ -1114,19 +2019,26 @@ class ObjectLayerEngineElement extends HTMLElement {
1114
2019
  return this._width;
1115
2020
  }
1116
2021
  set width(v) {
1117
- this._width = Math.max(1, Math.floor(v));
1118
- this.setAttribute('width', String(this._width));
1119
- this._setupContextsAndSize();
1120
- this.render();
2022
+ this._resizeAxis(v, this._attributeSize('height', this._height));
1121
2023
  }
1122
2024
  get height() {
1123
2025
  return this._height;
1124
2026
  }
1125
2027
  set height(v) {
1126
- this._height = Math.max(1, Math.floor(v));
1127
- this.setAttribute('height', String(this._height));
1128
- this._setupContextsAndSize();
1129
- this.render();
2028
+ this._resizeAxis(this._attributeSize('width', this._width), v);
2029
+ }
2030
+
2031
+ /* An axis as its attribute states it. While the element upgrades, the attributes are all
2032
+ * present before any setter has run, so the axis not being set is read from here rather than
2033
+ * from a default the resize would otherwise write over the attribute. */
2034
+ _attributeSize(name, fallback) {
2035
+ return parseInt(this.getAttribute(name), 10) || fallback;
2036
+ }
2037
+
2038
+ _resizeAxis(w, h) {
2039
+ const nw = Math.max(1, Math.floor(w));
2040
+ const nh = Math.max(1, Math.floor(h));
2041
+ if (nw !== this._width || nh !== this._height) this.resize(nw, nh);
1130
2042
  }
1131
2043
  get pixelSize() {
1132
2044
  return this._pixelSize;
@@ -1157,6 +2069,8 @@ class ObjectLayerEngineElement extends HTMLElement {
1157
2069
 
1158
2070
  customElements.define('object-layer-engine', ObjectLayerEngineElement);
1159
2071
 
2072
+ export { ObjectLayerEngineElement };
2073
+
1160
2074
  /*
1161
2075
  Example usage:
1162
2076
  <object-layer-engine id="ole" width="20" height="12" pixel-size="20"></object-layer-engine>