@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.
- package/.env.example +17 -5
- package/.github/workflows/coverall.cyberia.ci.yml +10 -0
- package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
- package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
- package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
- package/.github/workflows/engine-cyberia.ci.yml +1 -1
- package/.github/workflows/publish.cyberia.ci.yml +44 -0
- package/AGENTS.md +61 -12
- package/CHANGELOG.md +159 -161
- package/CLI-HELP.md +20 -13
- package/Dockerfile +1 -1
- package/Dockerfile.dev +1 -1
- package/Dockerfile.test +2 -2
- package/README.md +28 -28
- package/bin/build.js +96 -21
- package/bin/cyberia.js +1247 -1373
- package/bin/index.js +1247 -1373
- package/compose.env +17 -5
- package/conf.js +7 -4
- package/deploy/cyberia-client/deploy.sh +10 -11
- package/deploy/cyberia-client/package.sh +0 -1
- package/deploy/cyberia-client/state.sh +3 -2
- package/deploy/cyberia-server/deploy.sh +10 -11
- package/deploy/cyberia-server/package.sh +0 -1
- package/deploy/cyberia-server/state.sh +3 -2
- package/deploy/dd-cyberia/deploy.sh +5 -5
- package/deploy/dd-cyberia/init.sh +15 -14
- package/deploy/dd-cyberia/package.sh +0 -1
- package/deploy/dd-cyberia/state.sh +2 -2
- package/deploy/dd-cyberia/sync-deploy.sh +139 -75
- package/deploy/lib/config.sh +29 -0
- package/deploy/lib/github-actions-logging.sh +92 -76
- package/deploy/lib/host.sh +28 -19
- package/deploy/lib/state.sh +4 -1
- package/deploy/pwa-microservices-template/deploy.sh +10 -8
- package/deploy/release/deploy.sh +0 -2
- package/deployment.yaml +1 -1
- package/docker-compose.yml +23 -17
- package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1591 -0
- package/docs/coverage/cyberia/api/cyberia-instance/index.html +116 -0
- package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +4138 -0
- package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +116 -0
- package/docs/coverage/cyberia/api/object-layer/index.html +116 -0
- package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1393 -0
- package/docs/coverage/cyberia/base.css +362 -0
- package/docs/coverage/cyberia/block-navigation.js +82 -0
- package/docs/coverage/cyberia/favicon.png +0 -0
- package/docs/coverage/cyberia/index.html +161 -0
- package/docs/coverage/cyberia/prettify.css +101 -0
- package/docs/coverage/cyberia/prettify.js +937 -0
- package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1315 -0
- package/docs/coverage/cyberia/projects/cyberia/index.html +176 -0
- package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +715 -0
- package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +2182 -0
- package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +3265 -0
- package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +328 -0
- package/docs/coverage/cyberia/sort-arrow-sprite.png +0 -0
- package/docs/coverage/cyberia/sorter.js +205 -0
- package/hardhat/package-lock.json +2 -2
- package/hardhat/package.json +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
- package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/manifests/deployment/mongo-express/deployment.yaml +46 -27
- package/manifests/deployment/mongo-express/kustomization.yaml +9 -0
- package/manifests/deployment/mongo-express/mongo-express-nodeport.yaml +18 -0
- package/manifests/deployment/mongo-express/service.yaml +16 -0
- package/manifests/deployment/mongo-express-no-auth/disable-mongodb-auth.yaml +16 -0
- package/manifests/deployment/mongo-express-no-auth/kustomization.yaml +16 -0
- package/package.json +19 -17
- package/scripts/test-monitor.sh +3 -3
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.controller.js +14 -9
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.model.js +36 -5
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.router.js +5 -0
- package/src/api/atlas-sprite-sheet/atlas-sprite-sheet.service.js +88 -184
- package/src/api/cyberia-action/cyberia-action.router.js +2 -2
- package/src/api/cyberia-action/cyberia-action.service.js +3 -4
- package/src/api/cyberia-audio/cyberia-audio.controller.js +6 -0
- package/src/api/cyberia-audio/cyberia-audio.model.js +61 -0
- package/src/api/cyberia-audio/cyberia-audio.router.js +21 -0
- package/src/api/cyberia-audio/cyberia-audio.service.js +220 -0
- package/src/api/cyberia-client-hints/cyberia-client-hints.controller.js +1 -3
- package/src/api/cyberia-client-hints/cyberia-client-hints.model.js +10 -19
- package/src/api/cyberia-client-hints/cyberia-client-hints.router.js +12 -34
- package/src/api/cyberia-client-hints/cyberia-client-hints.service.js +15 -32
- package/src/api/cyberia-dialogue/cyberia-dialogue.model.js +1 -1
- package/src/api/cyberia-dialogue/cyberia-dialogue.router.js +1 -1
- package/src/api/cyberia-entity/cyberia-entity.model.js +2 -0
- package/src/api/cyberia-entity/cyberia-entity.service.js +1 -1
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.controller.js +8 -2
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.model.js +31 -20
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.router.js +16 -1
- package/src/api/cyberia-entity-type-default/cyberia-entity-type-default.service.js +466 -3
- package/src/api/cyberia-instance/cyberia-fallback-capture.js +87 -51
- package/src/api/cyberia-instance/cyberia-fallback-world.js +21 -40
- package/src/api/cyberia-instance/cyberia-instance-boot.service.js +5 -5
- package/src/api/cyberia-instance/cyberia-instance-items.js +98 -0
- package/src/api/cyberia-instance/cyberia-instance-map.service.js +34 -141
- package/src/api/cyberia-instance/cyberia-instance.controller.js +1 -4
- package/src/api/cyberia-instance/cyberia-instance.model.js +0 -14
- package/src/api/cyberia-instance/cyberia-instance.router.js +3 -16
- package/src/api/cyberia-instance/cyberia-instance.service.js +3 -43
- package/src/api/cyberia-instance/cyberia-world-generator.js +8 -13
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.model.js +49 -75
- package/src/api/cyberia-instance-conf/cyberia-instance-conf.service.js +74 -1
- package/src/api/cyberia-map/cyberia-map.service.js +6 -2
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.controller.js +31 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.model.js +56 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.router.js +33 -0
- package/src/api/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +189 -0
- package/src/api/cyberia-quest/cyberia-quest.router.js +3 -3
- package/src/api/cyberia-quest/cyberia-quest.service.js +3 -3
- package/src/api/cyberia-quest-progress/cyberia-quest-progress.model.js +1 -1
- package/src/api/cyberia-server-defaults/cyberia-server-defaults.js +403 -188
- package/src/api/cyberia-skill/cyberia-skill.model.js +6 -6
- package/src/api/file/file.ref.js +153 -0
- package/src/api/file/file.ref.json +11 -2
- package/src/api/object-layer/object-layer.model.js +47 -12
- package/src/api/object-layer/object-layer.router.js +10 -3
- package/src/api/object-layer/object-layer.service.js +113 -120
- package/src/cli/app.js +12 -1
- package/src/cli/client.js +2 -2
- package/src/cli/cluster.js +80 -18
- package/src/cli/deploy.js +215 -34
- package/src/cli/docker-compose.js +1 -1
- package/src/cli/fs-selection.js +115 -0
- package/src/cli/fs.js +158 -342
- package/src/cli/index.js +22 -10
- package/src/cli/release.js +3 -1
- package/src/cli/repository.js +35 -9
- package/src/cli/run.js +80 -199
- package/src/cli/test.js +4 -0
- package/src/cli/wireguard.js +32 -31
- package/src/client/components/core/Badge.js +0 -1
- package/src/client/components/core/CssCore.js +8 -1
- package/src/client/components/core/Docs.js +77 -100
- package/src/client/components/core/Modal.js +77 -23
- package/src/client/components/core/ToolTip.js +15 -0
- package/src/client/components/cryptokoyn/AppShellCryptokoyn.js +0 -2
- package/src/client/components/cyberia/ActionEngineCyberia.js +12 -36
- package/src/client/components/cyberia/EntityEngineCyberia.js +492 -121
- package/src/client/components/cyberia/InstanceEngineCyberia.js +129 -160
- package/src/client/components/cyberia/InstanceSelectionView.js +7 -1
- package/src/client/components/cyberia/MapEngineCyberia.js +175 -69
- package/src/client/components/cyberia/ObjectLayerEngine.js +1011 -97
- package/src/client/components/cyberia/ObjectLayerEngineModal.js +81 -155
- package/src/client/components/cyberia/ObjectLayerEngineViewer.js +113 -98
- package/src/client/components/cyberia/SharedDefaultsCyberia.js +215 -56
- package/src/client/components/cyberia-portal/AppShellCyberiaPortal.js +0 -34
- package/src/client/components/cyberia-portal/CssCyberiaPortal.js +1 -1
- package/src/client/components/cyberia-portal/MainBodyCyberiaPortal.js +712 -76
- package/src/client/components/cyberia-portal/RouterCyberiaPortal.js +0 -4
- package/src/client/components/cyberia-portal/TranslateCyberiaPortal.js +0 -4
- package/src/client/components/default/AppShellDefault.js +1 -4
- package/src/client/components/itemledger/AppShellItemledger.js +0 -2
- package/src/client/components/underpost/AppShellUnderpost.js +1 -1
- package/src/client/public/cyberia-docs/ARCHITECTURE.md +15 -15
- package/src/client/public/cyberia-docs/CYBERIA-CLI.md +280 -24
- package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +51 -29
- package/src/client/public/cyberia-docs/CYBERIA-SAGA.md +52 -53
- package/src/client/public/cyberia-docs/CYBERIA-SERVER.md +16 -11
- package/src/client/public/cyberia-docs/CYBERIA.md +28 -28
- package/src/client/public/cyberia-docs/ENTITY-PROFILE.md +11 -5
- package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
- package/src/client/public/cyberia-docs/STATS-PROGRESSION.md +217 -0
- package/src/client/public/cyberia-docs/WHITE-PAPER.md +20 -12
- package/src/client/services/cyberia-audio/cyberia-audio.service.js +99 -0
- package/src/client/services/cyberia-entity-type-default/cyberia-entity-type-default.service.js +38 -0
- package/src/client/services/cyberia-instance/cyberia-instance.management.js +5 -4
- package/src/client/services/cyberia-instance/cyberia-instance.service.js +0 -40
- package/src/client/services/cyberia-map/cyberia-map.management.js +5 -4
- package/src/client/services/cyberia-map-audio-conf/cyberia-map-audio-conf.service.js +138 -0
- package/src/client/services/object-layer/object-layer.management.js +6 -10
- package/src/client/services/object-layer/object-layer.service.js +3 -1
- package/src/client/ssr/views/Cyberia404.js +3 -3
- package/src/client-builder/client-build-docs.js +52 -52
- package/src/client-builder/client-build.js +67 -44
- package/src/client-builder/client-bundle.js +347 -0
- package/src/db/mongo/MongoExpress.js +187 -0
- package/src/grpc/cyberia/grpc-server.js +5 -7
- package/src/index.js +1 -1
- package/src/projects/cyberia/atlas-sprite-sheet-generator.js +143 -146
- package/src/projects/cyberia/atlas-sprite-sheet-store.js +391 -0
- package/src/projects/cyberia/besu-genesis-generator.js +5 -14
- package/src/projects/cyberia/boot-contract-fixtures.js +32 -0
- package/src/projects/cyberia/catalog-cyberia.js +3 -2
- package/src/projects/cyberia/gemini-client.js +3 -3
- package/src/projects/cyberia/generate-saga.js +4 -11
- package/src/projects/cyberia/hot-reload-trigger.js +17 -28
- package/src/projects/cyberia/instance-backup.js +210 -0
- package/src/projects/cyberia/instance-data.js +177 -257
- package/src/projects/cyberia/map-preview-generator.js +32 -68
- package/src/projects/cyberia/object-layer.js +152 -319
- package/src/projects/cyberia/seed-audio.js +139 -0
- package/src/projects/cyberia/semantic-layer-generator-skin.js +2 -8
- package/src/projects/cyberia/semantic-layer-generator.js +2 -18
- package/src/projects/cyberia/stat-balance.js +81 -0
- package/src/projects/cyberia/stat-commands.js +23 -0
- package/src/projects/cyberia/stat-contract-generator.js +101 -0
- package/src/runtime/cyberia-client/Dockerfile +8 -4
- package/src/runtime/cyberia-client/Dockerfile.dev +5 -2
- package/src/runtime/cyberia-server/Dockerfile +9 -2
- package/src/runtime/cyberia-server/Dockerfile.dev +8 -1
- package/src/runtime/engine-cyberia/Dockerfile +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
- package/src/runtime/engine-cyberia/Dockerfile.test +2 -2
- package/src/runtime/engine-cyberia/compose.env +17 -5
- package/src/runtime/engine-cyberia/docker-compose.yml +23 -17
- package/src/server/build/coverage.js +113 -42
- package/src/server/build/package.js +55 -12
- package/src/server/build/testing.js +59 -9
- package/src/server/network/middlewares.js +8 -2
- package/src/server/network/underpost-gateway.js +113 -14
- package/src/server/ops/logger.js +70 -35
- package/src/server/runtime/conf.js +40 -9
- package/src/server/runtime/start.js +11 -2
- package/src/server/storage/data-query.js +16 -0
- package/src/server/storage/downloader.js +62 -14
- package/src/server/storage/zip.js +153 -0
- package/test/integration/app/cyberia/atlas-sprite-sheet-store.test.js +402 -0
- package/test/integration/app/cyberia/atlas-sprite-sheet.test.js +86 -0
- package/test/integration/app/cyberia/cyberia-cli-plain-reads.test.js +34 -0
- package/test/integration/app/cyberia/cyberia-entity-type-default.test.js +842 -0
- package/test/integration/app/cyberia/cyberia-instance-conf-coerce.test.js +53 -0
- package/test/integration/app/cyberia/cyberia-instance-conf-defaults.test.js +16 -22
- package/test/integration/app/cyberia/cyberia-instance-items.test.js +90 -0
- package/test/integration/app/cyberia/cyberia-load.test.js +6 -7
- package/test/integration/app/cyberia/cyberia-map-audio-conf.test.js +256 -0
- package/test/integration/app/cyberia/cyberia-stats.test.js +150 -0
- package/test/integration/app/cyberia/fallback-world-capture.test.js +87 -2
- package/test/integration/app/cyberia/object-layer-item-selection.test.js +56 -0
- package/test/integration/app/cyberia/object-layer-natural-key.test.js +32 -0
- package/test/integration/app/cyberia/seed-audio.test.js +275 -0
- package/test/integration/infra/2-network/wireguard-cli.test.js +33 -9
- package/test/integration/infra/2-network/wireguard-edge.test.js +58 -18
- package/test/integration/infra/3-cluster/mongo-express-deploy.test.js +156 -0
- package/test/integration/infra/4-ingress/api-cross-origin.test.js +79 -0
- package/test/integration/infra/4-ingress/underpost-ingress.test.js +1 -1
- package/test/unit/catalog.test.js +13 -2
- package/test/unit/client-build-docs.test.js +97 -70
- package/test/unit/client-bundle.test.js +313 -0
- package/test/unit/conf-resolution.test.js +58 -7
- package/test/unit/coverage-artifact.test.js +120 -29
- package/test/unit/cyberia/instance-backup.test.js +183 -0
- package/test/unit/cyberia/instance-data.test.js +314 -0
- package/test/unit/cyberia/instance-object-layer-items.test.js +46 -0
- package/test/unit/cyberia/publish-workflow.test.js +22 -0
- package/test/unit/cyberia/stat-balance.test.js +69 -0
- package/test/unit/deploy-legacy-gateway-sweep.test.js +63 -0
- package/test/unit/deploy-log-table.test.js +76 -5
- package/test/unit/downloader-integrity.test.js +98 -0
- package/test/unit/file-reference-registry.test.js +141 -0
- package/test/unit/fs-storage-paths.test.js +596 -0
- package/test/unit/gateway-pod-fetch.test.js +95 -0
- package/test/unit/letsencrypt-issuer.test.js +101 -0
- package/test/unit/logger-redaction.test.js +29 -0
- package/test/unit/package.test.js +70 -16
- package/test/unit/prepare-host.test.js +84 -1
- package/test/unit/release-bump.test.js +3 -1
- package/test/unit/start-options.test.js +12 -0
- package/test/unit/test-tiers.test.js +27 -0
- package/test/unit/zip-archive.test.js +142 -0
- package/vitest.config.js +11 -3
- package/src/api/cyberia-instance/cyberia-fallback-default-items.js +0 -63
- 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
|
-
|
|
68
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
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.
|
|
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
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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.
|
|
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
|
|
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
|
|
820
|
-
|
|
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 =
|
|
833
|
-
data[p++] =
|
|
834
|
-
data[p++] =
|
|
835
|
-
data[p++] =
|
|
836
|
-
data[p++] =
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
this.
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
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>
|