obsidian-formfire/docs/plans/2026-02-13-phase2-implementation.md
tolvitty 1ed9845af3 docs: add Phase 2 implementation plan
5 tasks covering undo/redo, drag & drop, live preview, and styles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:32:17 +01:00

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:

  1. Before the mode dropdown onChange mutation, add this.pushSnapshot(); before this.draft.mode = v as ...:
      (v) => {
        this.pushSnapshot();
        this.draft.mode = v as 'create' | 'update';
        this.render();
      },
  1. Before the add-field mutation, add this.pushSnapshot(); before this.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:

  1. 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();
        });
  1. 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();
        });
  1. Before delete splice: add this.pushSnapshot();
      deleteBtn.addEventListener('click', () => {
        this.pushSnapshot();
        this.draft.fields.splice(i, 1);
        this.render();
      });
  1. 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