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 } }