Mass guide editor changes #18

Merged
Tyler merged 25 commits from guide_editor_changes into main 2026-06-15 15:37:48 +00:00
3 changed files with 85 additions and 26 deletions
Showing only changes of commit 8e2e1f41fb - Show all commits
+22 -22
View File
@@ -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() {
+19
View File
@@ -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
View File
@@ -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);
} }