A no-Xcode AppKit app (swiftc + build.sh): - Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find, live Markdown preview (split mode), selection-aware Markdown toolbar. - Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape, US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel), freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich status bar. The Editor follows the selected page; layout persists to a foo.txt.board sidecar; the .txt stays pure plaintext. - scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain). - HTML scraping pipeline: custom selector engine + Discogs profile, ingests to Postgres (psql) into a marketplace_snapshots schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
76 lines
4.9 KiB
Markdown
76 lines
4.9 KiB
Markdown
# Freeform Board — implementation blueprint
|
||
|
||
Vetted by a 13-agent design panel (winner: reuse-robust, with freeform-ux box-cards
|
||
and the editorScrollView-nesting fix grafted in).
|
||
|
||
## Core decisions
|
||
- **Board = 4th WorkspaceController.Mode** (`editor`, `split`, `board`). Canvas mode is
|
||
ABSORBED into the board — the board's shapes layer is the drawing surface.
|
||
- **The magnifying `boardScroll` is the only scroll view.** Cards are bare views
|
||
(NSTextView / NSImageView / CALayer box). NEVER nest `editorScrollView` inside it.
|
||
- **Main card hosts the document's real `textView`** (reparented bare, no scroll view),
|
||
so dirty/undo/find/line-col all keep working. Its text = the `.txt` bytes (never
|
||
serialized to the sidecar — no second copy can diverge).
|
||
- **Secondary text cards** are board-only annotations; their text lives in the sidecar,
|
||
not the `.txt`.
|
||
- **Clean boxes** = `BoardCard(kind: .box)` CALayer cards (fill `accent@0.18`, stroke
|
||
`accent`, cornerRadius 8). Freehand pen/shapes stay on the reused `DrawCanvasView`.
|
||
|
||
## View hierarchy (Sources/BoardContainer.swift)
|
||
```
|
||
BoardContainerView : NSView (sibling of splitView; same 4 pins; isHidden=true)
|
||
├─ palette : NSStackView (left, fixed width) Select/Box/Pen/Line/Arrow/Oval/Eraser,
|
||
│ stroke well, width slider, New Text Card, Add Image…, Portrait/Landscape,
|
||
│ zoom-to-fit, undo/redo, export PNG, send-to-tailnet (pinned, NOT zoomed)
|
||
└─ boardScroll : NSScrollView allowsMagnification, min .15 / max 4
|
||
└─ boardView : BoardView (NSView, isFlipped, wantsLayer, world 8000×6000, dot grid)
|
||
├─ shapes : DrawCanvasView (reused, canvasBackground=.clear, bottom)
|
||
├─ cardViews : [CardView] by z (TextCardView / ImageCardView / BoxCardView)
|
||
└─ selectionOverlay : SelectionOverlayView (8 handles, top, forwards events)
|
||
```
|
||
|
||
## Data model (Sources/Board.swift), Codable
|
||
- `BoardCardKind { mainText, text, image, box }`
|
||
- `PageOrientation { portrait(612×792), landscape(792×612) }`
|
||
- `BoardCard { id, kind, frame(world), z, text?, orientation?, imageFile?, fillHex?, strokeHex?, cornerRadius?, title? }`
|
||
- `BoardDoc { version, cards, shapes: DrawCanvasView.CanvasDoc, magnification, scrollOrigin, boardBackgroundHex? }`
|
||
- Hand-written `init(from:)` that `try?`-decodes each field with a default → missing/old
|
||
sidecar never throws.
|
||
|
||
## Persistence: bundle dir `foo.txt.board/`
|
||
- `board.json` (atomic write via replaceItemAt, pretty+sortedKeys)
|
||
- `img-<uuid>.png` assets (referenced by filename; bytes never in JSON; GC orphans on save)
|
||
- `preview.png` (derived, for send-to-tailnet)
|
||
- Load after Document.swift:41; save after Document.swift:121, all `try?`-guarded so a
|
||
sidecar failure NEVER fails the plaintext save. Main card text is never written here.
|
||
- Migration: if `foo.txt.board/` absent but `foo.txt.canvas.json` exists, import its
|
||
CanvasDoc into `BoardDoc.shapes` (one-way, leaves the old file).
|
||
|
||
## Focus transition
|
||
- Enter: double-click text card OR Return on selection → record preFocus mag/visibleRect,
|
||
raise z, animate `boardScroll.animator().setMagnification(clamp 1.0–1.5, centeredAt:)`,
|
||
textView editable+selectable, makeFirstResponder(textView).
|
||
- Exit: Esc / click empty / select other / leave mode → breakUndoCoalescing(), copy
|
||
secondary card text back + updateChangeCount, editable=false, makeFirstResponder(boardView),
|
||
animate back.
|
||
- **Mandatory:** CardView.hitTest returns self while unfocused (a non-selectable NSTextView
|
||
still returns self for a plain click). Shapes layer hit-tests only when a draw tool active.
|
||
|
||
## Must-handle pitfalls
|
||
1. Reparent only the bare textView, never editorScrollView (the #1 sleeper bug).
|
||
2. rulersVisible=false in board; restore on exit; keep editorScrollView alive.
|
||
3. Gate applyWrap when mode==.board (it reads editorScrollView.contentSize).
|
||
4. Return editor to splitView via insertArrangedSubview(at:0), never add (pane order).
|
||
5. NSScrollView.allowsMagnification only — no manual CGAffineTransform. convert(from:nil) keeps working.
|
||
6. CardView hitTest override (above).
|
||
7. Centralize textView reparenting in attachTextViewToBoard()/detachTextViewToEditor(); one superview owns it; never recreate it. Test editor→board→editor→split round-trip FIRST.
|
||
8. shapes DrawCanvasView canvasBackground=.clear (it opaque-fills bounds otherwise).
|
||
9. Two undo managers (text=document, structural=boardView); every structural mutation also updateChangeCount(.changeDone).
|
||
10. Board PNG export at magnification 1.0 over the union of content, not the 8000×6000 world.
|
||
11. Re-resolve CALayer cgColors in applyAppearance() (they don't auto-update on appearance change).
|
||
12. saveBoardSidecar wrapped in try?, after super.save succeeds.
|
||
|
||
## MVP cuts (defer)
|
||
connectors, marquee multi-select, alignment guides (keep grid-snap), card snapshotting,
|
||
interleaved shape/card z, multi-page-into-one-.txt, JSON forward-merge, QuickLook plugin.
|