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