feat: add snapshot-based undo/redo to form builder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1ed9845af3
commit
03c471a60b
1 changed files with 84 additions and 5 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue