# 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: ```typescript private history: FormDefinition[] = []; private historyIndex = 0; ``` **Step 2: Initialize history in constructor** In the constructor, after `this.onSave = onSave;`, add: ```typescript 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()`: ```typescript 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: ```typescript // 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: ```typescript // --- 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: 1. Before the mode dropdown onChange mutation, add `this.pushSnapshot();` before `this.draft.mode = v as ...`: ```typescript (v) => { this.pushSnapshot(); this.draft.mode = v as 'create' | 'update'; this.render(); }, ``` 2. Before the add-field mutation, add `this.pushSnapshot();` before `this.draft.fields.push(newField)`: ```typescript 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: 3. Before move-up swap: add `this.pushSnapshot();` ```typescript 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(); }); ``` 4. Before move-down swap: add `this.pushSnapshot();` ```typescript 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(); }); ``` 5. Before delete splice: add `this.pushSnapshot();` ```typescript deleteBtn.addEventListener('click', () => { this.pushSnapshot(); this.draft.fields.splice(i, 1); this.render(); }); ``` 6. Before type change: add `this.pushSnapshot();` ```typescript (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`: ```typescript input.addEventListener('change', () => { this.pushSnapshot(); onChange(input.value); }); ``` In `addTextareaSetting`: ```typescript textarea.addEventListener('change', () => { this.pushSnapshot(); onChange(textarea.value); }); ``` In `addDropdownSetting`: ```typescript select.addEventListener('change', () => { this.pushSnapshot(); onChange(select.value); }); ``` In `addToggleSetting`: ```typescript 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** ```bash 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: ```typescript 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: ```typescript fieldEl.setAttribute('draggable', 'true'); fieldEl.dataset.index = String(i); ``` In the header section, add a drag handle BEFORE the field number span: ```typescript headerEl.createSpan({ cls: 'ff-drag-handle', text: '\u2807', }); ``` After the delete button (after the entire actions section), add drag event handlers on `fieldEl`: ```typescript // --- 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** ```bash 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: ```typescript 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: ```typescript 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** ```bash 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;`): ```css /* --- 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: ```css .ff-builder-modal { max-width: 1100px; margin: 0 auto; } ``` Add after `.ff-builder-modal h3`: ```css /* --- 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: ```css /* --- 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: ```css .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** ```bash 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