Add a "Make substep of…" option to the step context menu
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m39s

Lets a step be re-parented under another step by entering that step's
displayed number, moving it (with its own substeps) to the end of the
target's substeps and updating the numbering accordingly.
This commit is contained in:
2026-06-13 20:23:31 -05:00
parent be88434e44
commit a58607f029
+61
View File
@@ -731,6 +731,7 @@ 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: '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) },
@@ -1289,6 +1290,66 @@ class GuideEditor {
this.onToast(parent ? 'Substep added.' : 'Step added.'); this.onToast(parent ? 'Substep added.' : 'Step added.');
} }
/** All step ids whose ancestor chain leads back to `stepId`. */
getStepDescendantIds(stepId) {
const result = [];
const queue = [stepId];
while (queue.length) {
const current = queue.shift();
for (const s of this.steps) {
if (s.parentStepId === current) {
result.push(s.stepId);
queue.push(s.stepId);
}
}
}
return result;
}
async makeSubstepOf(stepId) {
const step = this.stepMap.get(stepId);
if (!step) 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)) {
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}.`);
return;
}
step.parentStepId = targetId;
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;
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}.`);
}
async duplicateSelectedStep() { async duplicateSelectedStep() {
const step = this.currentStep; const step = this.currentStep;
if (!step) return; if (!step) return;