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>
4.9 KiB
4.9 KiB
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
boardScrollis the only scroll view. Cards are bare views (NSTextView / NSImageView / CALayer box). NEVER nesteditorScrollViewinside 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.txtbytes (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 (fillaccent@0.18, strokeaccent, cornerRadius 8). Freehand pen/shapes stay on the reusedDrawCanvasView.
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:)thattry?-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>.pngassets (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 butfoo.txt.canvas.jsonexists, import its CanvasDoc intoBoardDoc.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
- Reparent only the bare textView, never editorScrollView (the #1 sleeper bug).
- rulersVisible=false in board; restore on exit; keep editorScrollView alive.
- Gate applyWrap when mode==.board (it reads editorScrollView.contentSize).
- Return editor to splitView via insertArrangedSubview(at:0), never add (pane order).
- NSScrollView.allowsMagnification only — no manual CGAffineTransform. convert(from:nil) keeps working.
- CardView hitTest override (above).
- Centralize textView reparenting in attachTextViewToBoard()/detachTextViewToEditor(); one superview owns it; never recreate it. Test editor→board→editor→split round-trip FIRST.
- shapes DrawCanvasView canvasBackground=.clear (it opaque-fills bounds otherwise).
- Two undo managers (text=document, structural=boardView); every structural mutation also updateChangeCount(.changeDone).
- Board PNG export at magnification 1.0 over the union of content, not the 8000×6000 world.
- Re-resolve CALayer cgColors in applyAppearance() (they don't auto-update on appearance change).
- 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.