From 8e2e1f41fb909c84fe8934f980ebd69f44e161f8 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 20:33:29 -0500 Subject: [PATCH] =?UTF-8?q?Show=20"Make=20substep=20of=E2=80=A6"=20as=20a?= =?UTF-8?q?=20hover=20submenu=20listing=20all=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the number-entry prompt with a scrollable side panel (so large guides don't overflow the screen) listing every eligible step; clicking one immediately re-parents the step. Context menus now support submenu items in general via contextMenu(). --- app/renderer/editor.js | 44 +++++++++++++++++++------------------- app/renderer/style.css | 19 +++++++++++++++++ app/renderer/util.js | 48 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 85 insertions(+), 26 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 8453e9b..b688d7b 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -731,7 +731,18 @@ class GuideEditor { this.selectStep(step.stepId); contextMenu(e.clientX, e.clientY, [ { label: 'Add substep', action: () => this.addSubstep(step.stepId) }, - { label: 'Make substep of…', action: () => this.makeSubstepOf(step.stepId) }, + { + label: 'Make substep of…', + submenu: () => { + const subtreeIds = new Set([step.stepId, ...this.getStepDescendantIds(step.stepId)]); + return this.steps + .filter((s) => !subtreeIds.has(s.stepId)) + .map((s) => ({ + label: `${numbers.get(s.stepId)} ${s.title || 'Untitled step'}`, + action: () => this.makeSubstepOf(step.stepId, s.stepId), + })); + }, + }, { label: 'Duplicate step', action: () => this.duplicateSelectedStep() }, 'sep', { label: 'Move up', action: () => this.moveSelectedStep(-1) }, @@ -1306,48 +1317,37 @@ class GuideEditor { return result; } - async makeSubstepOf(stepId) { + async makeSubstepOf(stepId, targetStepId) { const step = this.stepMap.get(stepId); - if (!step) return; + const target = this.stepMap.get(targetStepId); + if (!step || !target) return; const numbers = stepNumberMap(this.steps); - const input = await dialogs.promptText({ - title: 'Make substep', - label: 'Make substep of step #', - placeholder: 'e.g. 2', - }); - if (input == null) return; - const target = input.trim().replace(/^#/, ''); - const targetId = this.steps.find((s) => numbers.get(s.stepId) === target)?.stepId; - if (!targetId) { - this.onToast(`No step numbered "${target}".`, { error: true }); - return; - } const subtreeIds = new Set([stepId, ...this.getStepDescendantIds(stepId)]); - if (subtreeIds.has(targetId)) { + if (subtreeIds.has(targetStepId)) { this.onToast('A step cannot be made a substep of itself or one of its own substeps.', { error: true }); return; } - if (step.parentStepId === targetId) { - this.onToast(`“${step.title || 'Untitled step'}” is already a substep of step ${target}.`); + if (step.parentStepId === targetStepId) { + this.onToast(`“${step.title || 'Untitled step'}” is already a substep of step ${numbers.get(targetStepId)}.`); return; } - step.parentStepId = targetId; + step.parentStepId = targetStepId; await api.step.save({ guideId: this.guideId, step }); // Move the step (with its own substeps) to sit right after the target // step's existing substeps, so it becomes the target's last substep. const order = this.steps.map((s) => s.stepId); const remaining = order.filter((id) => !subtreeIds.has(id)); - const targetSubtree = new Set([targetId, ...this.getStepDescendantIds(targetId)]); - let insertAt = remaining.indexOf(targetId) + 1; + const targetSubtree = new Set([targetStepId, ...this.getStepDescendantIds(targetStepId)]); + let insertAt = remaining.indexOf(targetStepId) + 1; while (insertAt < remaining.length && targetSubtree.has(remaining[insertAt])) insertAt++; const movedBlock = order.filter((id) => subtreeIds.has(id)); remaining.splice(insertAt, 0, ...movedBlock); await api.step.reorder({ guideId: this.guideId, order: remaining }); await this.reload(stepId); - this.onToast(`“${step.title || 'Untitled step'}” is now a substep of step ${target}.`); + this.onToast(`“${step.title || 'Untitled step'}” is now a substep of step ${numbers.get(targetStepId)}.`); } async duplicateSelectedStep() { diff --git a/app/renderer/style.css b/app/renderer/style.css index 7d8febd..9f1c37e 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -731,6 +731,25 @@ fieldset legend { } .ctx-menu .mi:hover { background: var(--panel-2); } .ctx-menu .mi.danger { color: var(--danger); } +.ctx-menu .mi.disabled { + color: var(--muted); + cursor: default; +} +.ctx-menu .mi.disabled:hover { background: none; } +.ctx-menu .mi.has-submenu { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.ctx-menu .mi .submenu-arrow { + color: var(--muted); + font-size: 12px; +} +.ctx-submenu { + max-height: min(360px, calc(100vh - 24px)); + overflow-y: auto; +} .ctx-menu hr { margin: 6px 0; border: 0; diff --git a/app/renderer/util.js b/app/renderer/util.js index 3d553ef..c521fe7 100644 --- a/app/renderer/util.js +++ b/app/renderer/util.js @@ -119,15 +119,55 @@ function promptDialog(title, { value = '', label = 'Name' } = {}) { }); } -/** Context menu at (x, y); items: [{label, danger, action}] or 'sep'. */ +/** + * Context menu at (x, y). + * items: [{label, danger, action}] | [{label, submenu}] | 'sep'. + * `submenu` is an array (or a function returning one) of the same item + * shapes, shown in a scrollable panel beside the item on hover. + */ function contextMenu(x, y, items) { document.querySelectorAll('.ctx-menu').forEach((n) => n.remove()); const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } }); + let openSubmenu = null; + const closeSubmenu = () => { + if (openSubmenu) { openSubmenu.remove(); openSubmenu = null; } + }; + const closeAll = () => { closeSubmenu(); menu.remove(); }; for (const item of items) { - if (item === 'sep') { menu.append(el('hr')); continue; } + if (item === 'sep') { menu.append(el('hr', { onMouseEnter: closeSubmenu })); continue; } + if (item.submenu) { + const mi = el('div.mi.has-submenu', { + onMouseEnter: () => { + closeSubmenu(); + const subItems = typeof item.submenu === 'function' ? item.submenu() : item.submenu; + const sub = el('div.ctx-menu.ctx-submenu'); + if (!subItems.length) { + sub.append(el('div.mi.disabled', {}, 'Nothing else to choose')); + } + for (const subItem of subItems) { + if (subItem === 'sep') { sub.append(el('hr')); continue; } + sub.append(el('div.mi', { + className: `mi${subItem.danger ? ' danger' : ''}`, + onClick: () => { closeAll(); subItem.action(); }, + }, subItem.label)); + } + document.body.append(sub); + const miRect = mi.getBoundingClientRect(); + sub.style.left = `${miRect.right + 2}px`; + sub.style.top = `${miRect.top}px`; + const subRect = sub.getBoundingClientRect(); + if (subRect.right > innerWidth) sub.style.left = `${Math.max(6, miRect.left - subRect.width - 2)}px`; + if (subRect.bottom > innerHeight) sub.style.top = `${Math.max(6, innerHeight - subRect.height - 6)}px`; + openSubmenu = sub; + }, + }, el('span', {}, item.label), el('span.submenu-arrow', {}, '›')); + menu.append(mi); + continue; + } menu.append(el('div.mi', { className: `mi${item.danger ? ' danger' : ''}`, - onClick: () => { menu.remove(); item.action(); }, + onMouseEnter: closeSubmenu, + onClick: () => { closeAll(); item.action(); }, }, item.label)); } document.body.append(menu); @@ -135,7 +175,7 @@ function contextMenu(x, y, items) { if (rect.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`; if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`; setTimeout(() => { - document.addEventListener('click', () => menu.remove(), { once: true }); + document.addEventListener('click', () => closeAll(), { once: true }); }, 0); }