feat: add snapshot-based undo/redo to form builder

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Oelfke 2026-02-13 15:39:12 +01:00
parent 1ed9845af3
commit 03c471a60b

View file

@ -24,6 +24,8 @@ const FIELD_TYPES: FieldType[] = [
export class FormBuilderModal extends Modal { export class FormBuilderModal extends Modal {
private draft: FormDefinition; private draft: FormDefinition;
private onSave: (form: FormDefinition) => void; private onSave: (form: FormDefinition) => void;
private history: FormDefinition[] = [];
private historyIndex = 0;
constructor( constructor(
app: App, app: App,
@ -33,6 +35,8 @@ export class FormBuilderModal extends Modal {
super(app); super(app);
this.draft = structuredClone(form); this.draft = structuredClone(form);
this.onSave = onSave; this.onSave = onSave;
this.history = [structuredClone(this.draft)];
this.historyIndex = 0;
} }
onOpen(): void { onOpen(): void {
@ -43,11 +47,48 @@ export class FormBuilderModal extends Modal {
this.contentEl.empty(); this.contentEl.empty();
} }
private pushSnapshot(): void {
// Truncate any forward history
this.history = this.history.slice(0, this.historyIndex + 1);
this.history.push(structuredClone(this.draft));
// Cap at 30 entries
if (this.history.length > 30) {
this.history.shift();
} else {
this.historyIndex++;
}
}
private undo(): void {
if (this.historyIndex <= 0) return;
this.historyIndex--;
this.draft = structuredClone(this.history[this.historyIndex]);
this.render();
}
private redo(): void {
if (this.historyIndex >= this.history.length - 1) return;
this.historyIndex++;
this.draft = structuredClone(this.history[this.historyIndex]);
this.render();
}
private render(): void { private render(): void {
const { contentEl } = this; const { contentEl } = this;
contentEl.empty(); contentEl.empty();
contentEl.addClass('ff-builder-modal'); contentEl.addClass('ff-builder-modal');
// Undo/Redo keyboard shortcuts
contentEl.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
e.preventDefault();
this.undo();
} else if (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey) {
e.preventDefault();
this.redo();
}
});
// --- General settings --- // --- General settings ---
contentEl.createEl('h2', { text: 'Form Settings' }); contentEl.createEl('h2', { text: 'Form Settings' });
@ -65,6 +106,7 @@ export class FormBuilderModal extends Modal {
['create', 'update'], ['create', 'update'],
this.draft.mode, this.draft.mode,
(v) => { (v) => {
this.pushSnapshot();
this.draft.mode = v as 'create' | 'update'; this.draft.mode = v as 'create' | 'update';
this.render(); // Re-render to show mode-specific settings this.render(); // Re-render to show mode-specific settings
}, },
@ -126,6 +168,7 @@ export class FormBuilderModal extends Modal {
type: 'text', type: 'text',
required: false, required: false,
}; };
this.pushSnapshot();
this.draft.fields.push(newField); this.draft.fields.push(newField);
this.render(); this.render();
}); });
@ -133,12 +176,34 @@ export class FormBuilderModal extends Modal {
// --- Footer --- // --- Footer ---
const footer = contentEl.createDiv({ cls: 'ff-builder-footer' }); const footer = contentEl.createDiv({ cls: 'ff-builder-footer' });
const cancelBtn = footer.createEl('button', { text: 'Cancel' }); // Left side: undo/redo
const undoRedoEl = footer.createDiv({ cls: 'ff-builder-undo-redo' });
const undoBtn = undoRedoEl.createEl('button', {
cls: 'ff-builder-action-btn',
text: '\u21A9',
});
undoBtn.setAttribute('aria-label', 'Undo (Ctrl+Z)');
if (this.historyIndex <= 0) undoBtn.setAttribute('disabled', '');
undoBtn.addEventListener('click', () => this.undo());
const redoBtn = undoRedoEl.createEl('button', {
cls: 'ff-builder-action-btn',
text: '\u21AA',
});
redoBtn.setAttribute('aria-label', 'Redo (Ctrl+Shift+Z)');
if (this.historyIndex >= this.history.length - 1) redoBtn.setAttribute('disabled', '');
redoBtn.addEventListener('click', () => this.redo());
// Right side: cancel/save
const footerRight = footer.createDiv({ cls: 'ff-builder-footer-right' });
const cancelBtn = footerRight.createEl('button', { text: 'Cancel' });
cancelBtn.addEventListener('click', () => { cancelBtn.addEventListener('click', () => {
this.close(); this.close();
}); });
const saveBtn = footer.createEl('button', { const saveBtn = footerRight.createEl('button', {
text: 'Save', text: 'Save',
cls: 'mod-cta', cls: 'mod-cta',
}); });
@ -180,6 +245,7 @@ export class FormBuilderModal extends Modal {
}); });
upBtn.setAttribute('aria-label', 'Move up'); upBtn.setAttribute('aria-label', 'Move up');
upBtn.addEventListener('click', () => { upBtn.addEventListener('click', () => {
this.pushSnapshot();
[this.draft.fields[i - 1], this.draft.fields[i]] = [ [this.draft.fields[i - 1], this.draft.fields[i]] = [
this.draft.fields[i], this.draft.fields[i],
this.draft.fields[i - 1], this.draft.fields[i - 1],
@ -196,6 +262,7 @@ export class FormBuilderModal extends Modal {
}); });
downBtn.setAttribute('aria-label', 'Move down'); downBtn.setAttribute('aria-label', 'Move down');
downBtn.addEventListener('click', () => { downBtn.addEventListener('click', () => {
this.pushSnapshot();
[this.draft.fields[i], this.draft.fields[i + 1]] = [ [this.draft.fields[i], this.draft.fields[i + 1]] = [
this.draft.fields[i + 1], this.draft.fields[i + 1],
this.draft.fields[i], this.draft.fields[i],
@ -211,6 +278,7 @@ export class FormBuilderModal extends Modal {
}); });
deleteBtn.setAttribute('aria-label', 'Delete field'); deleteBtn.setAttribute('aria-label', 'Delete field');
deleteBtn.addEventListener('click', () => { deleteBtn.addEventListener('click', () => {
this.pushSnapshot();
this.draft.fields.splice(i, 1); this.draft.fields.splice(i, 1);
this.render(); this.render();
}); });
@ -235,6 +303,7 @@ export class FormBuilderModal extends Modal {
FIELD_TYPES, FIELD_TYPES,
field.type, field.type,
(v) => { (v) => {
this.pushSnapshot();
field.type = v as FieldType; field.type = v as FieldType;
this.render(); this.render();
}, },
@ -331,7 +400,10 @@ export class FormBuilderModal extends Modal {
type: 'text', type: 'text',
value: value, value: value,
}); });
input.addEventListener('change', () => onChange(input.value)); input.addEventListener('change', () => {
this.pushSnapshot();
onChange(input.value);
});
} }
private addTextareaSetting( private addTextareaSetting(
@ -347,7 +419,10 @@ export class FormBuilderModal extends Modal {
}); });
textarea.value = value; textarea.value = value;
textarea.rows = 4; textarea.rows = 4;
textarea.addEventListener('change', () => onChange(textarea.value)); textarea.addEventListener('change', () => {
this.pushSnapshot();
onChange(textarea.value);
});
} }
private addDropdownSetting( private addDropdownSetting(
@ -364,7 +439,10 @@ export class FormBuilderModal extends Modal {
select.createEl('option', { value: opt, text: opt }); select.createEl('option', { value: opt, text: opt });
} }
select.value = value; select.value = value;
select.addEventListener('change', () => onChange(select.value)); select.addEventListener('change', () => {
this.pushSnapshot();
onChange(select.value);
});
} }
private addToggleSetting( private addToggleSetting(
@ -378,6 +456,7 @@ export class FormBuilderModal extends Modal {
const toggle = row.createDiv({ cls: 'checkbox-container' }); const toggle = row.createDiv({ cls: 'checkbox-container' });
if (value) toggle.addClass('is-enabled'); if (value) toggle.addClass('is-enabled');
toggle.addEventListener('click', () => { toggle.addEventListener('click', () => {
this.pushSnapshot();
const enabled = !toggle.hasClass('is-enabled'); const enabled = !toggle.hasClass('is-enabled');
toggle.toggleClass('is-enabled', enabled); toggle.toggleClass('is-enabled', enabled);
onChange(enabled); onChange(enabled);