Mass guide editor changes #18
+22
-22
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user