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

1349 lines
56 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
/// The active board tool. `select` manipulates cards; `box` drops clean
/// rectangles; the rest forward to the freehand shapes layer.
enum BoardTool: Equatable {
case select, box, pen, marker, line, arrow, oval, eraser
static let ordered: [BoardTool] = [.select, .box, .pen, .marker, .line, .arrow, .oval, .eraser]
var name: String {
switch self {
case .select: return "Select"
case .box: return "Box"
case .pen: return "Pen"
case .marker: return "Marker"
case .line: return "Line"
case .arrow: return "Arrow"
case .oval: return "Oval"
case .eraser: return "Eraser"
}
}
var isDraw: Bool {
switch self { case .select, .box: return false; default: return true }
}
var drawTool: DrawTool? {
switch self {
case .pen: return .pen
case .marker: return .marker
case .line: return .line
case .arrow: return .arrow
case .oval: return .oval
case .eraser: return .eraser
default: return nil
}
}
}
// MARK: - Card view
/// One on-board object: a page/text card (bare NSTextView), an image, or a clean
/// box. While idle it swallows clicks so the board can select/drag it; while
/// focused (text editing) it passes clicks through to its text view for the caret.
final class CardView: NSView {
let cardID: UUID
var kind: BoardCardKind
weak var board: BoardView?
private(set) var textView: NSTextView? // text / mainText cards
private(set) var imageView: NSImageView? // image cards
var ownsTextView: Bool // false for the document's main text view
var isFocused = false
var title: String?
// box styling
var fillColor: NSColor = .clear
var strokeColor: NSColor = .clear
var boxCornerRadius: CGFloat = 8
override var isFlipped: Bool { true }
init(card: BoardCard, board: BoardView) {
self.cardID = card.id
self.kind = card.kind
self.board = board
self.ownsTextView = (card.kind != .mainText)
self.title = card.title
super.init(frame: card.frame)
wantsLayer = true
translatesAutoresizingMaskIntoConstraints = true
autoresizesSubviews = true
configure(for: card)
}
required init?(coder: NSCoder) { fatalError() }
private func configure(for card: BoardCard) {
switch card.kind {
case .mainText:
break // the document's real text view is adopted later
case .text:
let tv = NSTextView(frame: bounds.insetBy(dx: 10, dy: 10))
TextViewConfig.harden(tv)
// The document is the delegate so edits route undo through it and
// mark the doc dirty (secondary-page text is saved to the sidecar).
tv.delegate = board?.document
tv.autoresizingMask = [.width, .height]
tv.drawsBackground = false
tv.isEditable = false
tv.isSelectable = false
tv.textContainerInset = NSSize(width: 6, height: 8)
tv.textContainer?.widthTracksTextView = true
tv.isHorizontallyResizable = false
tv.string = card.text ?? ""
addSubview(tv)
textView = tv
case .image:
let iv = NSImageView(frame: bounds)
iv.autoresizingMask = [.width, .height]
iv.imageScaling = .scaleProportionallyUpOrDown
iv.imageFrameStyle = .none
addSubview(iv)
imageView = iv
case .box:
if let f = card.fillHex.flatMap({ NSColor(hex: $0) }) { fillColor = f }
if let s = card.strokeHex.flatMap({ NSColor(hex: $0) }) { strokeColor = s }
if let r = card.cornerRadius { boxCornerRadius = r }
}
}
/// Bind a text view to this card without mounting it as a subview used for
/// the main card, whose text view starts life in the editor.
func assignTextView(_ tv: NSTextView) {
textView = tv
ownsTextView = (kind != .mainText)
}
/// Mount this card's text view as its on-board content (idle, wraps to width).
func mountTextView() {
guard let tv = textView else { return }
tv.removeFromSuperview()
tv.frame = bounds.insetBy(dx: 10, dy: 10)
tv.autoresizingMask = [.width, .height]
tv.drawsBackground = false
tv.textContainerInset = NSSize(width: 6, height: 8)
tv.isHorizontallyResizable = false
tv.textContainer?.widthTracksTextView = true
tv.isEditable = false
tv.isSelectable = false
addSubview(tv)
}
/// Lift this card's text view out (to host it in the editor); keeps the ref.
@discardableResult
func unmountTextView() -> NSTextView? {
textView?.removeFromSuperview()
return textView
}
func setImage(_ image: NSImage) { imageView?.image = image }
// MARK: drawing (page chrome / box fill)
override func draw(_ dirtyRect: NSRect) {
guard let theme = board?.theme else { return }
let r = bounds
switch kind {
case .mainText, .text:
let bg = NSBezierPath(roundedRect: r.insetBy(dx: 1, dy: 1), xRadius: 6, yRadius: 6)
// Page uses the editor's own background so the theme.foreground text
// stays readable in every theme (white page would hide light text).
theme.background.setFill()
bg.fill()
(isFocused ? theme.accent : theme.secondaryText.withAlphaComponent(0.35)).setStroke()
bg.lineWidth = isFocused ? 2 : 1
bg.stroke()
if kind == .text {
// little badge so board-only annotations read as distinct
drawBadge("note", theme: theme)
}
case .image:
let p = NSBezierPath(rect: r)
theme.secondaryText.withAlphaComponent(isFocused ? 0.9 : 0.3).setStroke()
p.lineWidth = isFocused ? 2 : 1
p.stroke()
case .box:
let p = NSBezierPath(roundedRect: r.insetBy(dx: 1, dy: 1), xRadius: boxCornerRadius, yRadius: boxCornerRadius)
fillColor.setFill(); p.fill()
strokeColor.setStroke(); p.lineWidth = 2; p.stroke()
if let title, !title.isEmpty { drawBoxTitle(title, theme: theme) }
}
}
private func drawBadge(_ text: String, theme: EditorTheme) {
let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 9, weight: .semibold),
.foregroundColor: theme.secondaryText,
]
(text as NSString).draw(at: NSPoint(x: 8, y: 4), withAttributes: attrs)
}
private func drawBoxTitle(_ text: String, theme: EditorTheme) {
let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.systemFont(ofSize: 12, weight: .medium),
.foregroundColor: theme.foreground,
]
(text as NSString).draw(at: NSPoint(x: 8, y: 6), withAttributes: attrs)
}
// MARK: hit-testing (the core interaction rule)
override func hitTest(_ point: NSPoint) -> NSView? {
// Focused: caret/selection clicks go to the text view.
if isFocused { return super.hitTest(point) }
// Idle (any tool): transparent so the BoardView owns select/drag/draw/box.
// (Returning self would steal events from the board; returning the text
// view would place a caret instead of selecting the card.)
return nil
}
func snapshotText(into card: inout BoardCard) {
if kind == .text { card.text = textView?.string }
}
}
// MARK: - Selection overlay (visual only; board owns all mouse handling)
final class SelectionOverlayView: NSView {
weak var board: BoardView?
override var isFlipped: Bool { true }
override func hitTest(_ point: NSPoint) -> NSView? { nil } // transparent to events
override func draw(_ dirtyRect: NSRect) {
guard let board else { return }
let theme = board.theme
// live box preview (box tool drag) on top of everything
if let b = board.liveBox {
let p = NSBezierPath(roundedRect: b, xRadius: 8, yRadius: 8)
board.boxFill.withAlphaComponent(0.18).setFill(); p.fill()
board.boxStroke.setStroke(); p.lineWidth = 2; p.stroke()
}
guard let card = board.selectedCardView else { return }
let r = card.frame
let sel = NSBezierPath(rect: r.insetBy(dx: -2, dy: -2))
theme.accent.setStroke()
sel.lineWidth = 1.5
sel.stroke()
for h in board.handleRects(for: r) {
let hp = NSBezierPath(ovalIn: h)
theme.accent.setFill(); hp.fill()
NSColor.white.setStroke(); hp.lineWidth = 1; hp.stroke()
}
}
}
// MARK: - Board view (the zooming world)
final class BoardView: NSView {
static let worldSize = CGSize(width: 8000, height: 6000)
static let handleSize: CGFloat = 9
let shapes = DrawCanvasView(frame: NSRect(origin: .zero, size: BoardView.worldSize))
private let overlay = SelectionOverlayView(frame: NSRect(origin: .zero, size: BoardView.worldSize))
private(set) var cardViews: [CardView] = []
private(set) var cards: [BoardCard] = []
weak var document: Document?
weak var container: BoardContainerView?
var onChange: (() -> Void)? // mark document dirty
var onSelectionChange: (() -> Void)? // update palette enablement
var theme: EditorTheme = .system
var tool: BoardTool = .select {
didSet { applyToolGating() }
}
var boxFill: NSColor = .systemBlue
var boxStroke: NSColor = .systemBlue
private(set) var selectedCardView: CardView?
private(set) var liveBox: NSRect?
private let boardUndo = UndoManager()
override var isFlipped: Bool { true }
override var acceptsFirstResponder: Bool { true }
override var undoManager: UndoManager? { boardUndo }
override init(frame frameRect: NSRect) {
super.init(frame: NSRect(origin: .zero, size: BoardView.worldSize))
wantsLayer = true
shapes.canvasBackground = .clear
shapes.autoresizingMask = []
// Freehand strokes/erases route straight to the shapes layer, bypassing
// BoardView's mouse handling so mark the document dirty from here too,
// or a draw-only session wouldn't prompt to save on close.
shapes.onChange = { [weak self] in self?.markDirty() }
addSubview(shapes) // bottom
overlay.board = self
addSubview(overlay) // top
applyToolGating()
registerForDraggedTypes([.fileURL, .png, .tiff])
}
func viewportCenter() -> NSPoint {
let r = enclosingScrollView?.documentVisibleRect ?? NSRect(origin: .zero, size: BoardView.worldSize)
return NSPoint(x: r.midX, y: r.midY)
}
// MARK: paste / drag-drop images
@objc func paste(_ sender: Any?) {
let pb = NSPasteboard.general
if let images = pb.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage],
let img = images.first {
addImageCard(img, at: viewportCenter())
}
}
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { .copy }
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
let pb = sender.draggingPasteboard
let drop = convert(sender.draggingLocation, from: nil)
if let urls = pb.readObjects(forClasses: [NSURL.self],
options: [.urlReadingContentsConformToTypes: ["public.image"]]) as? [URL],
let url = urls.first, let img = NSImage(contentsOf: url) {
addImageCard(img, at: drop)
return true
}
if let images = pb.readObjects(forClasses: [NSImage.self], options: nil) as? [NSImage],
let img = images.first {
addImageCard(img, at: drop)
return true
}
return false
}
required init?(coder: NSCoder) { fatalError() }
// MARK: model views
func load(doc: BoardDoc) {
var sorted = doc.cards.sorted { $0.z < $1.z }
// Invariant: exactly one main card must exist, or the document's text
// view would have nowhere to live. Synthesize one if a sidecar lacks it.
if !sorted.contains(where: { $0.kind == .mainText }) {
let dims = PageSize.from(AppSettings.boardPageSize).size(.portrait)
let origin = CGPoint(x: (BoardView.worldSize.width - dims.width) / 2, y: 200)
sorted.insert(BoardCard(kind: .mainText, frame: CGRect(origin: origin, size: dims),
z: -1, orientation: .portrait, pageSize: AppSettings.boardPageSize), at: 0)
}
cards = sorted
rebuildCardViews()
shapes.load(from: (try? JSONEncoder().encode(doc.shapes)) ?? Data())
shapes.canvasBackground = .clear
}
var snapshotDoc: BoardDoc {
// pull live frames + secondary text back into the model
var out: [BoardCard] = []
for (i, var card) in cards.enumerated() {
if let v = cardViews.first(where: { $0.cardID == card.id }) {
card.frame = v.frame
if card.kind == .text { card.text = v.textView?.string }
card.z = i
}
if card.kind == .mainText { card.text = nil } // never duplicate the .txt bytes
out.append(card)
}
let shapeDoc = (shapes.serialized()).flatMap { try? JSONDecoder().decode(DrawCanvasView.CanvasDoc.self, from: $0) }
?? DrawCanvasView.CanvasDoc(backgroundHex: "#00000000", elements: [], width: 0, height: 0)
return BoardDoc(cards: out, shapes: shapeDoc)
}
private func rebuildCardViews() {
for v in cardViews { v.removeFromSuperview() }
cardViews = cards.map { CardView(card: $0, board: self) }
// insert below the overlay, above the shapes layer, in model order
for v in cardViews { addSubview(v, positioned: .below, relativeTo: overlay) }
overlay.needsDisplay = true
}
var mainCardView: CardView? { cardViews.first { $0.kind == .mainText } }
// MARK: editor page binding
/// Which card the Editor tab should show: the selected text card, else the
/// main page. Lets the editor follow the on-board selection.
func editorPageCandidate() -> CardView? {
if let s = selectedCardView, s.kind == .text || s.kind == .mainText { return s }
return mainCardView
}
/// Bind the document's text view to the main card (it starts in the editor).
func assignMainTextView(_ tv: NSTextView) {
mainCardView?.assignTextView(tv)
}
/// Theme each on-board text card's text view to the current theme.
func applyTextTheme(font: NSFont, fg: NSColor, caret: NSColor) {
for v in cardViews where v.kind == .text || v.kind == .mainText {
v.textView?.font = font
v.textView?.textColor = fg
v.textView?.insertionPointColor = caret
v.textView?.typingAttributes = [.font: font, .foregroundColor: fg]
}
}
// MARK: tool gating
private func applyToolGating() {
shapes.hitTestEnabled = tool.isDraw
if let dt = tool.drawTool { shapes.tool = dt }
if tool != .select { unfocusCard(commit: true) }
window?.invalidateCursorRects(for: self)
}
func applyDrawStyle(color: NSColor, width: CGFloat, fill: Bool) {
shapes.strokeColor = color
shapes.lineWidth = width
shapes.fillEnabled = fill
}
// MARK: theming
func apply(theme: EditorTheme) {
self.theme = theme
// The board "surface" is the gutter shade so the page card (theme.background)
// stands out against it in every theme.
layer?.backgroundColor = theme.gutterBackground.cgColor
for v in cardViews { v.needsDisplay = true }
overlay.needsDisplay = true
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
theme.gutterBackground.setFill()
dirtyRect.fill()
// dot grid
let spacing: CGFloat = 40
let dot = theme.gutterText.withAlphaComponent(theme.isDark ? 0.18 : 0.25)
dot.setFill()
let startX = (floor(dirtyRect.minX / spacing)) * spacing
let startY = (floor(dirtyRect.minY / spacing)) * spacing
var y = startY
while y <= dirtyRect.maxY {
var x = startX
while x <= dirtyRect.maxX {
NSBezierPath(ovalIn: NSRect(x: x - 1, y: y - 1, width: 2, height: 2)).fill()
x += spacing
}
y += spacing
}
}
// MARK: selection + handles
func handleRects(for r: NSRect) -> [NSRect] {
let s = BoardView.handleSize
let pts = [
NSPoint(x: r.minX, y: r.minY), NSPoint(x: r.midX, y: r.minY), NSPoint(x: r.maxX, y: r.minY),
NSPoint(x: r.minX, y: r.midY), NSPoint(x: r.maxX, y: r.midY),
NSPoint(x: r.minX, y: r.maxY), NSPoint(x: r.midX, y: r.maxY), NSPoint(x: r.maxX, y: r.maxY),
]
return pts.map { NSRect(x: $0.x - s / 2, y: $0.y - s / 2, width: s, height: s) }
}
private func handleIndex(at p: NSPoint, for r: NSRect) -> Int? {
for (i, h) in handleRects(for: r).enumerated() where h.insetBy(dx: -3, dy: -3).contains(p) { return i }
return nil
}
func select(_ card: CardView?) {
if selectedCardView !== card { unfocusCard(commit: true) }
selectedCardView = card
overlay.needsDisplay = true
onSelectionChange?()
}
// MARK: focus
func focusCard(_ card: CardView) {
guard card.kind == .mainText || card.kind == .text, let tv = card.textView else { return }
select(card)
raise(card)
card.isFocused = true
tv.isEditable = true
tv.isSelectable = true
window?.makeFirstResponder(tv)
card.needsDisplay = true
overlay.needsDisplay = true
if let scroll = enclosingScrollView { zoom(scroll, to: card.frame) }
}
@discardableResult
func unfocusCard(commit: Bool) -> Bool {
guard let card = cardViews.first(where: { $0.isFocused }) else { return false }
card.textView?.breakUndoCoalescing()
if commit, card.kind == .text, let idx = cards.firstIndex(where: { $0.id == card.cardID }) {
cards[idx].text = card.textView?.string
markDirty()
}
if card.kind == .text { card.textView?.isEditable = false; card.textView?.isSelectable = false }
else { card.textView?.isEditable = false; card.textView?.isSelectable = false }
card.isFocused = false
card.needsDisplay = true
window?.makeFirstResponder(self)
overlay.needsDisplay = true
return true
}
private func zoom(_ scroll: NSScrollView, to rect: NSRect) {
let target = min(1.4, max(1.0, (scroll.contentView.bounds.width - 80) / max(1, rect.width)))
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.25
ctx.allowsImplicitAnimation = true
scroll.animator().setMagnification(target, centeredAt: NSPoint(x: rect.midX, y: rect.midY))
}
}
private func raise(_ card: CardView) {
guard let idx = cardViews.firstIndex(where: { $0 === card }) else { return }
cardViews.remove(at: idx)
cardViews.append(card)
card.removeFromSuperview()
addSubview(card, positioned: .below, relativeTo: overlay)
syncModelOrder()
}
private func syncModelOrder() {
var reordered: [BoardCard] = []
for v in cardViews {
if let card = cards.first(where: { $0.id == v.cardID }) { reordered.append(card) }
}
cards = reordered
}
// MARK: mutation helpers
func markDirty() {
onChange?()
document?.updateChangeCount(.changeDone)
}
private func updateModelFrame(_ id: UUID, _ frame: NSRect) {
if let i = cards.firstIndex(where: { $0.id == id }) { cards[i].frame = frame }
}
func addBox(_ rect: NSRect) {
let card = BoardCard(kind: .box, frame: rect, z: cards.count,
fillHex: boxFill.withAlphaComponent(0.18).hexString,
strokeHex: boxStroke.hexString, cornerRadius: 8)
addCard(card, undoName: "Add Box")
}
func addTextCard(at center: NSPoint) {
// A new text "doc" matches the current page size / orientation; the user
// can resize it freely afterward.
let ps = mainPageSize, o = mainOrientation
let size = ps.size(o)
// Place it beside existing content (not on top of the main page) so pages
// never overlap; fall back to the given center on an empty board.
let origin: NSPoint
if cardViews.isEmpty {
origin = NSPoint(x: center.x - size.width / 2, y: center.y - size.height / 2)
} else {
let content = contentBounds()
origin = NSPoint(x: content.maxX + 48, y: content.minY)
}
let rect = NSRect(origin: origin, size: size)
let card = BoardCard(kind: .text, frame: rect, z: cards.count, text: "",
orientation: o, pageSize: ps.rawValue)
addCard(card, undoName: "Add Text Page")
scrollToVisible(rect.insetBy(dx: -60, dy: -60)) // reveal the new page
}
func addImageCard(_ image: NSImage, at center: NSPoint) {
var size = image.size
let maxDim: CGFloat = 360
if size.width > maxDim || size.height > maxDim {
let s = maxDim / max(size.width, size.height); size = NSSize(width: size.width * s, height: size.height * s)
}
if size.width < 10 || size.height < 10 { size = NSSize(width: 240, height: 180) }
let id = UUID()
let rect = NSRect(x: center.x - size.width / 2, y: center.y - size.height / 2,
width: size.width, height: size.height)
let card = BoardCard(id: id, kind: .image, frame: rect, z: cards.count, imageFile: "img-\(id).png")
let view = addCard(card, undoName: "Add Image")
view?.setImage(image)
}
func setImage(_ image: NSImage, forCardID id: UUID) {
cardViews.first { $0.cardID == id }?.setImage(image)
}
func image(forCardID id: UUID) -> NSImage? {
cardViews.first { $0.cardID == id }?.imageView?.image
}
@discardableResult
private func addCard(_ card: BoardCard, undoName: String) -> CardView? {
let v = CardView(card: card, board: self)
cards.append(card)
cardViews.append(v)
addSubview(v, positioned: .below, relativeTo: overlay)
boardUndo.registerUndo(withTarget: self) { b in b.extract(v, card: card) }
boardUndo.setActionName(undoName)
select(v)
markDirty()
return v
}
func removeCard(id: UUID, undoName: String = "Delete") {
guard let idx = cardViews.firstIndex(where: { $0.cardID == id }),
cardViews[idx].kind != .mainText,
let card = cards.first(where: { $0.id == id }) else { return }
boardUndo.setActionName(undoName)
extract(cardViews[idx], card: card)
}
/// Remove a card view, undoably. Reuses the LIVE view (preserving its image
/// pixels / text) instead of rebuilding a blank one from the model, and
/// registers a symmetric redo so deleteundoredo all round-trip.
private func extract(_ view: CardView, card: BoardCard) {
if selectedCardView === view { select(nil) }
view.removeFromSuperview()
cardViews.removeAll { $0 === view }
cards.removeAll { $0.id == card.id }
boardUndo.registerUndo(withTarget: self) { b in b.reinsert(view, card: card) }
markDirty()
}
private func reinsert(_ view: CardView, card: BoardCard) {
cards.append(card)
cardViews.append(view)
addSubview(view, positioned: .below, relativeTo: overlay)
boardUndo.registerUndo(withTarget: self) { b in b.extract(view, card: card) }
select(view)
markDirty()
}
func deleteSelection() {
if let c = selectedCardView { removeCard(id: c.cardID, undoName: "Delete Card") }
}
func undo() { if boardUndo.canUndo { boardUndo.undo(); overlay.needsDisplay = true } }
func redo() { if boardUndo.canRedo { boardUndo.redo(); overlay.needsDisplay = true } }
// MARK: orientation + framing
var mainOrientation: PageOrientation { cards.first { $0.kind == .mainText }?.orientation ?? .portrait }
var mainPageSize: PageSize { PageSize.from(cards.first { $0.kind == .mainText }?.pageSize) }
func setMainOrientation(_ o: PageOrientation) {
applyMainPage(size: mainPageSize, orientation: o)
}
func setMainPageSize(_ size: PageSize) {
applyMainPage(size: size, orientation: mainOrientation)
}
/// Resize the main page card to a preset size × orientation, keeping its
/// top-left corner fixed.
private func applyMainPage(size: PageSize, orientation: PageOrientation) {
guard let i = cards.firstIndex(where: { $0.kind == .mainText }) else { return }
let dims = size.size(orientation)
cards[i].orientation = orientation
cards[i].pageSize = size.rawValue
cards[i].frame.size = dims
mainCardView?.frame.size = dims
overlay.needsDisplay = true
markDirty()
}
/// Union of all card frames (the meaningful content), for fit + export.
func contentBounds() -> NSRect {
var rects = cardViews.map { $0.frame }
if let sb = shapes.contentBounds() { rects.append(sb) }
guard var u = rects.first else {
let p = PageSize.letter.size(.portrait)
return NSRect(x: (BoardView.worldSize.width - p.width) / 2, y: 200, width: p.width, height: p.height)
}
for r in rects.dropFirst() { u = u.union(r) }
return u
}
func zoomToFit() {
guard let scroll = enclosingScrollView else { return }
let content = contentBounds().insetBy(dx: -80, dy: -80)
let vp = scroll.contentView.bounds.size
let scale = min(scroll.maxMagnification, max(scroll.minMagnification,
min(vp.width / max(1, content.width), vp.height / max(1, content.height))))
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.25
ctx.allowsImplicitAnimation = true
scroll.animator().setMagnification(scale, centeredAt: NSPoint(x: content.midX, y: content.midY))
}
DispatchQueue.main.async {
self.scrollToVisible(content)
}
}
/// Center the viewport on the main card without changing magnification.
func centerOnMain() {
guard let main = mainCardView else { return }
scrollToVisible(main.frame.insetBy(dx: -60, dy: -60))
}
// MARK: mouse select / drag / resize / box
private enum DragMode { case none, move, resize(Int), box }
private var dragMode: DragMode = .none
private var dragStart = NSPoint.zero
private var origFrame = NSRect.zero
override func mouseDown(with event: NSEvent) {
let p = convert(event.locationInWindow, from: nil)
if tool == .box {
dragMode = .box
dragStart = p
liveBox = NSRect(origin: p, size: .zero)
return
}
// select tool
if let sel = selectedCardView, let h = handleIndex(at: p, for: sel.frame) {
dragMode = .resize(h); dragStart = p; origFrame = sel.frame
return
}
if let hitView = topCard(at: p) {
select(hitView)
if event.clickCount >= 2 { focusCard(hitView); dragMode = .none; return }
dragMode = .move; dragStart = p; origFrame = hitView.frame
} else {
select(nil)
unfocusCard(commit: true)
dragMode = .none
}
}
override func mouseDragged(with event: NSEvent) {
let p = convert(event.locationInWindow, from: nil)
switch dragMode {
case .move:
guard let v = selectedCardView else { return }
var f = origFrame
f.origin.x += p.x - dragStart.x
f.origin.y += p.y - dragStart.y
v.frame = snap(f)
overlay.needsDisplay = true
case .resize(let h):
guard let v = selectedCardView else { return }
v.frame = snap(resized(origFrame, handle: h, by: NSPoint(x: p.x - dragStart.x, y: p.y - dragStart.y)))
overlay.needsDisplay = true
case .box:
liveBox = NSRect(corner: dragStart, to: p)
overlay.needsDisplay = true
case .none:
break
}
}
override func mouseUp(with event: NSEvent) {
switch dragMode {
case .move, .resize:
if let v = selectedCardView {
updateModelFrame(v.cardID, v.frame)
markDirty()
}
case .box:
if let b = liveBox, b.width > 8, b.height > 8 { addBox(b) }
liveBox = nil
overlay.needsDisplay = true
case .none:
break
}
dragMode = .none
}
private func topCard(at p: NSPoint) -> CardView? {
for v in cardViews.reversed() where v.frame.contains(p) { return v }
return nil
}
private func snap(_ r: NSRect) -> NSRect {
guard AppSettings.boardSnap else {
return NSRect(x: r.origin.x, y: r.origin.y, width: max(40, r.width), height: max(30, r.height))
}
let g: CGFloat = 8
return NSRect(x: (r.origin.x / g).rounded() * g, y: (r.origin.y / g).rounded() * g,
width: max(40, (r.width / g).rounded() * g), height: max(30, (r.height / g).rounded() * g))
}
private func resized(_ r: NSRect, handle: Int, by d: NSPoint) -> NSRect {
// handle order: 0 TL,1 TM,2 TR,3 ML,4 MR,5 BL,6 BM,7 BR
let minW: CGFloat = 40, minH: CGFloat = 30
var minX = r.minX, maxX = r.maxX, minY = r.minY, maxY = r.maxY
let left = [0, 3, 5].contains(handle), right = [2, 4, 7].contains(handle)
let top = [0, 1, 2].contains(handle), bottom = [5, 6, 7].contains(handle)
// Move the dragged edge but never past the anchored (opposite) edge
// clamp against min size so the card can't flip or explode.
if left { minX = min(r.minX + d.x, maxX - minW) }
if right { maxX = max(r.maxX + d.x, minX + minW) }
if top { minY = min(r.minY + d.y, maxY - minH) }
if bottom { maxY = max(r.maxY + d.y, minY + minH) }
return NSRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY)
}
override func keyDown(with event: NSEvent) {
switch event.keyCode {
case 53: // esc
if !unfocusCard(commit: true) { select(nil) }
case 51, 117: // delete / fwd-delete
deleteSelection()
case 36: // return focus selected text card
if let c = selectedCardView, c.kind == .mainText || c.kind == .text { focusCard(c) }
default:
super.keyDown(with: event)
}
}
// MARK: right-click context menu
override func menu(for event: NSEvent) -> NSMenu? {
let p = convert(event.locationInWindow, from: nil)
let card = topCard(at: p)
select(card) // right-click selects what's under the cursor
return container?.contextMenu(forCard: card)
}
// MARK: z-order + duplicate (used by the context menu)
func bringToFront(_ card: CardView) {
guard let idx = cardViews.firstIndex(where: { $0 === card }) else { return }
cardViews.remove(at: idx)
cardViews.append(card)
card.removeFromSuperview()
addSubview(card, positioned: .below, relativeTo: overlay)
syncModelOrder(); markDirty(); overlay.needsDisplay = true
}
func sendToBack(_ card: CardView) {
guard let idx = cardViews.firstIndex(where: { $0 === card }) else { return }
cardViews.remove(at: idx)
cardViews.insert(card, at: 0)
card.removeFromSuperview()
addSubview(card, positioned: .above, relativeTo: shapes)
syncModelOrder(); markDirty(); overlay.needsDisplay = true
}
func duplicateSelected() {
guard let v = selectedCardView, v.kind != .mainText,
let card = cards.first(where: { $0.id == v.cardID }) else { return }
var copy = card
copy.id = UUID()
copy.frame = card.frame.offsetBy(dx: 28, dy: 28)
copy.z = cards.count
if copy.kind == .image { copy.imageFile = "img-\(copy.id).png" }
let view = addCard(copy, undoName: "Duplicate")
if copy.kind == .image, let img = v.imageView?.image { view?.setImage(img) }
}
func focusSelected() {
if let c = selectedCardView, c.kind == .mainText || c.kind == .text { focusCard(c) }
}
}
// MARK: - Board scroll view (modifier-aware wheel)
/// Wheel behavior in board mode: zooms in/out centered on the cursor,
/// scrolls horizontally, scrolls vertically; a plain wheel keeps the
/// default scrolling. Pan amounts are divided by magnification so the felt
/// speed stays constant at any zoom.
final class BoardScrollView: NSScrollView {
override func scrollWheel(with event: NSEvent) {
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let precise = event.hasPreciseScrollingDeltas
let dy = precise ? event.scrollingDeltaY : event.deltaY * 24
let dx = precise ? event.scrollingDeltaX : event.deltaX * 24
if flags.contains(.control) {
let raw = precise ? event.scrollingDeltaY : event.deltaY
guard raw != 0 else { return }
let factor = 1 + raw * (precise ? 0.004 : 0.05)
let newMag = max(minMagnification, min(maxMagnification, magnification * factor))
if let doc = documentView {
setMagnification(newMag, centeredAt: doc.convert(event.locationInWindow, from: nil))
}
return
}
if flags.contains(.option) {
// Map the (usually vertical) wheel onto the X axis.
pan(dx: -(dy != 0 ? dy : dx), dy: 0)
return
}
if flags.contains(.command) {
pan(dx: 0, dy: -dy)
return
}
super.scrollWheel(with: event)
}
private func pan(dx: CGFloat, dy: CGFloat) {
let m = max(magnification, 0.0001)
let clip = contentView
var origin = clip.bounds.origin
origin.x += dx / m
origin.y += dy / m
let constrained = clip.constrainBoundsRect(NSRect(origin: origin, size: clip.bounds.size))
clip.scroll(to: constrained.origin)
reflectScrolledClipView(clip)
}
}
// MARK: - Board container (palette + magnifying scroll view)
/// Hosts the freeform board: a fixed left tool palette plus a magnifying scroll
/// view whose document view is the BoardView world. Mirrors CanvasContainerView's
/// palette idiom and absorbs the old paint canvas (the board's shapes layer).
final class BoardContainerView: NSView {
let boardView = BoardView(frame: .zero)
let boardScroll = BoardScrollView()
private let palette = NSStackView()
private var theme: EditorTheme = .system
weak var document: Document? {
didSet { boardView.document = document }
}
var onStatus: ((String) -> Void)?
var onSendToTailnet: ((Data) -> Void)?
var onStatusChange: (() -> Void)? // pan/zoom/tool/selection refresh status bar
var currentTool: BoardTool { tool }
var zoomPercent: Int { Int((boardScroll.magnification * 100).rounded()) }
private var tool: BoardTool = .select
private var toolButtons: [BoardTool: NSButton] = [:]
private var strokeWell: NSColorWell?
private var orientationSeg: NSSegmentedControl?
private var pageSizeButton: NSButton?
private let paletteWidth: CGFloat = 60
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
setup()
}
required init?(coder: NSCoder) { fatalError() }
deinit { NotificationCenter.default.removeObserver(self) }
@objc private func boardViewportChanged() { onStatusChange?() }
private func setup() {
boardView.container = self
boardScroll.translatesAutoresizingMaskIntoConstraints = false
boardScroll.hasVerticalScroller = true
boardScroll.hasHorizontalScroller = true
boardScroll.borderType = .noBorder
boardScroll.allowsMagnification = true
boardScroll.minMagnification = 0.15
boardScroll.maxMagnification = 4
boardScroll.documentView = boardView
addSubview(boardScroll)
// Status updates on pan/zoom.
boardScroll.contentView.postsBoundsChangedNotifications = true
NotificationCenter.default.addObserver(self, selector: #selector(boardViewportChanged),
name: NSView.boundsDidChangeNotification,
object: boardScroll.contentView)
palette.orientation = .vertical
palette.alignment = .centerX
palette.spacing = 5
palette.edgeInsets = NSEdgeInsets(top: 8, left: 5, bottom: 8, right: 5)
palette.translatesAutoresizingMaskIntoConstraints = false
palette.wantsLayer = true
addSubview(palette)
buildPalette()
NSLayoutConstraint.activate([
palette.leadingAnchor.constraint(equalTo: leadingAnchor),
palette.topAnchor.constraint(equalTo: topAnchor),
palette.bottomAnchor.constraint(equalTo: bottomAnchor),
palette.widthAnchor.constraint(equalToConstant: paletteWidth),
boardScroll.leadingAnchor.constraint(equalTo: palette.trailingAnchor),
boardScroll.trailingAnchor.constraint(equalTo: trailingAnchor),
boardScroll.topAnchor.constraint(equalTo: topAnchor),
boardScroll.bottomAnchor.constraint(equalTo: bottomAnchor),
])
boardView.onSelectionChange = { [weak self] in self?.refreshPageControls(); self?.onStatusChange?() }
applyColor(strokeWell?.color ?? .systemBlue) // sync pen to the well at launch
selectTool(.select)
apply(theme: .system)
}
private let toolOrder: [(BoardTool, String, String)] = [
(.select, "cursorarrow", "Select / move / resize"),
(.box, "rectangle", "Clean box"),
(.pen, "pencil.tip", "Pen"),
(.marker, "highlighter", "Marker"),
(.line, "line.diagonal", "Line"),
(.arrow, "arrow.up.right", "Arrow"),
(.oval, "oval", "Oval"),
(.eraser, "eraser", "Eraser"),
]
private func buildPalette() {
for (t, sym, tip) in toolOrder {
let b = paletteButton(symbol: sym, tooltip: tip)
b.target = self; b.action = #selector(toolTapped(_:))
b.tag = toolOrder.firstIndex { $0.0 == t }!
toolButtons[t] = b
palette.addArrangedSubview(b)
}
palette.addArrangedSubview(separator())
let stroke = NSColorWell()
stroke.color = .systemBlue
stroke.target = self; stroke.action = #selector(strokeChanged(_:))
stroke.toolTip = "Color for pen, shapes & boxes — click to pick"
constrainSquare(stroke, 30)
strokeWell = stroke
palette.addArrangedSubview(stroke)
let eyedrop = paletteButton(symbol: "eyedropper", tooltip: "Grab a color from anywhere on screen")
eyedrop.target = self; eyedrop.action = #selector(pickScreenColor)
palette.addArrangedSubview(eyedrop)
let width = NSSlider(value: 3, minValue: 1, maxValue: 24, target: self, action: #selector(widthChanged(_:)))
width.toolTip = "Stroke width"
width.translatesAutoresizingMaskIntoConstraints = false
width.widthAnchor.constraint(equalToConstant: 48).isActive = true
palette.addArrangedSubview(width)
palette.addArrangedSubview(separator())
let newText = paletteButton(symbol: "doc.badge.plus", tooltip: "New text page (current page size)")
newText.target = self; newText.action = #selector(newTextCard); palette.addArrangedSubview(newText)
let addImage = paletteButton(symbol: "photo", tooltip: "Add image…")
addImage.target = self; addImage.action = #selector(addImage(_:)); palette.addArrangedSubview(addImage)
let pageBtn = paletteButton(symbol: "ruler", tooltip: "Main page size")
pageBtn.target = self; pageBtn.action = #selector(showPageSizeMenu(_:))
pageSizeButton = pageBtn
palette.addArrangedSubview(pageBtn)
let orient = NSSegmentedControl(labels: ["P", "L"], trackingMode: .selectOne,
target: self, action: #selector(orientationChanged(_:)))
orient.toolTip = "Portrait / Landscape page"
orient.selectedSegment = 0
orient.translatesAutoresizingMaskIntoConstraints = false
orient.widthAnchor.constraint(equalToConstant: 48).isActive = true
orientationSeg = orient
palette.addArrangedSubview(orient)
palette.addArrangedSubview(separator())
let fit = paletteButton(symbol: "arrow.up.left.and.down.right.magnifyingglass", tooltip: "Zoom to fit")
fit.target = self; fit.action = #selector(zoomFit); palette.addArrangedSubview(fit)
let undo = paletteButton(symbol: "arrow.uturn.backward", tooltip: "Undo board change")
undo.target = self; undo.action = #selector(undoTapped); palette.addArrangedSubview(undo)
let redo = paletteButton(symbol: "arrow.uturn.forward", tooltip: "Redo board change")
redo.target = self; redo.action = #selector(redoTapped); palette.addArrangedSubview(redo)
palette.addArrangedSubview(separator())
let export = paletteButton(symbol: "square.and.arrow.down", tooltip: "Export board PNG…")
export.target = self; export.action = #selector(exportTapped); palette.addArrangedSubview(export)
let send = paletteButton(symbol: "paperplane", tooltip: "Send board to tailnet")
send.target = self; send.action = #selector(sendTapped); palette.addArrangedSubview(send)
}
private func paletteButton(symbol: String, tooltip: String) -> NSButton {
let b = NSButton()
b.bezelStyle = .regularSquare
b.isBordered = true
b.setButtonType(.momentaryPushIn)
b.image = NSImage(systemSymbolName: symbol, accessibilityDescription: tooltip)
b.imageScaling = .scaleProportionallyDown
b.toolTip = tooltip
constrainSquare(b, 30)
return b
}
private func separator() -> NSView {
let v = NSBox(); v.boxType = .separator
v.translatesAutoresizingMaskIntoConstraints = false
v.widthAnchor.constraint(equalToConstant: 42).isActive = true
return v
}
private func constrainSquare(_ v: NSView, _ side: CGFloat) {
v.translatesAutoresizingMaskIntoConstraints = false
v.widthAnchor.constraint(equalToConstant: side).isActive = true
v.heightAnchor.constraint(equalToConstant: side).isActive = true
}
// MARK: actions
@objc private func toolTapped(_ sender: NSButton) {
selectTool(toolOrder[sender.tag].0)
}
func selectTool(_ t: BoardTool) {
tool = t
boardView.tool = t
for (bt, b) in toolButtons {
b.contentTintColor = (bt == t) ? theme.accent : nil
b.layer?.backgroundColor = (bt == t) ? theme.accent.withAlphaComponent(0.16).cgColor : nil
}
onStatusChange?()
}
@objc private func strokeChanged(_ s: NSColorWell) {
applyColor(s.color)
}
/// Single source of truth for the active color drives the well, the box
/// fill/stroke, and the freehand pen/shape color together.
func applyColor(_ color: NSColor) {
strokeWell?.color = color
boardView.boxFill = color
boardView.boxStroke = color
boardView.applyDrawStyle(color: color, width: boardView.shapes.lineWidth, fill: boardView.shapes.fillEnabled)
}
/// Native macOS screen color sampler click anywhere on screen (the loupe)
/// to grab a color and apply it to pen, shapes, and boxes.
@objc private func pickScreenColor() {
NSColorSampler().show { [weak self] picked in
guard let self, let picked else { return }
self.applyColor(picked)
}
}
@objc private func widthChanged(_ s: NSSlider) {
boardView.applyDrawStyle(color: boardView.shapes.strokeColor, width: CGFloat(s.doubleValue), fill: boardView.shapes.fillEnabled)
}
@objc private func newTextCard() {
boardView.addTextCard(at: viewportCenterInWorld())
selectTool(.select)
}
@objc private func orientationChanged(_ s: NSSegmentedControl) {
boardView.setMainOrientation(s.selectedSegment == 1 ? .landscape : .portrait)
}
@objc private func showPageSizeMenu(_ sender: NSButton) {
let menu = NSMenu()
let current = boardView.mainPageSize
for ps in PageSize.allCases {
let item = NSMenuItem(title: ps.label, action: #selector(pageSizePicked(_:)), keyEquivalent: "")
item.target = self
item.representedObject = ps.rawValue
item.state = (ps == current) ? .on : .off
menu.addItem(item)
}
menu.popUp(positioning: nil, at: NSPoint(x: sender.bounds.maxX, y: sender.bounds.midY), in: sender)
}
@objc private func pageSizePicked(_ item: NSMenuItem) {
guard let raw = item.representedObject as? String else { return }
boardView.setMainPageSize(PageSize.from(raw))
refreshPageControls()
}
/// Sync the orientation control + page-size tooltip to the loaded main page.
func refreshPageControls() {
orientationSeg?.selectedSegment = boardView.mainOrientation == .landscape ? 1 : 0
pageSizeButton?.toolTip = "Main page size — \(boardView.mainPageSize.label)"
}
@objc private func zoomFit() { boardView.zoomToFit(); onStatusChange?() }
@objc private func undoTapped() { boardView.undo() }
@objc private func redoTapped() { boardView.redo() }
@objc private func zoomIn() { setZoom(boardScroll.magnification * 1.25) }
@objc private func zoomOut() { setZoom(boardScroll.magnification / 1.25) }
@objc private func actualSize() { setZoom(1) }
private func setZoom(_ m: CGFloat) {
let clamped = max(boardScroll.minMagnification, min(boardScroll.maxMagnification, m))
boardScroll.setMagnification(clamped, centeredAt: boardView.viewportCenter())
onStatusChange?()
}
// MARK: - Right-click context menu (full feature access from the mouse)
private let colorSwatches: [(String, NSColor)] = [
("Red", .systemRed), ("Orange", .systemOrange), ("Yellow", .systemYellow),
("Green", .systemGreen), ("Blue", .systemBlue), ("Purple", .systemPurple),
("Pink", .systemPink), ("Black", .black), ("Gray", .systemGray), ("White", .white),
]
func contextMenu(forCard card: CardView?) -> NSMenu {
let menu = NSMenu()
menu.autoenablesItems = false
if let card { addCardSection(menu, card: card); menu.addItem(.separator()) }
addBoardSection(menu)
return menu
}
private func mi(_ title: String, _ action: Selector?, obj: Any? = nil,
checked: Bool = false, enabled: Bool = true) -> NSMenuItem {
let it = NSMenuItem(title: title, action: action, keyEquivalent: "")
it.target = self
it.representedObject = obj
it.state = checked ? .on : .off
it.isEnabled = enabled
return it
}
private func submenu(_ title: String, _ build: (NSMenu) -> Void) -> NSMenuItem {
let parent = NSMenuItem(title: title, action: nil, keyEquivalent: "")
let sub = NSMenu(title: title)
sub.autoenablesItems = false
build(sub)
parent.submenu = sub
return parent
}
private func addCardSection(_ menu: NSMenu, card: CardView) {
let kindName: String
switch card.kind {
case .mainText: kindName = "Main page"
case .text: kindName = "Text page"
case .image: kindName = "Image"
case .box: kindName = "Box"
}
menu.addItem(mi(kindName, nil, enabled: false))
if card.kind == .mainText || card.kind == .text {
menu.addItem(mi("Edit Text", #selector(menuFocus)))
}
menu.addItem(.separator())
menu.addItem(mi("Bring to Front", #selector(menuBringFront)))
menu.addItem(mi("Send to Back", #selector(menuSendBack)))
if card.kind != .mainText {
menu.addItem(mi("Duplicate", #selector(menuDuplicate)))
menu.addItem(mi("Delete", #selector(menuDelete)))
}
}
private func addBoardSection(_ menu: NSMenu) {
menu.addItem(submenu("Tools") { sub in
for t in BoardTool.ordered {
sub.addItem(self.mi(t.name, #selector(self.menuTool(_:)), obj: t, checked: t == self.tool))
}
})
menu.addItem(.separator())
menu.addItem(mi("New Text Page", #selector(newTextCard)))
menu.addItem(mi("Add Image…", #selector(addImage(_:))))
let hasImg = NSPasteboard.general.canReadObject(forClasses: [NSImage.self], options: nil)
menu.addItem(mi("Paste Image", #selector(menuPaste), enabled: hasImg))
menu.addItem(.separator())
menu.addItem(submenu("Page") { sub in
sub.addItem(self.submenu("Size") { s2 in
for ps in PageSize.allCases {
s2.addItem(self.mi(ps.label, #selector(self.menuPageSize(_:)), obj: ps.rawValue,
checked: ps == self.boardView.mainPageSize))
}
})
sub.addItem(self.submenu("Orientation") { s2 in
s2.addItem(self.mi("Portrait", #selector(self.menuOrientation(_:)), obj: "portrait",
checked: self.boardView.mainOrientation == .portrait))
s2.addItem(self.mi("Landscape", #selector(self.menuOrientation(_:)), obj: "landscape",
checked: self.boardView.mainOrientation == .landscape))
})
})
menu.addItem(submenu("Zoom") { sub in
sub.addItem(self.mi("Zoom In", #selector(self.zoomIn)))
sub.addItem(self.mi("Zoom Out", #selector(self.zoomOut)))
sub.addItem(self.mi("Zoom to Fit", #selector(self.zoomFit)))
sub.addItem(self.mi("Actual Size (100%)", #selector(self.actualSize)))
})
menu.addItem(submenu("Color") { sub in
for (name, color) in self.colorSwatches {
sub.addItem(self.mi(name, #selector(self.menuColor(_:)), obj: color))
}
sub.addItem(.separator())
sub.addItem(self.mi("Pick from Screen…", #selector(self.pickScreenColor)))
sub.addItem(self.mi("Show Color Picker…", #selector(self.menuShowColors)))
})
menu.addItem(submenu("View Mode") { sub in
sub.addItem(self.mi("Editor", #selector(self.menuModeEditor)))
sub.addItem(self.mi("Editor + Preview", #selector(self.menuModeSplit)))
sub.addItem(self.mi("Board", #selector(self.menuModeBoard), checked: true))
})
menu.addItem(submenu("Theme") { sub in
let cur = AppSettings.theme.id
for th in EditorTheme.all {
sub.addItem(self.mi(th.name, #selector(self.menuTheme(_:)), obj: th.id, checked: th.id == cur))
}
})
menu.addItem(.separator())
menu.addItem(mi("Undo", #selector(undoTapped)))
menu.addItem(mi("Redo", #selector(redoTapped)))
menu.addItem(.separator())
menu.addItem(mi("Export Board PNG…", #selector(exportTapped)))
menu.addItem(mi("Send Board to Tailnet", #selector(sendTapped)))
}
@objc private func menuTool(_ s: NSMenuItem) { if let t = s.representedObject as? BoardTool { selectTool(t) } }
@objc private func menuPaste() { boardView.paste(nil) }
@objc private func menuPageSize(_ s: NSMenuItem) {
if let raw = s.representedObject as? String { boardView.setMainPageSize(PageSize.from(raw)); refreshPageControls() }
}
@objc private func menuOrientation(_ s: NSMenuItem) {
if let raw = s.representedObject as? String, let o = PageOrientation(rawValue: raw) {
boardView.setMainOrientation(o); refreshPageControls()
}
}
@objc private func menuColor(_ s: NSMenuItem) { if let c = s.representedObject as? NSColor { applyColor(c) } }
@objc private func menuShowColors() { strokeWell?.activate(true) }
@objc private func menuFocus() { boardView.focusSelected() }
@objc private func menuBringFront() { if let c = boardView.selectedCardView { boardView.bringToFront(c) } }
@objc private func menuSendBack() { if let c = boardView.selectedCardView { boardView.sendToBack(c) } }
@objc private func menuDuplicate() { boardView.duplicateSelected() }
@objc private func menuDelete() { boardView.deleteSelection() }
@objc private func menuModeEditor() { document?.showEditorMode(nil) }
@objc private func menuModeSplit() { document?.showSplitMode(nil) }
@objc private func menuModeBoard() { document?.showBoardMode(nil) }
@objc private func menuTheme(_ s: NSMenuItem) { if let id = s.representedObject as? String { AppSettings.set(AppSettings.themeKey, id) } }
@objc private func addImage(_ sender: Any?) {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.png, .jpeg, .tiff, .gif, .heic, .image]
panel.allowsMultipleSelection = false
let complete: (NSApplication.ModalResponse) -> Void = { [weak self] resp in
guard resp == .OK, let url = panel.url, let self else { return }
self.document?.importImageToBoard(url: url, at: self.viewportCenterInWorld())
}
if let w = window { panel.beginSheetModal(for: w, completionHandler: complete) }
else { complete(panel.runModal()) }
}
@objc private func exportTapped() {
guard let data = boardPNG() else { onStatus?("Couldn't render board"); return }
let panel = NSSavePanel()
panel.nameFieldStringValue = "board.png"
panel.allowedContentTypes = [.png]
let complete: (NSApplication.ModalResponse) -> Void = { resp in
guard resp == .OK, let url = panel.url else { return }
do { try data.write(to: url); self.onStatus?("Exported \(url.lastPathComponent)") }
catch { self.onStatus?("Export failed: \(error.localizedDescription)") }
}
if let w = window { panel.beginSheetModal(for: w, completionHandler: complete) }
else { complete(panel.runModal()) }
}
@objc private func sendTapped() {
guard let data = boardPNG() else { onStatus?("Couldn't render board"); return }
onSendToTailnet?(data)
}
/// Render the content region of the board to PNG at 1x (not the zoomed view,
/// not the whole 8000×6000 world).
func boardPNG() -> Data? {
boardView.unfocusCard(commit: true)
let region = boardView.contentBounds().insetBy(dx: -20, dy: -20).integral
guard region.width > 1, region.height > 1,
let rep = boardView.bitmapImageRepForCachingDisplay(in: region) else { return nil }
boardView.cacheDisplay(in: region, to: rep)
return rep.representation(using: .png, properties: [:])
}
private func viewportCenterInWorld() -> NSPoint {
let v = boardScroll.contentView.bounds
return NSPoint(x: v.midX, y: v.midY)
}
func apply(theme: EditorTheme) {
self.theme = theme
palette.layer?.backgroundColor = theme.gutterBackground.cgColor
boardScroll.backgroundColor = theme.isDark ? EditorTheme.rgb(26, 28, 34) : EditorTheme.rgb(228, 229, 234)
boardScroll.drawsBackground = true
boardView.apply(theme: theme)
selectTool(tool)
refreshPageControls()
}
}