5 tasks covering undo/redo, drag & drop, live preview, and styles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
18 KiB
Phase 2 — Builder Upgrade Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Transform the FormBuilderModal with drag & drop, live preview, and undo/redo.
Architecture: All three features modify form-builder.ts. Undo/redo is implemented first (provides infrastructure), then drag & drop (uses pushSnapshot), then layout restructure for preview. Each task builds on the previous.
Tech Stack: TypeScript, Obsidian API, HTML5 Drag & Drop, esbuild
Task 1: Add undo/redo infrastructure
Files:
- Modify:
src/ui/form-builder.ts
Step 1: Add instance variables and imports
At the top of FormBuilderModal class, after the existing private onSave line, add:
private history: FormDefinition[] = [];
private historyIndex = 0;
Step 2: Initialize history in constructor
In the constructor, after this.onSave = onSave;, add:
this.history = [structuredClone(this.draft)];
this.historyIndex = 0;
Step 3: Add pushSnapshot, undo, redo methods
Add these three methods after the onClose() method, before render():
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();
}
Step 4: Add keyboard listener in render()
In the render() method, right after contentEl.addClass('ff-builder-modal');, add:
// 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();
}
});
Step 5: Add undo/redo buttons in footer
In the render() method, replace the entire footer section (from const footer = ... through the save button click handler) with:
// --- Footer ---
const footer = contentEl.createDiv({ cls: 'ff-builder-footer' });
// 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', () => {
this.close();
});
const saveBtn = footerRight.createEl('button', {
text: 'Save',
cls: 'mod-cta',
});
saveBtn.addEventListener('click', () => {
if (!this.draft.name.trim()) {
new Notice('Form name cannot be empty.');
return;
}
this.onSave(this.draft);
this.close();
});
Step 6: Add pushSnapshot to all mutation points
In the render() method:
- Before the mode dropdown onChange mutation, add
this.pushSnapshot();beforethis.draft.mode = v as ...:
(v) => {
this.pushSnapshot();
this.draft.mode = v as 'create' | 'update';
this.render();
},
- Before the add-field mutation, add
this.pushSnapshot();beforethis.draft.fields.push(newField):
addBtn.addEventListener('click', () => {
const newField: FormField = {
id: `field_${Date.now()}`,
label: 'New Field',
type: 'text',
required: false,
};
this.pushSnapshot();
this.draft.fields.push(newField);
this.render();
});
In the renderFields() method:
- Before move-up swap: add
this.pushSnapshot();
upBtn.addEventListener('click', () => {
this.pushSnapshot();
[this.draft.fields[i - 1], this.draft.fields[i]] = [
this.draft.fields[i],
this.draft.fields[i - 1],
];
this.render();
});
- Before move-down swap: add
this.pushSnapshot();
downBtn.addEventListener('click', () => {
this.pushSnapshot();
[this.draft.fields[i], this.draft.fields[i + 1]] = [
this.draft.fields[i + 1],
this.draft.fields[i],
];
this.render();
});
- Before delete splice: add
this.pushSnapshot();
deleteBtn.addEventListener('click', () => {
this.pushSnapshot();
this.draft.fields.splice(i, 1);
this.render();
});
- Before type change: add
this.pushSnapshot();
(v) => {
this.pushSnapshot();
field.type = v as FieldType;
this.render();
},
Step 7: Add pushSnapshot to setting helpers
Modify each helper to call this.pushSnapshot() before invoking the callback:
In addTextSetting:
input.addEventListener('change', () => {
this.pushSnapshot();
onChange(input.value);
});
In addTextareaSetting:
textarea.addEventListener('change', () => {
this.pushSnapshot();
onChange(textarea.value);
});
In addDropdownSetting:
select.addEventListener('change', () => {
this.pushSnapshot();
onChange(select.value);
});
In addToggleSetting:
toggle.addEventListener('click', () => {
this.pushSnapshot();
const enabled = !toggle.hasClass('is-enabled');
toggle.toggleClass('is-enabled', enabled);
onChange(enabled);
});
Step 8: Build to verify
Run: node esbuild.config.mjs
Expected: No errors
Step 9: Commit
git add src/ui/form-builder.ts
git commit -m "feat: add snapshot-based undo/redo to form builder"
Task 2: Add drag & drop field reordering
Files:
- Modify:
src/ui/form-builder.ts(renderFields method)
Step 1: Add instance variable
After the historyIndex declaration, add:
private dragSourceIndex: number | null = null;
Step 2: Add drag handle and events to renderFields()
In the renderFields() method, make these changes to each field element:
After const fieldEl = container.createDiv({ cls: 'ff-builder-field' });, add:
fieldEl.setAttribute('draggable', 'true');
fieldEl.dataset.index = String(i);
In the header section, add a drag handle BEFORE the field number span:
headerEl.createSpan({
cls: 'ff-drag-handle',
text: '\u2807',
});
After the delete button (after the entire actions section), add drag event handlers on fieldEl:
// --- Drag & Drop ---
fieldEl.addEventListener('dragstart', (e: DragEvent) => {
this.dragSourceIndex = i;
fieldEl.addClass('ff-dragging');
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
}
});
fieldEl.addEventListener('dragend', () => {
this.dragSourceIndex = null;
fieldEl.removeClass('ff-dragging');
});
fieldEl.addEventListener('dragover', (e: DragEvent) => {
e.preventDefault();
if (this.dragSourceIndex === null || this.dragSourceIndex === i) return;
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
const rect = fieldEl.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
fieldEl.removeClass('ff-drop-above', 'ff-drop-below');
if (e.clientY < midY) {
fieldEl.addClass('ff-drop-above');
} else {
fieldEl.addClass('ff-drop-below');
}
});
fieldEl.addEventListener('dragleave', () => {
fieldEl.removeClass('ff-drop-above', 'ff-drop-below');
});
fieldEl.addEventListener('drop', (e: DragEvent) => {
e.preventDefault();
fieldEl.removeClass('ff-drop-above', 'ff-drop-below');
if (this.dragSourceIndex === null || this.dragSourceIndex === i) return;
const rect = fieldEl.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const insertBefore = e.clientY < midY;
this.pushSnapshot();
const [moved] = this.draft.fields.splice(this.dragSourceIndex, 1);
let targetIndex = insertBefore ? i : i + 1;
if (this.dragSourceIndex < i) targetIndex--;
this.draft.fields.splice(targetIndex, 0, moved);
this.dragSourceIndex = null;
this.render();
});
Step 3: Build to verify
Run: node esbuild.config.mjs
Expected: No errors
Step 4: Commit
git add src/ui/form-builder.ts
git commit -m "feat: add drag & drop field reordering to form builder"
Task 3: Add side-by-side live preview
Files:
- Modify:
src/ui/form-builder.ts
Step 1: Add import for renderField
At the top of the file, change the imports to include renderField:
import { App, Modal, Notice } from 'obsidian';
import { FormDefinition, FormField, FieldType } from '../types';
import { renderField } from './field-renderers';
Step 2: Restructure render() to use grid layout
In the render() method, replace the content between the keyboard listener and the footer with a grid layout. The full render() method should become:
private render(): void {
const { contentEl } = this;
contentEl.empty();
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();
}
});
// --- Two-column layout ---
const layout = contentEl.createDiv({ cls: 'ff-builder-layout' });
// === LEFT: Editor ===
const editorEl = layout.createDiv({ cls: 'ff-builder-editor' });
editorEl.createEl('h2', { text: 'Form Settings' });
const generalEl = editorEl.createDiv({ cls: 'ff-builder-general' });
// Name
this.addTextSetting(generalEl, 'Form Name', this.draft.name, (v) => {
this.draft.name = v;
});
// Mode
this.addDropdownSetting(
generalEl,
'Mode',
['create', 'update'],
this.draft.mode,
(v) => {
this.pushSnapshot();
this.draft.mode = v as 'create' | 'update';
this.render();
},
);
// Mode-specific settings
if (this.draft.mode === 'create') {
this.addTextSetting(
generalEl,
'Target Folder',
this.draft.targetFolder ?? '/',
(v) => {
this.draft.targetFolder = v;
},
);
this.addTextSetting(
generalEl,
'File Name Template',
this.draft.fileNameTemplate ?? '{{date}}-{{title}}',
(v) => {
this.draft.fileNameTemplate = v;
},
);
this.addTextareaSetting(
generalEl,
'Body Template',
this.draft.bodyTemplate ?? '',
(v) => {
this.draft.bodyTemplate = v;
},
);
} else {
this.addDropdownSetting(
generalEl,
'Target File',
['active', 'prompt'],
this.draft.targetFile ?? 'active',
(v) => {
this.draft.targetFile = v as 'active' | 'prompt';
},
);
}
// Fields section
editorEl.createEl('h3', { text: 'Fields' });
const fieldsContainer = editorEl.createDiv({ cls: 'ff-builder-fields' });
this.renderFields(fieldsContainer);
// Add field button
const addBtn = editorEl.createEl('button', {
cls: 'ff-builder-add-btn',
text: '+ Add Field',
});
addBtn.addEventListener('click', () => {
const newField: FormField = {
id: `field_${Date.now()}`,
label: 'New Field',
type: 'text',
required: false,
};
this.pushSnapshot();
this.draft.fields.push(newField);
this.render();
});
// === RIGHT: Preview ===
const previewEl = layout.createDiv({ cls: 'ff-builder-preview' });
previewEl.createEl('h2', { text: 'Preview' });
const previewContent = previewEl.createDiv({ cls: 'ff-builder-preview-content' });
if (this.draft.fields.length === 0) {
previewContent.createDiv({
cls: 'ff-builder-preview-empty',
text: 'Add fields to see a preview.',
});
} else {
// Form title
previewContent.createEl('h3', {
text: this.draft.name || 'Untitled Form',
cls: 'ff-form-title',
});
// Render each field
const previewFields = previewContent.createDiv({ cls: 'ff-fields' });
for (const field of this.draft.fields) {
renderField(this.app, previewFields, field, field.defaultValue);
}
// Disabled submit button
const previewFooter = previewContent.createDiv({ cls: 'ff-form-footer' });
const previewSubmit = previewFooter.createEl('button', {
text: this.draft.mode === 'create' ? 'Create Note' : 'Update Frontmatter',
cls: 'mod-cta ff-submit-btn',
});
previewSubmit.setAttribute('disabled', '');
}
// --- Footer (full width, below grid) ---
const footer = contentEl.createDiv({ cls: 'ff-builder-footer' });
// 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', () => {
this.close();
});
const saveBtn = footerRight.createEl('button', {
text: 'Save',
cls: 'mod-cta',
});
saveBtn.addEventListener('click', () => {
if (!this.draft.name.trim()) {
new Notice('Form name cannot be empty.');
return;
}
this.onSave(this.draft);
this.close();
});
}
Note: This replaces the ENTIRE render() method. The renderFields() method and setting helpers stay unchanged from Task 2.
Step 3: Build to verify
Run: node esbuild.config.mjs
Expected: No errors
Step 4: Commit
git add src/ui/form-builder.ts
git commit -m "feat: add side-by-side live preview to form builder"
Task 4: Add Phase 2 styles
Files:
- Modify:
styles.css
Step 1: Add drag & drop styles
Add after the existing .ff-builder-field-body rule (after padding: 10px;):
/* --- Drag & Drop --- */
.ff-drag-handle {
cursor: grab;
color: var(--text-faint);
font-size: 1rem;
line-height: 1;
user-select: none;
transition: color 0.15s ease;
padding: 0 2px;
}
.ff-drag-handle:hover {
color: var(--text-muted);
}
.ff-builder-field.ff-dragging {
opacity: 0.4;
}
.ff-builder-field.ff-drop-above {
border-top: 2px solid var(--interactive-accent);
}
.ff-builder-field.ff-drop-below {
border-bottom: 2px solid var(--interactive-accent);
}
Step 2: Update builder modal width and add layout styles
Replace the existing .ff-builder-modal rule:
.ff-builder-modal {
max-width: 1100px;
margin: 0 auto;
}
Add after .ff-builder-modal h3:
/* --- Two-column layout --- */
.ff-builder-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
min-height: 300px;
}
.ff-builder-editor {
min-width: 0;
}
Step 3: Add preview panel styles
Add after the .ff-builder-editor rule:
/* --- Preview panel --- */
.ff-builder-preview {
border-left: 1px solid var(--background-modifier-border);
padding-left: 24px;
min-width: 0;
}
.ff-builder-preview h2 {
position: sticky;
top: 0;
background: var(--background-primary);
padding-bottom: 8px;
z-index: 1;
}
.ff-builder-preview-content {
max-height: 55vh;
overflow-y: auto;
padding-right: 4px;
}
.ff-builder-preview-empty {
padding: 32px 16px;
text-align: center;
font-size: 0.85rem;
color: var(--text-muted);
}
Step 4: Add undo/redo footer styles
Replace the existing .ff-builder-footer rule:
.ff-builder-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--background-modifier-border);
}
.ff-builder-undo-redo {
display: flex;
gap: 4px;
}
.ff-builder-footer-right {
display: flex;
gap: 8px;
}
.ff-builder-action-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
Step 5: Build to verify
Run: node esbuild.config.mjs
Expected: No errors
Step 6: Commit
git add styles.css
git commit -m "feat: add styles for drag & drop, preview panel, and undo/redo"
Task 5: Final build and verification
Step 1: Clean build
Run: node esbuild.config.mjs
Expected: Build succeeds, no errors
Step 2: Verify all files are tracked
Run: git status
Expected: Clean working tree
Step 3: Verify commit history
Run: git log --oneline -6
Expected: 4 new commits on top of Phase 1 history