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>
1349 lines
56 KiB
Swift
1349 lines
56 KiB
Swift
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 delete↔undo↔redo 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()
|
||
}
|
||
}
|