Mass guide editor changes #18

Merged
Tyler merged 25 commits from guide_editor_changes into main 2026-06-15 15:37:48 +00:00
6 changed files with 156 additions and 21 deletions
Showing only changes of commit 58b4182c54 - Show all commits
+9
View File
@@ -419,6 +419,15 @@ function setupIpc() {
reindex(guideId);
return true;
});
h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => {
const images = {
original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null,
working: workingBase64 ? Buffer.from(workingBase64, 'base64') : null,
};
const restored = store.restoreStep(guideId, step, images, position);
reindex(guideId);
return restored;
});
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order));
h('step:imagePath', ({ guideId, stepId, which }) => {
const p = store.stepImagePath(guideId, stepId, which || 'working');
+1
View File
@@ -34,6 +34,7 @@ const api = {
add: invoke('step:add'),
save: invoke('step:save'),
delete: invoke('step:delete'),
restore: invoke('step:restore'),
reorder: invoke('steps:reorder'),
imagePath: invoke('step:imagePath'),
setWorkingImage: invoke('step:setWorkingImage'),
+1 -1
View File
@@ -279,7 +279,7 @@ class StepForgeApp {
}
send({ action: s.paused ? 'resume' : 'pause' });
},
}, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause');
}, s.paused ? 'Start recording' : 'Stop recording');
this.captureStatus.append(
el('span', { title: `Capture session — ${trigger}` }, `Rec - ${trigger}`),
+99 -20
View File
@@ -873,13 +873,21 @@ class GuideEditor {
if (!ids.length) return;
const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: 'Delete' });
if (!ok) return;
if (this.pendingSave) await this.flushStep();
const order = this.steps.map((s) => s.stepId);
const entries = [];
for (const stepId of ids) {
const step = this.stepMap.get(stepId);
if (step) entries.push(await this.snapshotStepForDeletion(step));
}
for (const stepId of ids) {
await api.step.delete({ guideId: this.guideId, stepId });
}
if (entries.length) this.pushCanvasHistory({ type: 'delete-step', steps: entries, order });
this.stepSelectMode = false;
this.selectedSteps = new Set();
await this.reload(null);
this.onToast(`${ids.length} step${ids.length === 1 ? '' : 's'} deleted.`);
this.onToast(`${ids.length} step${ids.length === 1 ? '' : 's'} deleted. Press Ctrl+Z to undo.`);
}
syncStepFields() {
@@ -1146,7 +1154,8 @@ class GuideEditor {
pushCanvasHistory(recordOrLabel = 'change') {
if (!this.currentStep) return;
const record = recordOrLabel && typeof recordOrLabel === 'object' && recordOrLabel.step
const record = recordOrLabel && typeof recordOrLabel === 'object'
&& (recordOrLabel.step || recordOrLabel.type === 'delete-step')
? recordOrLabel
: { step: clone(this.currentStep) };
this.canvasHistory.push(record);
@@ -1187,27 +1196,39 @@ class GuideEditor {
}
async undo() {
if (!this.currentStep) return;
if (!this.canvasHistory.length) {
this.onToast('Nothing to undo.');
return;
}
const previous = this.canvasHistory.pop();
if (previous.type === 'delete-step') {
await this.restoreDeletedSteps(previous);
this.canvasFuture.push(previous);
this.renderAll();
return;
}
if (!this.currentStep) return;
const current = await this.snapshotCurrentStep(true);
if (current) this.canvasFuture.push(current);
const previous = this.canvasHistory.pop();
await this.restoreHistoryRecord(previous);
this.renderAll();
}
async redo() {
if (!this.currentStep) return;
if (!this.canvasFuture.length) {
this.onToast('Nothing to redo.');
return;
}
const next = this.canvasFuture.pop();
if (next.type === 'delete-step') {
await this.deleteStepsAgain(next);
this.canvasHistory.push(next);
this.renderAll();
return;
}
if (!this.currentStep) return;
const current = await this.snapshotCurrentStep(true);
if (current) this.canvasHistory.push(current);
const next = this.canvasFuture.pop();
await this.restoreHistoryRecord(next);
this.renderAll();
}
@@ -1434,12 +1455,16 @@ class GuideEditor {
if (!step) return;
const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' });
if (!ok) return;
if (this.pendingSave) await this.flushStep();
const order = this.steps.map((s) => s.stepId);
const entry = await this.snapshotStepForDeletion(step);
await api.step.delete({ guideId: this.guideId, stepId: step.stepId });
this.pushCanvasHistory({ type: 'delete-step', steps: [entry], order });
const next = this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) + 1]
|| this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1]
|| null;
await this.reload(next && next.stepId);
this.onToast('Step deleted.');
this.onToast('Step deleted. Press Ctrl+Z to undo.');
}
async moveSelectedStep(delta) {
@@ -1479,18 +1504,18 @@ class GuideEditor {
async openCaptureMenu(event) {
const rect = event.target.getBoundingClientRect();
const session = (await api.capture.state())?.active;
contextMenu(rect.left, rect.bottom + 4, [
const items = [
{ label: 'Capture full screen', action: () => this.captureStep('fullscreen') },
{ label: 'Capture window', action: () => this.captureStep('window') },
{ label: 'Capture region…', action: () => this.captureStep('region') },
'sep',
{ label: 'Paste image as step', action: () => this.pasteClipboardStep() },
{ label: 'Import images…', action: () => this.importImageSteps() },
'sep',
session
? { label: 'Finish capture session', action: () => this.finishCaptureSession() }
: { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() },
]);
];
if (!session) {
items.push('sep', { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() });
}
contextMenu(rect.left, rect.bottom + 4, items);
}
async pasteClipboardStep() {
@@ -1598,12 +1623,6 @@ class GuideEditor {
this.emitMeta();
}
async finishCaptureSession() {
await api.capture.session({ action: 'finish', guideId: this.guideId });
this.onToast('Capture session finished.');
this.emitMeta();
}
async openSettings() {
const settings = await api.settings.all();
const placeholders = await api.settings.globalPlaceholders();
@@ -1750,8 +1769,12 @@ class GuideEditor {
}
async currentStepImageToBase64(step = this.currentStep) {
return this.stepImageToBase64(step, 'working');
}
async stepImageToBase64(step, which = 'working') {
if (!step || !step.image) return null;
const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' });
const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which });
if (!file) return null;
return new Promise((resolve) => {
const img = new Image();
@@ -1769,6 +1792,62 @@ class GuideEditor {
});
}
/** Snapshot a step (and its images, if any) before deletion so it can be undone. */
async snapshotStepForDeletion(step) {
const position = this.steps.findIndex((s) => s.stepId === step.stepId);
const childIds = this.steps.filter((s) => s.parentStepId === step.stepId).map((s) => s.stepId);
let images = null;
if (step.image) {
const [original, working] = await Promise.all([
this.stepImageToBase64(step, 'original'),
this.stepImageToBase64(step, 'working'),
]);
images = { original, working };
}
return { step: clone(step), position, childIds, images };
}
/** Undo a 'delete-step' history record: recreate the deleted steps and their order. */
async restoreDeletedSteps(record) {
for (const entry of record.steps) {
await api.step.restore({
guideId: this.guideId,
step: entry.step,
originalBase64: entry.images?.original?.base64 || null,
workingBase64: entry.images?.working?.base64 || null,
position: entry.position,
});
}
await api.step.reorder({ guideId: this.guideId, order: record.order });
this.saveStepDebounced.cancel();
this.pendingSave = false;
await this.reload(record.steps[0].step.stepId);
for (const entry of record.steps) {
for (const childId of entry.childIds) {
const child = this.stepMap.get(childId);
if (child && child.parentStepId !== entry.step.stepId) {
child.parentStepId = entry.step.stepId;
await api.step.save({ guideId: this.guideId, step: child });
}
}
}
if (record.steps.some((entry) => entry.childIds.length)) await this.reload(record.steps[0].step.stepId);
this.onToast(`Restored ${record.steps.length} step${record.steps.length === 1 ? '' : 's'}.`);
}
/** Redo of an undone 'delete-step' record: delete those steps again. */
async deleteStepsAgain(record) {
for (const entry of record.steps) {
await api.step.delete({ guideId: this.guideId, stepId: entry.step.stepId });
}
const deletedIds = new Set(record.steps.map((entry) => entry.step.stepId));
const remaining = record.order.filter((id) => !deletedIds.has(id));
this.saveStepDebounced.cancel();
this.pendingSave = false;
await this.reload(remaining[0] || null);
this.onToast(`Deleted ${record.steps.length} step${record.steps.length === 1 ? '' : 's'}.`);
}
async onCanvasChange(annotations) {
const step = this.currentStep;
if (!step) return;
+21
View File
@@ -244,6 +244,27 @@ class GuideStore {
this.saveGuide(guide);
}
/**
* Recreate a previously-deleted step, preserving its stepId, and splice it
* back into the guide's order. Used to undo step deletion.
*/
restoreStep(guideId, step, images, position) {
const guide = this.getGuide(guideId);
const restored = normalizeStep(deepClone(step));
validateStep(restored);
const dir = this.stepDir(guideId, restored.stepId);
fs.mkdirSync(dir, { recursive: true });
if (restored.image && images) {
if (images.original) atomicWriteFileSync(path.join(dir, restored.image.originalPath), images.original);
if (images.working) atomicWriteFileSync(path.join(dir, restored.image.workingPath), images.working);
}
writeJsonSync(path.join(dir, 'step.json'), restored);
const at = Number.isInteger(position) ? Math.min(position, guide.stepsOrder.length) : guide.stepsOrder.length;
guide.stepsOrder.splice(at, 0, restored.stepId);
this.saveGuide(guide);
return restored;
}
reorderSteps(guideId, newOrder) {
const guide = this.getGuide(guideId);
const current = new Set(guide.stepsOrder);
+25
View File
@@ -69,6 +69,31 @@ test('step reorder, delete with substep reparenting, and order integrity', (t) =
assert.equal(store.getStep(guide.guideId, c.stepId).parentStepId, null);
});
test('restoreStep recreates a deleted step with its original id, data, and images', (t) => {
const root = makeTmpDir('restore');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Restore test' });
const a = store.addStep(guide.guideId, { kind: 'empty', title: 'A' });
const b = store.addStep(guide.guideId, { title: 'B', annotations: [{ id: 'ann1', type: 'arrow', x: 1, y: 2 }] }, TINY_PNG, { width: 1, height: 1 });
const c = store.addStep(guide.guideId, { kind: 'empty', title: 'C' });
const deleted = store.getStep(guide.guideId, b.stepId);
store.deleteStep(guide.guideId, b.stepId);
assert.deepEqual(store.getGuide(guide.guideId).stepsOrder, [a.stepId, c.stepId]);
const restored = store.restoreStep(guide.guideId, deleted, { original: TINY_PNG, working: TINY_PNG }, 1);
assert.equal(restored.stepId, b.stepId);
assert.equal(restored.title, 'B');
assert.equal(restored.annotations[0].type, 'arrow');
const after = store.getGuide(guide.guideId);
assert.deepEqual(after.stepsOrder, [a.stepId, b.stepId, c.stepId]);
assert.deepEqual(store.getStep(guide.guideId, b.stepId).annotations, deleted.annotations);
assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, b.stepId, 'original')), TINY_PNG);
assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, b.stepId, 'working')), TINY_PNG);
});
test('duplicate guide produces independent deep copy with fresh ids', (t) => {
const root = makeTmpDir('dup');
t.after(() => rmrf(root));