Add keyboard shortcut UX and command palette interactions

This commit is contained in:
Rbanh 2026-02-18 22:15:55 -05:00
parent 72ea3609bb
commit e2445980f9
11 changed files with 267 additions and 2 deletions

View File

@ -165,9 +165,11 @@ Tools:
- Selected panel editors for component properties, full pin properties, full symbol body/pin editing, and net connect/disconnect operations
- Click diagnostics to jump/flash focused net/component/pin
- Auto Layout and Auto Tidy actions
- `Shortcuts` helper modal and `Ctrl/Cmd+K` command palette quick actions
- Keyboard shortcuts:
- `Ctrl/Cmd+Z` undo
- `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo
- `Ctrl/Cmd+K` open command palette
- `Space` rotate selected components (or pan when no selection)
- `F` focus current selection
- `Alt+Enter` apply current selection editor (component/pin/net)

View File

@ -45,7 +45,8 @@ const state = {
historyFuture: [],
historyLimit: 80,
historyRestoring: false,
symbolMigrationAckHash: null
symbolMigrationAckHash: null,
commandIndex: 0
};
const el = {
@ -140,6 +141,7 @@ const el = {
copyReproBtn: document.getElementById("copyReproBtn"),
autoLayoutBtn: document.getElementById("autoLayoutBtn"),
autoTidyBtn: document.getElementById("autoTidyBtn"),
shortcutsBtn: document.getElementById("shortcutsBtn"),
undoBtn: document.getElementById("undoBtn"),
redoBtn: document.getElementById("redoBtn"),
renderModeSelect: document.getElementById("renderModeSelect"),
@ -150,7 +152,13 @@ const el = {
schemaViewer: document.getElementById("schemaViewer"),
closeSchemaBtn: document.getElementById("closeSchemaBtn"),
copySchemaBtn: document.getElementById("copySchemaBtn"),
downloadSchemaBtn: document.getElementById("downloadSchemaBtn")
downloadSchemaBtn: document.getElementById("downloadSchemaBtn"),
shortcutsModal: document.getElementById("shortcutsModal"),
closeShortcutsBtn: document.getElementById("closeShortcutsBtn"),
commandModal: document.getElementById("commandModal"),
closeCommandBtn: document.getElementById("closeCommandBtn"),
commandInput: document.getElementById("commandInput"),
commandList: document.getElementById("commandList")
};
function toGrid(v) {
@ -2139,6 +2147,77 @@ function closeSchemaModal() {
el.schemaModal.classList.add("hidden");
}
function openShortcutsModal() {
el.shortcutsModal?.classList.remove("hidden");
el.closeShortcutsBtn?.focus();
}
function closeShortcutsModal() {
el.shortcutsModal?.classList.add("hidden");
}
function commandEntries() {
return [
{ id: "auto-layout", label: "Run Auto Layout", run: () => el.autoLayoutBtn.click() },
{ id: "auto-tidy", label: "Run Auto Tidy", run: () => el.autoTidyBtn.click() },
{ id: "fit-view", label: "Fit View", run: () => el.fitViewBtn.click() },
{ id: "focus-selection", label: "Focus Selection", run: () => el.focusSelectionBtn.click() },
{ id: "toggle-labels", label: "Toggle Net Labels", run: () => el.showLabelsInput.click() },
{ id: "reset-sample", label: "Reset Sample", run: () => el.resetSampleBtn.click() },
{ id: "load-sample", label: "Load Sample", run: () => el.loadSampleBtn.click() },
{ id: "new-project", label: "New Project", run: () => el.newProjectBtn.click() },
{ id: "show-shortcuts", label: "Show Keyboard Shortcuts", run: () => openShortcutsModal() }
];
}
function filteredCommands(query) {
const q = String(query ?? "")
.trim()
.toLowerCase();
const cmds = commandEntries();
if (!q) {
return cmds;
}
return cmds.filter((c) => c.label.toLowerCase().includes(q) || c.id.includes(q.replace(/\s+/g, "-")));
}
function renderCommandList() {
const cmds = filteredCommands(el.commandInput?.value ?? "");
state.commandIndex = Math.max(0, Math.min(state.commandIndex, Math.max(0, cmds.length - 1)));
if (!cmds.length) {
el.commandList.innerHTML = `<div class="miniRow"><span>No commands.</span></div>`;
return cmds;
}
el.commandList.innerHTML = cmds
.map(
(cmd, idx) =>
`<button type="button" role="option" class="commandRow ${idx === state.commandIndex ? "active" : ""}" data-command-id="${cmd.id}">${escHtml(cmd.label)}</button>`
)
.join("");
return cmds;
}
function openCommandModal() {
state.commandIndex = 0;
el.commandModal?.classList.remove("hidden");
renderCommandList();
el.commandInput?.focus();
el.commandInput?.select();
}
function closeCommandModal() {
el.commandModal?.classList.add("hidden");
}
function runCommandById(id) {
const cmd = commandEntries().find((c) => c.id === id);
if (!cmd) {
return;
}
closeCommandModal();
cmd.run();
}
function buildMinimalRepro(model) {
if (!state.selectedRefs.length && !state.selectedNet) {
return model;
@ -3299,6 +3378,25 @@ function setupEvents() {
return;
}
if (mod && evt.key.toLowerCase() === "k") {
evt.preventDefault();
if (el.commandModal?.classList.contains("hidden")) {
openCommandModal();
} else {
closeCommandModal();
}
return;
}
if (!mod && evt.shiftKey && evt.key === "?") {
if (isTypingContext(evt.target)) {
return;
}
evt.preventDefault();
openShortcutsModal();
return;
}
if (!mod && !evt.altKey && !evt.shiftKey && evt.key.toLowerCase() === "f") {
if (isTypingContext(evt.target)) {
return;
@ -3323,6 +3421,14 @@ function setupEvents() {
}
}
if (evt.code === "Escape") {
if (!el.commandModal.classList.contains("hidden")) {
closeCommandModal();
return;
}
if (!el.shortcutsModal.classList.contains("hidden")) {
closeShortcutsModal();
return;
}
if (!el.schemaModal.classList.contains("hidden")) {
closeSchemaModal();
return;
@ -3375,6 +3481,57 @@ function setupEvents() {
}
});
el.shortcutsBtn?.addEventListener("click", openShortcutsModal);
el.closeShortcutsBtn?.addEventListener("click", closeShortcutsModal);
el.shortcutsModal?.addEventListener("click", (evt) => {
if (evt.target === el.shortcutsModal) {
closeShortcutsModal();
}
});
el.closeCommandBtn?.addEventListener("click", closeCommandModal);
el.commandModal?.addEventListener("click", (evt) => {
if (evt.target === el.commandModal) {
closeCommandModal();
}
});
el.commandInput?.addEventListener("input", () => {
state.commandIndex = 0;
renderCommandList();
});
el.commandInput?.addEventListener("keydown", (evt) => {
const cmds = renderCommandList();
if (!cmds.length) {
return;
}
if (evt.key === "ArrowDown") {
evt.preventDefault();
state.commandIndex = Math.min(cmds.length - 1, state.commandIndex + 1);
renderCommandList();
return;
}
if (evt.key === "ArrowUp") {
evt.preventDefault();
state.commandIndex = Math.max(0, state.commandIndex - 1);
renderCommandList();
return;
}
if (evt.key === "Enter") {
evt.preventDefault();
const cmd = cmds[state.commandIndex];
if (cmd) {
runCommandById(cmd.id);
}
}
});
el.commandList?.addEventListener("click", (evt) => {
const row = evt.target.closest("[data-command-id]");
if (!row) {
return;
}
runCommandById(row.getAttribute("data-command-id"));
});
el.validateJsonBtn.addEventListener("click", validateJsonEditor);
el.formatJsonBtn.addEventListener("click", () => {

View File

@ -20,6 +20,7 @@
<button id="exportBtn" aria-label="Export Schemeta JSON file">Export JSON</button>
<button id="autoLayoutBtn" aria-label="Run automatic layout">Auto Layout</button>
<button id="autoTidyBtn" aria-label="Run automatic tidy layout">Auto Tidy</button>
<button id="shortcutsBtn" aria-label="Show keyboard shortcuts">Shortcuts</button>
<button id="undoBtn" title="Undo (Ctrl/Cmd+Z)">Undo</button>
<button id="redoBtn" title="Redo (Ctrl/Cmd+Shift+Z)">Redo</button>
<label class="inlineSelect">
@ -294,6 +295,37 @@
</div>
</div>
<div id="shortcutsModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="shortcutsTitle">
<div class="modalCard compactModal">
<div class="modalHead">
<h3 id="shortcutsTitle">Keyboard Shortcuts</h3>
<button id="closeShortcutsBtn">Close</button>
</div>
<div class="shortcutGrid">
<div><kbd>Ctrl/Cmd + Z</kbd><span>Undo</span></div>
<div><kbd>Ctrl/Cmd + Shift + Z</kbd><span>Redo</span></div>
<div><kbd>Ctrl/Cmd + K</kbd><span>Open command palette</span></div>
<div><kbd>F</kbd><span>Focus current selection</span></div>
<div><kbd>Space</kbd><span>Rotate selected component(s)</span></div>
<div><kbd>Space + Drag</kbd><span>Pan canvas</span></div>
<div><kbd>Alt + Enter</kbd><span>Apply selected editor</span></div>
<div><kbd>Alt + C</kbd><span>Connect selected pin to chosen net</span></div>
<div><kbd>Esc</kbd><span>Close modal / clear selection</span></div>
</div>
</div>
</div>
<div id="commandModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="commandTitle">
<div class="modalCard compactModal">
<div class="modalHead">
<h3 id="commandTitle">Command Palette</h3>
<button id="closeCommandBtn">Close</button>
</div>
<input id="commandInput" placeholder="Type a command..." aria-label="Command input" />
<div id="commandList" class="commandList" role="listbox" aria-label="Command results"></div>
</div>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>

View File

@ -612,6 +612,12 @@ textarea {
gap: 8px;
}
.compactModal {
width: min(760px, 100%);
height: auto;
max-height: min(86vh, 760px);
}
.modalHead {
display: flex;
align-items: center;
@ -635,6 +641,69 @@ textarea {
min-height: 0;
}
.shortcutGrid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
overflow: auto;
padding-right: 4px;
}
.shortcutGrid > div {
display: flex;
justify-content: space-between;
gap: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 8px 10px;
font-size: 0.86rem;
color: var(--ink-muted);
background: #f8fbff;
}
kbd {
display: inline-flex;
align-items: center;
border: 1px solid var(--line-strong);
border-bottom-width: 2px;
border-radius: 6px;
background: #fff;
padding: 2px 6px;
color: var(--ink);
font-size: 0.74rem;
font-family: "JetBrains Mono", monospace;
}
.commandList {
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow: auto;
max-height: 46vh;
background: #fff;
}
.commandRow {
width: 100%;
border: none;
border-bottom: 1px solid var(--line);
border-radius: 0;
background: #fff;
text-align: left;
box-shadow: none;
padding: 8px 10px;
color: var(--ink);
font-size: 0.84rem;
}
.commandRow:last-child {
border-bottom: none;
}
.commandRow:hover,
.commandRow.active {
background: #edf4ff;
}
.flash {
animation: flashPulse 0.7s ease-in-out 0s 2;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

View File

@ -145,6 +145,11 @@ async function run() {
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
await compareScene(page, "initial");
await page.keyboard.press("Control+k");
await page.getByRole("dialog", { name: "Command Palette" }).waitFor();
await page.locator("#commandInput").fill("fit");
await page.keyboard.press("Enter");
await page.waitForFunction(() => document.querySelector("#commandModal")?.classList.contains("hidden"));
await page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click();
await expectText(page, "#selectedSummary", /U2 \(dac_i2s\)/);