Show "Make substep of…" as a hover submenu listing all steps
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().
This commit is contained in:
+22
-22
@@ -731,7 +731,18 @@ class GuideEditor {
|
|||||||
this.selectStep(step.stepId);
|
this.selectStep(step.stepId);
|
||||||
contextMenu(e.clientX, e.clientY, [
|
contextMenu(e.clientX, e.clientY, [
|
||||||
{ label: 'Add substep', action: () => this.addSubstep(step.stepId) },
|
{ 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() },
|
{ label: 'Duplicate step', action: () => this.duplicateSelectedStep() },
|
||||||
'sep',
|
'sep',
|
||||||
{ label: 'Move up', action: () => this.moveSelectedStep(-1) },
|
{ label: 'Move up', action: () => this.moveSelectedStep(-1) },
|
||||||
@@ -1306,48 +1317,37 @@ class GuideEditor {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async makeSubstepOf(stepId) {
|
async makeSubstepOf(stepId, targetStepId) {
|
||||||
const step = this.stepMap.get(stepId);
|
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 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)]);
|
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 });
|
this.onToast('A step cannot be made a substep of itself or one of its own substeps.', { error: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (step.parentStepId === targetId) {
|
if (step.parentStepId === targetStepId) {
|
||||||
this.onToast(`“${step.title || 'Untitled step'}” is already a substep of step ${target}.`);
|
this.onToast(`“${step.title || 'Untitled step'}” is already a substep of step ${numbers.get(targetStepId)}.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
step.parentStepId = targetId;
|
step.parentStepId = targetStepId;
|
||||||
await api.step.save({ guideId: this.guideId, step });
|
await api.step.save({ guideId: this.guideId, step });
|
||||||
|
|
||||||
// Move the step (with its own substeps) to sit right after the target
|
// 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.
|
// step's existing substeps, so it becomes the target's last substep.
|
||||||
const order = this.steps.map((s) => s.stepId);
|
const order = this.steps.map((s) => s.stepId);
|
||||||
const remaining = order.filter((id) => !subtreeIds.has(id));
|
const remaining = order.filter((id) => !subtreeIds.has(id));
|
||||||
const targetSubtree = new Set([targetId, ...this.getStepDescendantIds(targetId)]);
|
const targetSubtree = new Set([targetStepId, ...this.getStepDescendantIds(targetStepId)]);
|
||||||
let insertAt = remaining.indexOf(targetId) + 1;
|
let insertAt = remaining.indexOf(targetStepId) + 1;
|
||||||
while (insertAt < remaining.length && targetSubtree.has(remaining[insertAt])) insertAt++;
|
while (insertAt < remaining.length && targetSubtree.has(remaining[insertAt])) insertAt++;
|
||||||
const movedBlock = order.filter((id) => subtreeIds.has(id));
|
const movedBlock = order.filter((id) => subtreeIds.has(id));
|
||||||
remaining.splice(insertAt, 0, ...movedBlock);
|
remaining.splice(insertAt, 0, ...movedBlock);
|
||||||
await api.step.reorder({ guideId: this.guideId, order: remaining });
|
await api.step.reorder({ guideId: this.guideId, order: remaining });
|
||||||
|
|
||||||
await this.reload(stepId);
|
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() {
|
async duplicateSelectedStep() {
|
||||||
|
|||||||
@@ -731,6 +731,25 @@ fieldset legend {
|
|||||||
}
|
}
|
||||||
.ctx-menu .mi:hover { background: var(--panel-2); }
|
.ctx-menu .mi:hover { background: var(--panel-2); }
|
||||||
.ctx-menu .mi.danger { color: var(--danger); }
|
.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 {
|
.ctx-menu hr {
|
||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
|
|||||||
+44
-4
@@ -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) {
|
function contextMenu(x, y, items) {
|
||||||
document.querySelectorAll('.ctx-menu').forEach((n) => n.remove());
|
document.querySelectorAll('.ctx-menu').forEach((n) => n.remove());
|
||||||
const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } });
|
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) {
|
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', {
|
menu.append(el('div.mi', {
|
||||||
className: `mi${item.danger ? ' danger' : ''}`,
|
className: `mi${item.danger ? ' danger' : ''}`,
|
||||||
onClick: () => { menu.remove(); item.action(); },
|
onMouseEnter: closeSubmenu,
|
||||||
|
onClick: () => { closeAll(); item.action(); },
|
||||||
}, item.label));
|
}, item.label));
|
||||||
}
|
}
|
||||||
document.body.append(menu);
|
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.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`;
|
||||||
if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`;
|
if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.addEventListener('click', () => menu.remove(), { once: true });
|
document.addEventListener('click', () => closeAll(), { once: true });
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user