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
|
- 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
|
- Click diagnostics to jump/flash focused net/component/pin
|
||||||
- Auto Layout and Auto Tidy actions
|
- Auto Layout and Auto Tidy actions
|
||||||
|
- `Shortcuts` helper modal and `Ctrl/Cmd+K` command palette quick actions
|
||||||
- Keyboard shortcuts:
|
- Keyboard shortcuts:
|
||||||
- `Ctrl/Cmd+Z` undo
|
- `Ctrl/Cmd+Z` undo
|
||||||
- `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo
|
- `Ctrl/Cmd+Shift+Z` or `Ctrl/Cmd+Y` redo
|
||||||
|
- `Ctrl/Cmd+K` open command palette
|
||||||
- `Space` rotate selected components (or pan when no selection)
|
- `Space` rotate selected components (or pan when no selection)
|
||||||
- `F` focus current selection
|
- `F` focus current selection
|
||||||
- `Alt+Enter` apply current selection editor (component/pin/net)
|
- `Alt+Enter` apply current selection editor (component/pin/net)
|
||||||
|
|||||||
161
frontend/app.js
@ -45,7 +45,8 @@ const state = {
|
|||||||
historyFuture: [],
|
historyFuture: [],
|
||||||
historyLimit: 80,
|
historyLimit: 80,
|
||||||
historyRestoring: false,
|
historyRestoring: false,
|
||||||
symbolMigrationAckHash: null
|
symbolMigrationAckHash: null,
|
||||||
|
commandIndex: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
const el = {
|
const el = {
|
||||||
@ -140,6 +141,7 @@ const el = {
|
|||||||
copyReproBtn: document.getElementById("copyReproBtn"),
|
copyReproBtn: document.getElementById("copyReproBtn"),
|
||||||
autoLayoutBtn: document.getElementById("autoLayoutBtn"),
|
autoLayoutBtn: document.getElementById("autoLayoutBtn"),
|
||||||
autoTidyBtn: document.getElementById("autoTidyBtn"),
|
autoTidyBtn: document.getElementById("autoTidyBtn"),
|
||||||
|
shortcutsBtn: document.getElementById("shortcutsBtn"),
|
||||||
undoBtn: document.getElementById("undoBtn"),
|
undoBtn: document.getElementById("undoBtn"),
|
||||||
redoBtn: document.getElementById("redoBtn"),
|
redoBtn: document.getElementById("redoBtn"),
|
||||||
renderModeSelect: document.getElementById("renderModeSelect"),
|
renderModeSelect: document.getElementById("renderModeSelect"),
|
||||||
@ -150,7 +152,13 @@ const el = {
|
|||||||
schemaViewer: document.getElementById("schemaViewer"),
|
schemaViewer: document.getElementById("schemaViewer"),
|
||||||
closeSchemaBtn: document.getElementById("closeSchemaBtn"),
|
closeSchemaBtn: document.getElementById("closeSchemaBtn"),
|
||||||
copySchemaBtn: document.getElementById("copySchemaBtn"),
|
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) {
|
function toGrid(v) {
|
||||||
@ -2139,6 +2147,77 @@ function closeSchemaModal() {
|
|||||||
el.schemaModal.classList.add("hidden");
|
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) {
|
function buildMinimalRepro(model) {
|
||||||
if (!state.selectedRefs.length && !state.selectedNet) {
|
if (!state.selectedRefs.length && !state.selectedNet) {
|
||||||
return model;
|
return model;
|
||||||
@ -3299,6 +3378,25 @@ function setupEvents() {
|
|||||||
return;
|
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 (!mod && !evt.altKey && !evt.shiftKey && evt.key.toLowerCase() === "f") {
|
||||||
if (isTypingContext(evt.target)) {
|
if (isTypingContext(evt.target)) {
|
||||||
return;
|
return;
|
||||||
@ -3323,6 +3421,14 @@ function setupEvents() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (evt.code === "Escape") {
|
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")) {
|
if (!el.schemaModal.classList.contains("hidden")) {
|
||||||
closeSchemaModal();
|
closeSchemaModal();
|
||||||
return;
|
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.validateJsonBtn.addEventListener("click", validateJsonEditor);
|
||||||
|
|
||||||
el.formatJsonBtn.addEventListener("click", () => {
|
el.formatJsonBtn.addEventListener("click", () => {
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
<button id="exportBtn" aria-label="Export Schemeta JSON file">Export JSON</button>
|
<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="autoLayoutBtn" aria-label="Run automatic layout">Auto Layout</button>
|
||||||
<button id="autoTidyBtn" aria-label="Run automatic tidy layout">Auto Tidy</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="undoBtn" title="Undo (Ctrl/Cmd+Z)">Undo</button>
|
||||||
<button id="redoBtn" title="Redo (Ctrl/Cmd+Shift+Z)">Redo</button>
|
<button id="redoBtn" title="Redo (Ctrl/Cmd+Shift+Z)">Redo</button>
|
||||||
<label class="inlineSelect">
|
<label class="inlineSelect">
|
||||||
@ -294,6 +295,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script type="module" src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -612,6 +612,12 @@ textarea {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compactModal {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
height: auto;
|
||||||
|
max-height: min(86vh, 760px);
|
||||||
|
}
|
||||||
|
|
||||||
.modalHead {
|
.modalHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -635,6 +641,69 @@ textarea {
|
|||||||
min-height: 0;
|
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 {
|
.flash {
|
||||||
animation: flashPulse 0.7s ease-in-out 0s 2;
|
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 page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
||||||
|
|
||||||
await compareScene(page, "initial");
|
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 page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click();
|
||||||
await expectText(page, "#selectedSummary", /U2 \(dac_i2s\)/);
|
await expectText(page, "#selectedSummary", /U2 \(dac_i2s\)/);
|
||||||
|
|||||||