diff --git a/.github/workflows/publish-unsigned-preview.yml b/.github/workflows/publish-unsigned-preview.yml index 845fcc1..0c32535 100644 --- a/.github/workflows/publish-unsigned-preview.yml +++ b/.github/workflows/publish-unsigned-preview.yml @@ -7,12 +7,12 @@ on: description: New prerelease tag; an existing release is never overwritten required: true type: string - default: v0.5.0-beta.21 + default: v0.5.0-beta.22 preview_title: description: Public prerelease title required: true type: string - default: Parlyn Engine v0.5.0 Beta 21 (unsigned) + default: Parlyn Engine v0.5.0 Beta 22 (unsigned) permissions: contents: write @@ -24,8 +24,8 @@ jobs: env: CSC_IDENTITY_AUTO_DISCOVERY: false GH_TOKEN: ${{ github.token }} - PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.21' }} - PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 21 (unsigned)' }} + PREVIEW_TAG: ${{ inputs.preview_tag || 'v0.5.0-beta.22' }} + PREVIEW_TITLE: ${{ inputs.preview_title || 'Parlyn Engine v0.5.0 Beta 22 (unsigned)' }} steps: - name: Checkout @@ -55,7 +55,7 @@ jobs: throw "Expected exactly one Parlyn installer." } - $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.21-x64.zip" + $portable = "release/Parlyn-Engine-Portable-0.5.0-beta.22-x64.zip" if (-not (Test-Path -LiteralPath "release/win-unpacked/Parlyn Engine.exe")) { throw "Packaged Parlyn executable was not found for the portable build." } @@ -72,14 +72,14 @@ jobs: shell: pwsh run: | @" - # Parlyn Engine v0.5.0 Beta 21 - - This unsigned Windows beta adds optional Surface placement on Move - gizmo release. Meshes and billboards drop to a lower authored mesh - surface or the editor ground; one Undo reverses movement and placement. - It does not implement runtime physics, slope alignment or imported - terrain. Rotate, Scale, Inspector and Ground/End remain unchanged. - Beta 20 passed maintainer testing; Beta 21 awaits human acceptance. + # Parlyn Engine v0.5.0 Beta 22 + + This unsigned Windows beta completes the Phase 2 editor command and + keyboard audit. It centralizes shortcut routing, adds Delete selection + and provides a read-only shortcut reference under View. Text fields, + modal dialogs, key repeat and Alt/AltGr input are protected from + accidental editor commands. Beta 21 passed maintainer testing; Beta 22 + awaits human acceptance. The portable ZIP creates no Windows installation or uninstall entry and remains the preferred unsigned test path. @@ -96,7 +96,7 @@ jobs: other public certificate provider. Maintainer acceptance instructions: - https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md + https://github.com/WebCrew/Parlyn-Engine/blob/main/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md "@ | Set-Content -LiteralPath release/PREVIEW-NOTES.md -Encoding utf8 - name: Publish GitHub pre-release @@ -114,6 +114,6 @@ jobs: --notes-file release/PREVIEW-NOTES.md ` --prerelease ` "$($installer[0].FullName)" ` - "release/Parlyn-Engine-Portable-0.5.0-beta.21-x64.zip" ` + "release/Parlyn-Engine-Portable-0.5.0-beta.22-x64.zip" ` "release/latest.yml" ` "release/SHA256SUMS.txt" diff --git a/CHANGES.md b/CHANGES.md index de03896..002d298 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,12 @@ ## Unreleased — Foundation Stabilization +- Beta 22: centralized editor keyboard routing, added Delete selection and a + visible read-only shortcut reference under View. +- Protected text editing, modal dialogs, key repeat, composition and Alt/AltGr + input from accidental editor commands while preserving Ctrl+S save. +- Recorded successful Beta 21 human Windows acceptance. + - Beta 21: optional persistent Surface toggle places the moved primary node on a lower mesh support or editor ground when the Move gizmo is released. - Combined placement and movement into one Undo/Redo step; excluded self, diff --git a/README.md b/README.md index 6532472..9b55ca9 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,17 @@ # Parlyn Engine -Beta 21 adds an optional **Surface** toggle in the Placement group: on Move +Beta 22 completes the Phase 2 command/keyboard audit with centralized shortcut +routing, Delete selection and a visible reference under **View → Keyboard +Shortcuts**. Text entry, dialogs, key repeat and Alt/AltGr combinations are +protected from accidental editor commands. Beta 22 awaits human Windows +acceptance. + +Beta 21 added an optional **Surface** toggle in the Placement group: on Move release, a mesh or billboard drops onto the nearest suitable lower Mesh3D face or the existing editor ground. Movement plus placement is one Undo step. It does not affect Rotate, Scale or Inspector edits and is not physics/collision. -Beta 21 awaits human Windows acceptance; Beta 20 passed maintainer testing. +Beta 21 passed maintainer Windows acceptance on 2026-09-15. Beta 20 fixes saved per-scene Undo/Redo history after a scene switch. It retains Beta 19's optional scene/world bounds through **Bounds** in the View command @@ -150,6 +156,9 @@ This boundary is intentional: future renderer work should remain possible withou See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). +Current editor bindings and their context rules are documented in +[`docs/EDITOR-COMMANDS.md`](docs/EDITOR-COMMANDS.md). + ## 2.5D-first philosophy Parlyn does not define 2.5D as "fake 3D". It treats it as a flexible production approach. @@ -243,7 +252,7 @@ See [`docs/WINDOWS-DISTRIBUTION.md`](docs/WINDOWS-DISTRIBUTION.md) for signing, verification and the maintainer acceptance test. Phase 1 acceptance is complete. The current Phase 2 editor candidate uses the -repeatable [`v0.5.0-beta.21 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md). +repeatable [`v0.5.0-beta.22 maintainer checklist`](docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md). ### Windows release integrity diff --git a/docs/EDITOR-COMMANDS.md b/docs/EDITOR-COMMANDS.md new file mode 100644 index 0000000..47098ee --- /dev/null +++ b/docs/EDITOR-COMMANDS.md @@ -0,0 +1,49 @@ +# Editor Commands and Keyboard Behavior + +Parlyn routes buttons and keyboard shortcuts to the same editor command +functions. The current bindings are intentionally small and fixed; custom +profiles and rebinding are not part of the Phase 2 foundation. + +| Command | Shortcut | +| --- | --- | +| Select | Q / Esc | +| Move | W | +| Rotate | E | +| Scale | R | +| Frame Selected | F | +| Place on Ground | End | +| Delete Selection | Delete | +| Duplicate Node | Ctrl+D | +| Save Scene | Ctrl+S | +| Open Scene | Ctrl+O | +| Open Project | Ctrl+Shift+O | +| Undo | Ctrl+Z | +| Redo | Ctrl+Y / Ctrl+Shift+Z | + +## Context rules + +- Modal dialogs suspend editor shortcuts so commands cannot affect the scene + behind a dialog. Dialog controls retain their native keyboard behavior. +- Text fields retain typing, Delete and native field Undo/Redo. Ctrl+S commits + the active field and saves the scene. +- Key repeat, input-method composition and Alt/AltGr combinations do not invoke + editor commands. +- Unsupported modifier combinations do nothing; Parlyn does not silently map + them to a different command. + +## Reference review + +Godot, Unity and Unreal all establish discoverable, editor-wide shortcuts and +context-sensitive commands. Unity's official Shortcuts Manager documentation +explicitly separates global and contextual commands; Unreal exposes a keyboard +shortcut editor. Their mature profile/rebinding systems are useful future +references, but adding one during this audit would exceed the Phase 2 scope. + +Parlyn's current improvement is a smaller, predictable contract suited to the +present editor: a single tested resolver, visible bindings inside the editor and +explicit protection for text input and modal work. This is not a claim of broader +capability than those engines. + +- Unity: +- Unreal Engine: +- Godot editor documentation: diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md index 277b65d..42e16c8 100644 --- a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md +++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md @@ -1,7 +1,7 @@ # Parlyn Engine v0.5.0 Beta 21 — Maintainer Acceptance -Status: pending human Windows acceptance. Beta 20 passed the maintainer's -full Bounds/history re-test and additional editor regression tests on 2026-09-13. +Status: passed human Windows acceptance on 2026-09-15. Surface placement and +the wider editor regression scope completed without a reported defect. Scope: optional downward authoring placement on Move-gizmo release only. No collision/physics, slope alignment, Inspector snap or asset-import changes. diff --git a/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md new file mode 100644 index 0000000..406d51e --- /dev/null +++ b/docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md @@ -0,0 +1,35 @@ +# Parlyn Engine v0.5.0 Beta 22 — Maintainer Acceptance + +Status: pending human Windows acceptance. Beta 21 passed maintainer testing on +2026-09-15. + +Scope: final Phase 2 command and keyboard consistency audit. This release adds +Delete for the current selection, centralizes existing shortcuts and displays a +read-only shortcut reference. It does not add shortcut customization. + +1. Open **View → Keyboard Shortcuts**. Confirm that all 13 current commands and + keys are readable. Close it with its button and then reopen it and press Esc. +2. With no dialog or Inspector field active, verify Q/Esc, W, E and R switch to + Select, Move, Rotate and Scale and match the toolbar's active states. +3. Select one node and test F, End, Ctrl+D and Delete. Frame, Ground, Duplicate + and Delete must match their buttons. Undo and Redo must restore each change. +4. Multi-select two unrelated nodes and press Delete. Both must be removed as + one Undo step; Undo and Redo must restore/remove both together. +5. Verify Ctrl+S, Ctrl+O and Ctrl+Shift+O. They must match Save Scene, Open Scene + and Open Project, including unsaved-change protection. +6. Verify Ctrl+Z, Ctrl+Y and Ctrl+Shift+Z outside text fields. Both Redo variants + must perform the same Redo command. +7. Focus the Inspector name or a numeric field. Typing Q/W/E/R/F and pressing + Delete must edit the field without activating editor commands. Field-native + Ctrl+Z must not invoke scene Undo. Ctrl+S may commit the field and save. +8. Open Add Node, Bounds, Snap Settings or another modal dialog. Q/W/E/R/F, + Delete and editor Ctrl shortcuts must not affect the scene behind it. Esc + closes dialogs according to their existing behavior. +9. Hold a mapped key long enough to create keyboard repeat and try an Alt/AltGr + combination. A single physical command press must not repeat destructive + actions and Alt combinations must not trigger editor commands. +10. Repeat a short 2.5D/3D regression: selection, Move/Rotate/Scale, Snap, + Surface, Ground, Frame Selected, panels, Bounds, Save/Open and scene switch. + +The shortcut reference is descriptive only. User profiles, rebinding and a full +shortcut manager are not part of this Phase 2 audit. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 82cbfba..b0685dc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -65,11 +65,13 @@ Completion evidence: - optional Surface/Ground Snap and Grid Snap, including a keyboard command to place the current selection on the ground — initial Ground command and End shortcut complete; optional Move-release surface placement implemented in - Beta 21, human acceptance pending; + Beta 21, passed human acceptance on 2026-09-15; - Frame Selected — initial single- and multi-selection camera framing complete; - optional scene/world bounds independent from viewport size — initial validated world-space authoring guides passed human acceptance with Beta 20; -- consistent commands and keyboard behavior. +- consistent commands and keyboard behavior — centralized conflict-safe command + routing, Delete selection and a visible shortcut reference implemented in + Beta 22, human acceptance pending. Explicitly accepted Phase 2 workspace follow-ups (planned, not parallel work): diff --git a/docs/TESTING-v0.5.0.md b/docs/TESTING-v0.5.0.md index 580c7ec..66e8688 100644 --- a/docs/TESTING-v0.5.0.md +++ b/docs/TESTING-v0.5.0.md @@ -1,7 +1,7 @@ # Parlyn Engine v0.5.0 Test Guide For the current Phase 2 editor acceptance pass, use the shorter artifact-specific -[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md) +[`MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md`](MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md) The guide below remains the detailed feature reference. This test focuses on the two new foundations introduced in v0.5.0: viewport transform gizmos and the module lifecycle. diff --git a/package-lock.json b/package-lock.json index d95af0f..5a785db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.21", + "version": "0.5.0-beta.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "parlyn-engine", - "version": "0.5.0-beta.21", + "version": "0.5.0-beta.22", "license": "MIT", "dependencies": { "three": "0.185.1" diff --git a/package.json b/package.json index 1e9110d..a47d596 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parlyn-engine", - "version": "0.5.0-beta.21", + "version": "0.5.0-beta.22", "private": true, "description": "Parlyn Engine - open-source 2.5D-first game engine editor", "author": "Parlyn Engine contributors", diff --git a/scripts/check-desktop-boundaries.cjs b/scripts/check-desktop-boundaries.cjs index 8583fde..d1ddd22 100644 --- a/scripts/check-desktop-boundaries.cjs +++ b/scripts/check-desktop-boundaries.cjs @@ -74,6 +74,9 @@ const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableP assert.match(html, /id="tool-scale"[^>]+aria-label="Scale"/); assert.match(html, /id="place-on-ground"[^>]+aria-label="Place selection on ground"/); assert.match(html, /id="frame-selected"[^>]+aria-label="Frame Selected"[^>]+disabled/); + assert.match(html, /id="shortcuts-dialog"/); + assert.match(html, /id="show-shortcuts"/); + assert.match(html, /id="delete-node"[^>]+title="Delete selected nodes \(Delete\)"/); assert.match(html, /id="snap-translation"[^>]+min="0\.01"[^>]+max="100"/); assert.match(html, /id="snap-rotation"[^>]+min="1"[^>]+max="180"/); assert.match(html, /id="snap-scale"[^>]+min="0\.01"[^>]+max="10"/); @@ -122,9 +125,9 @@ const { resolveExistingProjectPath, resolveWritableProjectPath, resolveWritableP assert.match(renderer, /parlyn\.editor\.transform-space/); assert.match(renderer, /querySelector\("\.command-label"\)\.textContent/); assert.match(renderer, /placeSelectionOnGround/); - assert.match(renderer, /key === "end"/); assert.match(renderer, /renderer\.frameSelection\(\[\.\.\.selectedIds\]\)/); - assert.match(renderer, /key === "f"/); + assert.match(renderer, /resolveEditorShortcut\(event/); + assert.match(renderer, /dialog\[open\]/); assert.match(threeRenderer, /setTransformSpace\(space\)/); assert.match(threeRenderer, /this\.transformMode === 'scale' \? 'local' : this\.transformSpace/); assert.match(threeRenderer, /getGroundedPosition\(nodeId, groundY = -1\.55\)/); diff --git a/scripts/check-editor-shortcuts.mjs b/scripts/check-editor-shortcuts.mjs new file mode 100644 index 0000000..9b2e373 --- /dev/null +++ b/scripts/check-editor-shortcuts.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { EDITOR_SHORTCUTS, isTextEditingTarget, resolveEditorShortcut } from '../src/engine/editor/EditorShortcuts.mjs'; + +const key = (value, options = {}) => resolveEditorShortcut({ key:value, ...options }, options.context); + +for (const [pressed, command] of [['q','select'], ['Escape','select'], ['w','move'], ['e','rotate'], ['r','scale'], ['End','ground'], ['f','frame'], ['Delete','delete']]) { + assert.equal(key(pressed), command); +} +assert.equal(key('d', { ctrlKey:true }), 'duplicate'); +assert.equal(key('s', { ctrlKey:true }), 'save'); +assert.equal(key('o', { ctrlKey:true }), 'open-scene'); +assert.equal(key('o', { ctrlKey:true, shiftKey:true }), 'open-project'); +assert.equal(key('z', { ctrlKey:true }), 'undo'); +assert.equal(key('y', { ctrlKey:true }), 'redo'); +assert.equal(key('z', { ctrlKey:true, shiftKey:true }), 'redo'); +assert.equal(key('s', { metaKey:true }), 'save'); + +for (const blocked of [ + { key:'Delete', repeat:true }, + { key:'w', isComposing:true }, + { key:'w', altKey:true }, + { key:'f', shiftKey:true }, + { key:'s', ctrlKey:true, shiftKey:true }, + { key:'w', context:{ modalOpen:true } }, + { key:'Escape', context:{ modalOpen:true } }, + { key:'z', ctrlKey:true, context:{ editing:true } }, + { key:'Delete', context:{ editing:true } } +]) assert.equal(key(blocked.key, blocked), null); + +assert.equal(key('s', { ctrlKey:true, context:{ editing:true } }), 'save'); +assert.equal(isTextEditingTarget({ tagName:'INPUT' }), true); +assert.equal(isTextEditingTarget({ tagName:'DIV', isContentEditable:true }), true); +assert.equal(isTextEditingTarget({ tagName:'BUTTON' }), false); +assert.equal(new Set(EDITOR_SHORTCUTS.map((entry) => entry.command)).size, EDITOR_SHORTCUTS.length); +assert.equal(EDITOR_SHORTCUTS.length, 13); + +console.log('Editor shortcut routing check passed.'); diff --git a/scripts/check-windows-distribution.cjs b/scripts/check-windows-distribution.cjs index 40e31ae..9e36b2c 100644 --- a/scripts/check-windows-distribution.cjs +++ b/scripts/check-windows-distribution.cjs @@ -37,7 +37,7 @@ requireValue(workflow.includes('Verify signed Windows artifacts'), 'Signed outpu requireValue(workflow.includes('require_signing'), 'Windows workflow must distinguish signed and unsigned preflight builds.'); requireValue(!workflow.includes('BEGIN PRIVATE KEY'), 'Signing material must never be embedded in the workflow.'); requireValue(previewWorkflow.includes('Compress-Archive'), 'Unsigned previews must create a portable ZIP test artifact.'); -requireValue(previewWorkflow.includes('Parlyn-Engine-Portable-0.5.0-beta.21-x64.zip'), 'Portable preview artifact has an unexpected name.'); +requireValue(previewWorkflow.includes('Parlyn-Engine-Portable-0.5.0-beta.22-x64.zip'), 'Portable preview artifact has an unexpected name.'); requireValue(previewWorkflow.includes('release/win-unpacked/Parlyn Engine.exe'), 'Portable preview must verify its packaged executable.'); const gitignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8'); diff --git a/scripts/check.cjs b/scripts/check.cjs index cc39820..0c62f80 100644 --- a/scripts/check.cjs +++ b/scripts/check.cjs @@ -67,7 +67,11 @@ const required = [ 'scripts/check-scene-history-files.mjs', 'scripts/check-surface-placement.mjs', 'src/engine/editor/SurfacePlacement.mjs', + 'scripts/check-editor-shortcuts.mjs', + 'src/engine/editor/EditorShortcuts.mjs', 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.21.md', + 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.22.md', + 'docs/EDITOR-COMMANDS.md', 'src/main/sceneHistoryFiles.mjs', 'docs/MAINTAINER-ACCEPTANCE-v0.5.0-beta.20.md', 'src/engine/core/DocumentBounds.mjs', @@ -84,7 +88,7 @@ for (const rel of required) { const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); if (pkg.name !== 'parlyn-engine') throw new Error('Unexpected package name.'); -if (pkg.version !== '0.5.0-beta.21') throw new Error(`Unexpected package version: ${pkg.version}`); +if (pkg.version !== '0.5.0-beta.22') throw new Error(`Unexpected package version: ${pkg.version}`); function walk(dir) { return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { @@ -113,6 +117,7 @@ cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-frame-selected cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-document-bounds.mjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-scene-history-files.mjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-surface-placement.mjs')], { stdio: 'inherit' }); +cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-editor-shortcuts.mjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-asset-files.cjs')], { stdio: 'inherit' }); cp.execFileSync(process.execPath, [path.join(root, 'scripts/check-windows-distribution.cjs')], { stdio: 'inherit' }); diff --git a/src/engine/editor/EditorShortcuts.mjs b/src/engine/editor/EditorShortcuts.mjs new file mode 100644 index 0000000..a79328c --- /dev/null +++ b/src/engine/editor/EditorShortcuts.mjs @@ -0,0 +1,50 @@ +const TEXT_EDITING_TAGS = new Set(['input', 'select', 'textarea']); + +export const EDITOR_SHORTCUTS = Object.freeze([ + { command:'select', label:'Select', keys:'Q / Esc' }, + { command:'move', label:'Move', keys:'W' }, + { command:'rotate', label:'Rotate', keys:'E' }, + { command:'scale', label:'Scale', keys:'R' }, + { command:'frame', label:'Frame Selected', keys:'F' }, + { command:'ground', label:'Place on Ground', keys:'End' }, + { command:'delete', label:'Delete Selection', keys:'Delete' }, + { command:'duplicate', label:'Duplicate Node', keys:'Ctrl+D' }, + { command:'save', label:'Save Scene', keys:'Ctrl+S' }, + { command:'open-scene', label:'Open Scene', keys:'Ctrl+O' }, + { command:'open-project', label:'Open Project', keys:'Ctrl+Shift+O' }, + { command:'undo', label:'Undo', keys:'Ctrl+Z' }, + { command:'redo', label:'Redo', keys:'Ctrl+Y / Ctrl+Shift+Z' } +]); + +export function isTextEditingTarget(target) { + const tag = target?.tagName?.toLowerCase?.(); + return TEXT_EDITING_TAGS.has(tag) || target?.isContentEditable === true; +} + +export function resolveEditorShortcut(event, { editing = false, modalOpen = false } = {}) { + if (!event || event.defaultPrevented || event.repeat || event.isComposing || modalOpen || event.altKey) return null; + const key = String(event.key ?? '').toLowerCase(); + if (!key || key === 'process' || key === 'dead') return null; + const primary = Boolean(event.ctrlKey || event.metaKey); + + // Saving is the sole global editor command while a text field is active. + // Native field Undo/Redo/Delete behavior must remain available. + if (editing) return primary && !event.shiftKey && key === 's' ? 'save' : null; + + if (primary) { + if (key === 'o') return event.shiftKey ? 'open-project' : 'open-scene'; + if (event.shiftKey && key === 'z') return 'redo'; + if (event.shiftKey) return null; + if (key === 'd') return 'duplicate'; + if (key === 's') return 'save'; + if (key === 'z') return 'undo'; + if (key === 'y') return 'redo'; + return null; + } + + if (event.shiftKey) return null; + return ({ + escape:'select', q:'select', w:'move', e:'rotate', r:'scale', + end:'ground', f:'frame', delete:'delete' + })[key] ?? null; +} diff --git a/src/renderer/app.mjs b/src/renderer/app.mjs index 0e4205d..9f2e124 100644 --- a/src/renderer/app.mjs +++ b/src/renderer/app.mjs @@ -16,6 +16,7 @@ import { DEFAULT_TRANSFORM_SNAPPING, normalizeTransformSnapping } from "../engin import { normalizeTransformSpace } from "../engine/editor/TransformSpace.mjs"; import { normalizeDocumentBounds } from "../engine/core/DocumentBounds.mjs"; import { placeNodeOnSurface } from "../engine/editor/SurfacePlacement.mjs"; +import { EDITOR_SHORTCUTS, isTextEditingTarget, resolveEditorShortcut } from "../engine/editor/EditorShortcuts.mjs"; async function bootstrap() { const $ = (id) => document.getElementById(id); const status = $("status"); @@ -214,6 +215,10 @@ async function bootstrap() { closeViewMenu(); status.textContent = "Workspace layout reset to default."; }); + $("show-shortcuts").addEventListener("click", () => { + closeViewMenu(); + $("shortcuts-dialog").showModal(); + }); for (const resizer of document.querySelectorAll("[data-resize-panel]")) { resizer.addEventListener("pointerdown", (event) => beginPanelResize(event, resizer)); resizer.addEventListener("keydown", (event) => { @@ -226,6 +231,21 @@ async function bootstrap() { }); } } + function renderShortcutReference() { + const list = $("shortcut-list"); + list.replaceChildren(); + for (const shortcut of EDITOR_SHORTCUTS) { + const row = document.createElement("div"); + row.className = "shortcut-row"; + const label = document.createElement("span"); + label.textContent = shortcut.label; + const keys = document.createElement("kbd"); + keys.textContent = shortcut.keys; + row.append(label, keys); + list.appendChild(row); + } + } + renderShortcutReference(); function beginPanelResize(event, resizer) { if (event.button !== 0) return; const panel = resizer.dataset.resizePanel; @@ -1188,6 +1208,7 @@ async function bootstrap() { $("frame-selected").addEventListener("click", frameSelected); $("snap-settings").addEventListener("click", openTransformSnapSettings); $("cancel-snap-settings").addEventListener("click", () => $("snap-settings-dialog").close()); + $("close-shortcuts").addEventListener("click", () => $("shortcuts-dialog").close()); $("save-snap-settings").addEventListener("click", saveTransformSnapSettings); $("reset-snap-settings").addEventListener("click", resetTransformSnapSettings); $("modules").addEventListener("click", () => { @@ -1280,65 +1301,29 @@ async function bootstrap() { $("cancel-move-asset").addEventListener("click", () => $("move-asset-dialog").close()); $("confirm-move-asset").addEventListener("click", moveProjectAsset); window.addEventListener("keydown", (event) => { - const tag = document.activeElement?.tagName?.toLowerCase(); - const editing = tag === "input" || tag === "select" || tag === "textarea"; - const key = event.key.toLowerCase(); - if (!editing && !(event.ctrlKey || event.metaKey)) { - if (key === "escape" || key === "q") { - event.preventDefault(); - setTransformMode("select"); - return; - } - if (key === "w") { - event.preventDefault(); - setTransformMode("translate"); - return; - } - if (key === "e") { - event.preventDefault(); - setTransformMode("rotate"); - return; - } - if (key === "r") { - event.preventDefault(); - setTransformMode("scale"); - return; - } - if (key === "end") { - event.preventDefault(); - placeSelectionOnGround(); - return; - } - if (key === "f") { - event.preventDefault(); - frameSelected(); - return; - } - } - if (!(event.ctrlKey || event.metaKey)) return; - if (key === "d" && !editing) { - event.preventDefault(); - duplicateSelected(); - } - if (key === "s") { - event.preventDefault(); - saveScene(); - } - if (key === "o" && event.shiftKey) { - event.preventDefault(); - openProject(); - } else if (key === "o") { - event.preventDefault(); - openScene(); - } - if (key === "z" && !event.shiftKey) { - event.preventDefault(); - undo(); - } - if (key === "y" || key === "z" && event.shiftKey) { - event.preventDefault(); - redo(); - } + const editing = isTextEditingTarget(document.activeElement); + const command = resolveEditorShortcut(event, { + editing, + modalOpen:Boolean(document.querySelector("dialog[open]")) + }); + if (!command) return; + event.preventDefault(); + if (editing) document.activeElement?.blur?.(); + ({ + select:() => setTransformMode("select"), + move:() => setTransformMode("translate"), + rotate:() => setTransformMode("rotate"), + scale:() => setTransformMode("scale"), + ground:placeSelectionOnGround, + frame:frameSelected, + delete:deleteSelected, + duplicate:duplicateSelected, + save:saveScene, + "open-scene":openScene, + "open-project":openProject, + undo, + redo + })[command]?.(); }); const resizeObserver = new ResizeObserver(() => renderer.resize()); resizeObserver.observe($("viewport")); diff --git a/src/renderer/index.html b/src/renderer/index.html index b22a1b0..a43ce91 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -15,14 +15,14 @@