Mass guide editor changes #18
@@ -419,6 +419,15 @@ function setupIpc() {
|
|||||||
reindex(guideId);
|
reindex(guideId);
|
||||||
return true;
|
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('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order));
|
||||||
h('step:imagePath', ({ guideId, stepId, which }) => {
|
h('step:imagePath', ({ guideId, stepId, which }) => {
|
||||||
const p = store.stepImagePath(guideId, stepId, which || 'working');
|
const p = store.stepImagePath(guideId, stepId, which || 'working');
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const api = {
|
|||||||
add: invoke('step:add'),
|
add: invoke('step:add'),
|
||||||
save: invoke('step:save'),
|
save: invoke('step:save'),
|
||||||
delete: invoke('step:delete'),
|
delete: invoke('step:delete'),
|
||||||
|
restore: invoke('step:restore'),
|
||||||
reorder: invoke('steps:reorder'),
|
reorder: invoke('steps:reorder'),
|
||||||
imagePath: invoke('step:imagePath'),
|
imagePath: invoke('step:imagePath'),
|
||||||
setWorkingImage: invoke('step:setWorkingImage'),
|
setWorkingImage: invoke('step:setWorkingImage'),
|
||||||
|
|||||||
+1
-1
@@ -279,7 +279,7 @@ class StepForgeApp {
|
|||||||
}
|
}
|
||||||
send({ action: s.paused ? 'resume' : 'pause' });
|
send({ action: s.paused ? 'resume' : 'pause' });
|
||||||
},
|
},
|
||||||
}, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause');
|
}, s.paused ? 'Start recording' : 'Stop recording');
|
||||||
|
|
||||||
this.captureStatus.append(
|
this.captureStatus.append(
|
||||||
el('span', { title: `Capture session — ${trigger}` }, `Rec - ${trigger}`),
|
el('span', { title: `Capture session — ${trigger}` }, `Rec - ${trigger}`),
|
||||||
|
|||||||
+99
-20
@@ -873,13 +873,21 @@ class GuideEditor {
|
|||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: 'Delete' });
|
const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: 'Delete' });
|
||||||
if (!ok) return;
|
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) {
|
for (const stepId of ids) {
|
||||||
await api.step.delete({ guideId: this.guideId, stepId });
|
await api.step.delete({ guideId: this.guideId, stepId });
|
||||||
}
|
}
|
||||||
|
if (entries.length) this.pushCanvasHistory({ type: 'delete-step', steps: entries, order });
|
||||||
this.stepSelectMode = false;
|
this.stepSelectMode = false;
|
||||||
this.selectedSteps = new Set();
|
this.selectedSteps = new Set();
|
||||||
await this.reload(null);
|
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() {
|
syncStepFields() {
|
||||||
@@ -1146,7 +1154,8 @@ class GuideEditor {
|
|||||||
|
|
||||||
pushCanvasHistory(recordOrLabel = 'change') {
|
pushCanvasHistory(recordOrLabel = 'change') {
|
||||||
if (!this.currentStep) return;
|
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
|
? recordOrLabel
|
||||||
: { step: clone(this.currentStep) };
|
: { step: clone(this.currentStep) };
|
||||||
this.canvasHistory.push(record);
|
this.canvasHistory.push(record);
|
||||||
@@ -1187,27 +1196,39 @@ class GuideEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async undo() {
|
async undo() {
|
||||||
if (!this.currentStep) return;
|
|
||||||
if (!this.canvasHistory.length) {
|
if (!this.canvasHistory.length) {
|
||||||
this.onToast('Nothing to undo.');
|
this.onToast('Nothing to undo.');
|
||||||
return;
|
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);
|
const current = await this.snapshotCurrentStep(true);
|
||||||
if (current) this.canvasFuture.push(current);
|
if (current) this.canvasFuture.push(current);
|
||||||
const previous = this.canvasHistory.pop();
|
|
||||||
await this.restoreHistoryRecord(previous);
|
await this.restoreHistoryRecord(previous);
|
||||||
this.renderAll();
|
this.renderAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async redo() {
|
async redo() {
|
||||||
if (!this.currentStep) return;
|
|
||||||
if (!this.canvasFuture.length) {
|
if (!this.canvasFuture.length) {
|
||||||
this.onToast('Nothing to redo.');
|
this.onToast('Nothing to redo.');
|
||||||
return;
|
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);
|
const current = await this.snapshotCurrentStep(true);
|
||||||
if (current) this.canvasHistory.push(current);
|
if (current) this.canvasHistory.push(current);
|
||||||
const next = this.canvasFuture.pop();
|
|
||||||
await this.restoreHistoryRecord(next);
|
await this.restoreHistoryRecord(next);
|
||||||
this.renderAll();
|
this.renderAll();
|
||||||
}
|
}
|
||||||
@@ -1434,12 +1455,16 @@ class GuideEditor {
|
|||||||
if (!step) return;
|
if (!step) return;
|
||||||
const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' });
|
const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' });
|
||||||
if (!ok) return;
|
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 });
|
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]
|
const next = this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) + 1]
|
||||||
|| this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1]
|
|| this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1]
|
||||||
|| null;
|
|| null;
|
||||||
await this.reload(next && next.stepId);
|
await this.reload(next && next.stepId);
|
||||||
this.onToast('Step deleted.');
|
this.onToast('Step deleted. Press Ctrl+Z to undo.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveSelectedStep(delta) {
|
async moveSelectedStep(delta) {
|
||||||
@@ -1479,18 +1504,18 @@ class GuideEditor {
|
|||||||
async openCaptureMenu(event) {
|
async openCaptureMenu(event) {
|
||||||
const rect = event.target.getBoundingClientRect();
|
const rect = event.target.getBoundingClientRect();
|
||||||
const session = (await api.capture.state())?.active;
|
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 full screen', action: () => this.captureStep('fullscreen') },
|
||||||
{ label: 'Capture window', action: () => this.captureStep('window') },
|
{ label: 'Capture window', action: () => this.captureStep('window') },
|
||||||
{ label: 'Capture region…', action: () => this.captureStep('region') },
|
{ label: 'Capture region…', action: () => this.captureStep('region') },
|
||||||
'sep',
|
'sep',
|
||||||
{ label: 'Paste image as step', action: () => this.pasteClipboardStep() },
|
{ label: 'Paste image as step', action: () => this.pasteClipboardStep() },
|
||||||
{ label: 'Import images…', action: () => this.importImageSteps() },
|
{ label: 'Import images…', action: () => this.importImageSteps() },
|
||||||
'sep',
|
];
|
||||||
session
|
if (!session) {
|
||||||
? { label: 'Finish capture session', action: () => this.finishCaptureSession() }
|
items.push('sep', { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() });
|
||||||
: { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() },
|
}
|
||||||
]);
|
contextMenu(rect.left, rect.bottom + 4, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
async pasteClipboardStep() {
|
async pasteClipboardStep() {
|
||||||
@@ -1598,12 +1623,6 @@ class GuideEditor {
|
|||||||
this.emitMeta();
|
this.emitMeta();
|
||||||
}
|
}
|
||||||
|
|
||||||
async finishCaptureSession() {
|
|
||||||
await api.capture.session({ action: 'finish', guideId: this.guideId });
|
|
||||||
this.onToast('Capture session finished.');
|
|
||||||
this.emitMeta();
|
|
||||||
}
|
|
||||||
|
|
||||||
async openSettings() {
|
async openSettings() {
|
||||||
const settings = await api.settings.all();
|
const settings = await api.settings.all();
|
||||||
const placeholders = await api.settings.globalPlaceholders();
|
const placeholders = await api.settings.globalPlaceholders();
|
||||||
@@ -1750,8 +1769,12 @@ class GuideEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async currentStepImageToBase64(step = this.currentStep) {
|
async currentStepImageToBase64(step = this.currentStep) {
|
||||||
|
return this.stepImageToBase64(step, 'working');
|
||||||
|
}
|
||||||
|
|
||||||
|
async stepImageToBase64(step, which = 'working') {
|
||||||
if (!step || !step.image) return null;
|
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;
|
if (!file) return null;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const img = new Image();
|
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) {
|
async onCanvasChange(annotations) {
|
||||||
const step = this.currentStep;
|
const step = this.currentStep;
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
|
|||||||
@@ -244,6 +244,27 @@ class GuideStore {
|
|||||||
this.saveGuide(guide);
|
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) {
|
reorderSteps(guideId, newOrder) {
|
||||||
const guide = this.getGuide(guideId);
|
const guide = this.getGuide(guideId);
|
||||||
const current = new Set(guide.stepsOrder);
|
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);
|
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) => {
|
test('duplicate guide produces independent deep copy with fresh ids', (t) => {
|
||||||
const root = makeTmpDir('dup');
|
const root = makeTmpDir('dup');
|
||||||
t.after(() => rmrf(root));
|
t.after(() => rmrf(root));
|
||||||
|
|||||||
Reference in New Issue
Block a user