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

4.9 KiB
Raw Permalink Blame History

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.