textplus/Sources/Board.swift
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

185 lines
8.2 KiB
Swift
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.

import AppKit
/// What a card on the freeform board is. The single `.mainText` card hosts the
/// document's real text view (its text == the .txt bytes, never serialized here).
/// Other kinds are board-only objects stored in the sidecar.
enum BoardCardKind: String, Codable {
case mainText, text, image, box
}
enum PageOrientation: String, Codable {
case portrait, landscape
var toggled: PageOrientation { self == .portrait ? .landscape : .portrait }
}
/// A named page-size preset (point dimensions at 72pt/inch). `portrait` here is
/// always width height; orientation swaps for non-square sizes.
enum PageSize: String, Codable, CaseIterable {
case letter, a4, legal, square, wide, note
var label: String {
switch self {
case .letter: return "US Letter"
case .a4: return "A4"
case .legal: return "US Legal"
case .square: return "Square"
case .wide: return "Wide 16:9"
case .note: return "Note"
}
}
var shortLabel: String {
switch self {
case .letter: return "Letter"
case .a4: return "A4"
case .legal: return "Legal"
case .square: return "Square"
case .wide: return "Wide"
case .note: return "Note"
}
}
private var portraitBase: CGSize {
switch self {
case .letter: return CGSize(width: 612, height: 792) // 8.5 × 11"
case .a4: return CGSize(width: 595, height: 842)
case .legal: return CGSize(width: 612, height: 1008) // 8.5 × 14"
case .square: return CGSize(width: 700, height: 700)
case .wide: return CGSize(width: 540, height: 960) // 9:16 portrait of 16:9
case .note: return CGSize(width: 300, height: 220)
}
}
func size(_ orientation: PageOrientation) -> CGSize {
if self == .square { return portraitBase }
let b = portraitBase
return orientation == .landscape ? CGSize(width: b.height, height: b.width) : b
}
static func from(_ raw: String?) -> PageSize { raw.flatMap(PageSize.init(rawValue:)) ?? .letter }
}
/// One object on the board, in world (board) coordinates. Codable with a
/// lenient decoder so a partial or older sidecar never throws.
struct BoardCard: Codable {
var id: UUID
var kind: BoardCardKind
var frame: CGRect // world coords (flipped board, origin top-left)
var z: Int // explicit stacking order
var text: String? // .text annotations only; nil for mainText
var orientation: PageOrientation? // mainText/text page orientation
var pageSize: String? // PageSize raw value for mainText/text cards
var imageFile: String? // relative filename inside foo.txt.board/
var fillHex: String? // .box
var strokeHex: String? // .box
var cornerRadius: CGFloat? // .box
var title: String? // optional label on any card
init(id: UUID = UUID(), kind: BoardCardKind, frame: CGRect, z: Int,
text: String? = nil, orientation: PageOrientation? = nil, pageSize: String? = nil,
imageFile: String? = nil, fillHex: String? = nil, strokeHex: String? = nil,
cornerRadius: CGFloat? = nil, title: String? = nil) {
self.id = id; self.kind = kind; self.frame = frame; self.z = z
self.text = text; self.orientation = orientation; self.pageSize = pageSize
self.imageFile = imageFile; self.fillHex = fillHex; self.strokeHex = strokeHex
self.cornerRadius = cornerRadius; self.title = title
}
enum CodingKeys: String, CodingKey {
case id, kind, frame, z, text, orientation, pageSize, imageFile, fillHex, strokeHex, cornerRadius, title
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = (try? c.decode(UUID.self, forKey: .id)) ?? UUID()
kind = (try? c.decode(BoardCardKind.self, forKey: .kind)) ?? .text
frame = (try? c.decode(CGRect.self, forKey: .frame)) ?? CGRect(x: 80, y: 80, width: 240, height: 160)
z = (try? c.decode(Int.self, forKey: .z)) ?? 0
text = try? c.decodeIfPresent(String.self, forKey: .text)
orientation = try? c.decodeIfPresent(PageOrientation.self, forKey: .orientation)
pageSize = try? c.decodeIfPresent(String.self, forKey: .pageSize)
imageFile = try? c.decodeIfPresent(String.self, forKey: .imageFile)
fillHex = try? c.decodeIfPresent(String.self, forKey: .fillHex)
strokeHex = try? c.decodeIfPresent(String.self, forKey: .strokeHex)
cornerRadius = try? c.decodeIfPresent(CGFloat.self, forKey: .cornerRadius)
title = try? c.decodeIfPresent(String.self, forKey: .title)
}
}
/// The persisted board state for a document. Stored in foo.txt.board/board.json.
/// Reuses the paint canvas's existing Codable doc verbatim for the shapes layer.
struct BoardDoc: Codable {
var version: Int
var cards: [BoardCard]
var shapes: DrawCanvasView.CanvasDoc
var magnification: CGFloat
var scrollOrigin: CGPoint
var boardBackgroundHex: String?
init(version: Int = 1, cards: [BoardCard], shapes: DrawCanvasView.CanvasDoc,
magnification: CGFloat = 1, scrollOrigin: CGPoint = .zero, boardBackgroundHex: String? = nil) {
self.version = version; self.cards = cards; self.shapes = shapes
self.magnification = magnification; self.scrollOrigin = scrollOrigin
self.boardBackgroundHex = boardBackgroundHex
}
enum CodingKeys: String, CodingKey {
case version, cards, shapes, magnification, scrollOrigin, boardBackgroundHex
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
version = (try? c.decode(Int.self, forKey: .version)) ?? 1
cards = (try? c.decode([BoardCard].self, forKey: .cards)) ?? []
shapes = (try? c.decode(DrawCanvasView.CanvasDoc.self, forKey: .shapes))
?? DrawCanvasView.CanvasDoc(backgroundHex: "#00000000", elements: [], width: 0, height: 0)
magnification = (try? c.decode(CGFloat.self, forKey: .magnification)) ?? 1
scrollOrigin = (try? c.decode(CGPoint.self, forKey: .scrollOrigin)) ?? .zero
boardBackgroundHex = try? c.decodeIfPresent(String.self, forKey: .boardBackgroundHex)
}
/// A fresh board with a single empty main page card, centered in the world.
static func makeDefault(worldSize: CGSize,
pageSize: PageSize = .from(AppSettings.boardPageSize),
orientation: PageOrientation = AppSettings.boardOrientation) -> BoardDoc {
let page = pageSize.size(orientation)
let origin = CGPoint(x: (worldSize.width - page.width) / 2,
y: max(120, (worldSize.height - page.height) / 2 - 200))
let main = BoardCard(kind: .mainText,
frame: CGRect(origin: origin, size: page),
z: 0, orientation: orientation, pageSize: pageSize.rawValue)
let empty = DrawCanvasView.CanvasDoc(backgroundHex: "#00000000", elements: [], width: worldSize.width, height: worldSize.height)
return BoardDoc(cards: [main], shapes: empty)
}
}
extension JSONEncoder {
/// Hand-inspectable sidecar JSON, matching the existing canvas sidecar style.
static let boardEncoder: JSONEncoder = {
let e = JSONEncoder()
e.outputFormatting = [.prettyPrinted, .sortedKeys]
return e
}()
}
/// Shared plaintext-safety configuration for any NSTextView used as an editor
/// surface (the document editor and board text cards), so they behave identically.
enum TextViewConfig {
static func harden(_ tv: NSTextView) {
tv.isRichText = false
tv.importsGraphics = false
tv.usesFontPanel = false
tv.allowsUndo = true
tv.isAutomaticQuoteSubstitutionEnabled = false
tv.isAutomaticDashSubstitutionEnabled = false
tv.isAutomaticTextReplacementEnabled = false
tv.isAutomaticSpellingCorrectionEnabled = false
tv.isAutomaticLinkDetectionEnabled = false
tv.isAutomaticDataDetectionEnabled = false
tv.smartInsertDeleteEnabled = false
tv.isContinuousSpellCheckingEnabled = false
tv.enabledTextCheckingTypes = 0
}
}