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