Add keyboard shortcut UX and command palette interactions
@ -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)
|
||||
|
||||
161
frontend/app.js
@ -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", () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 228 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
@ -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\)/);
|
||||
|
||||