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