diff --git a/docs/plans/2026-02-13-phase2-implementation.md b/docs/plans/2026-02-13-phase2-implementation.md new file mode 100644 index 0000000..621ace6 --- /dev/null +++ b/docs/plans/2026-02-13-phase2-implementation.md @@ -0,0 +1,739 @@ +# 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