textplus/docs/BOARD_DESIGN.md
monster 09972ccde1 TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board
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>
2026-06-14 16:08:59 +10:00

76 lines
4.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.01.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.