import AppKit enum DrawTool: String, CaseIterable { case pen, marker, line, arrow, rect, oval, text, eraser var symbol: String { switch self { case .pen: return "pencil.tip" case .marker: return "highlighter" case .line: return "line.diagonal" case .arrow: return "arrow.up.right" case .rect: return "rectangle" case .oval: return "oval" case .text: return "textformat" case .eraser: return "eraser" } } var label: String { rawValue.capitalized } } /// One drawn element. Freehand tools keep a point list; shapes keep two corner /// points; text keeps an origin + string. Stored as plain values so the whole /// canvas serializes to JSON for an editable sidecar. struct DrawElement: Codable { var tool: String var colorHex: String var alpha: CGFloat var lineWidth: CGFloat var filled: Bool var points: [CGPoint] var text: String var drawTool: DrawTool { DrawTool(rawValue: tool) ?? .pen } var color: NSColor { NSColor(hex: colorHex)?.withAlphaComponent(alpha) ?? .black } } /// The drawing surface: an unflattened vector model rendered each frame, with /// per-element undo/redo and PNG/JSON export. Tool/color/width are driven by /// the palette to its left. final class DrawCanvasView: NSView { var tool: DrawTool = .pen var strokeColor: NSColor = .systemRed var fillEnabled = false var lineWidth: CGFloat = 3 var canvasBackground: NSColor = .white { didSet { needsDisplay = true } } /// When false, the view is transparent to mouse events (used as the board's /// bottom shapes layer so card drags win unless a draw tool is active). var hitTestEnabled = true override func hitTest(_ point: NSPoint) -> NSView? { hitTestEnabled ? super.hitTest(point) : nil } private(set) var elements: [DrawElement] = [] private var current: DrawElement? private let undoMgr = UndoManager() /// Called whenever the model changes, so the host can mark the sidecar dirty. var onChange: (() -> Void)? override var isFlipped: Bool { true } override var acceptsFirstResponder: Bool { true } override var undoManager: UndoManager? { undoMgr } // MARK: - Rendering override func draw(_ dirtyRect: NSRect) { canvasBackground.setFill() bounds.fill() for element in elements { render(element) } if let current { render(current) } } private func render(_ e: DrawElement) { let color = e.color color.setStroke() color.setFill() let path = NSBezierPath() path.lineWidth = e.lineWidth path.lineCapStyle = .round path.lineJoinStyle = .round switch e.drawTool { case .pen, .marker, .eraser: guard let first = e.points.first else { return } path.move(to: first) for p in e.points.dropFirst() { path.line(to: p) } if e.drawTool == .marker { color.withAlphaComponent(min(e.alpha, 0.4)).setStroke() } path.stroke() case .line: guard e.points.count >= 2 else { return } path.move(to: e.points[0]); path.line(to: e.points[1]); path.stroke() case .arrow: guard e.points.count >= 2 else { return } drawArrow(from: e.points[0], to: e.points[1], width: e.lineWidth, color: color) case .rect: guard e.points.count >= 2 else { return } let r = NSRect(corner: e.points[0], to: e.points[1]) let rp = NSBezierPath(rect: r); rp.lineWidth = e.lineWidth if e.filled { color.withAlphaComponent(0.25 * e.alpha).setFill(); rp.fill() } color.setStroke(); rp.stroke() case .oval: guard e.points.count >= 2 else { return } let r = NSRect(corner: e.points[0], to: e.points[1]) let op = NSBezierPath(ovalIn: r); op.lineWidth = e.lineWidth if e.filled { color.withAlphaComponent(0.25 * e.alpha).setFill(); op.fill() } color.setStroke(); op.stroke() case .text: guard let origin = e.points.first else { return } let font = NSFont.systemFont(ofSize: max(11, e.lineWidth * 5)) (e.text as NSString).draw(at: origin, withAttributes: [.font: font, .foregroundColor: color]) } } private func drawArrow(from: NSPoint, to: NSPoint, width: CGFloat, color: NSColor) { let shaft = NSBezierPath() shaft.lineWidth = width shaft.lineCapStyle = .round shaft.move(to: from); shaft.line(to: to) color.setStroke(); shaft.stroke() let angle = atan2(to.y - from.y, to.x - from.x) let headLen = max(10, width * 4) let spread = CGFloat.pi / 7 let head = NSBezierPath() head.move(to: to) head.line(to: NSPoint(x: to.x - headLen * cos(angle - spread), y: to.y - headLen * sin(angle - spread))) head.move(to: to) head.line(to: NSPoint(x: to.x - headLen * cos(angle + spread), y: to.y - headLen * sin(angle + spread))) head.lineWidth = width head.lineCapStyle = .round head.stroke() } // MARK: - Mouse → drawing override func mouseDown(with event: NSEvent) { let p = convert(event.locationInWindow, from: nil) if tool == .text { beginTextEntry(at: p) return } if tool == .eraser { eraseHits(at: p) return } current = DrawElement( tool: tool.rawValue, colorHex: strokeColor.hexString, alpha: strokeColor.alphaComponent, lineWidth: lineWidth, filled: fillEnabled, points: [p], text: "" ) needsDisplay = true } override func mouseDragged(with event: NSEvent) { guard current != nil else { return } let p = convert(event.locationInWindow, from: nil) switch tool { case .pen, .marker: current?.points.append(p) case .eraser: eraseHits(at: p) return default: if current!.points.count < 2 { current?.points.append(p) } else { current?.points[1] = p } } needsDisplay = true } override func mouseUp(with event: NSEvent) { guard let element = current else { return } current = nil // Ignore a stray click that produced no real shape. if element.drawTool != .text, element.points.count < 2, element.drawTool != .pen, element.drawTool != .marker { needsDisplay = true return } commit(element) } // MARK: - Model mutation (undoable) private func commit(_ element: DrawElement) { elements.append(element) undoMgr.registerUndo(withTarget: self) { canvas in canvas.removeLast(restoring: element) } undoMgr.setActionName("Draw \(element.drawTool.label)") needsDisplay = true onChange?() } private func removeLast(restoring element: DrawElement) { guard !elements.isEmpty else { return } let removed = elements.removeLast() undoMgr.registerUndo(withTarget: self) { canvas in canvas.commit(removed) } needsDisplay = true onChange?() } private func eraseHits(at p: NSPoint) { let hitRadius = max(8, lineWidth * 3) let survivors = elements.filter { !$0.hits(point: p, within: hitRadius) } guard survivors.count != elements.count else { return } let before = elements elements = survivors undoMgr.registerUndo(withTarget: self) { canvas in canvas.restore(before) } undoMgr.setActionName("Erase") needsDisplay = true onChange?() } private func restore(_ snapshot: [DrawElement]) { let before = elements elements = snapshot undoMgr.registerUndo(withTarget: self) { canvas in canvas.restore(before) } needsDisplay = true onChange?() } private func beginTextEntry(at p: NSPoint) { let field = NSTextField(frame: NSRect(x: p.x, y: p.y, width: 200, height: 24)) field.font = NSFont.systemFont(ofSize: max(11, lineWidth * 5)) field.textColor = strokeColor field.backgroundColor = .clear field.isBordered = false field.focusRingType = .none field.placeholderString = "type, ⏎" field.target = self field.action = #selector(commitTextField(_:)) field.tag = 0 addSubview(field) window?.makeFirstResponder(field) textEntryOrigin = p activeTextField = field } private var activeTextField: NSTextField? private var textEntryOrigin: NSPoint = .zero @objc private func commitTextField(_ sender: NSTextField) { let value = sender.stringValue sender.removeFromSuperview() activeTextField = nil guard !value.isEmpty else { return } var element = DrawElement(tool: DrawTool.text.rawValue, colorHex: strokeColor.hexString, alpha: strokeColor.alphaComponent, lineWidth: lineWidth, filled: false, points: [textEntryOrigin], text: value) // Nudge baseline so text sits where the click landed. element.points = [NSPoint(x: textEntryOrigin.x, y: textEntryOrigin.y)] commit(element) } // MARK: - Commands func clearAll() { guard !elements.isEmpty else { return } let before = elements elements = [] undoMgr.registerUndo(withTarget: self) { canvas in canvas.restore(before) } undoMgr.setActionName("Clear Canvas") needsDisplay = true onChange?() } func undo() { if undoMgr.canUndo { undoMgr.undo() } } func redo() { if undoMgr.canRedo { undoMgr.redo() } } // MARK: - Export / persistence func pngData() -> Data? { guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil } cacheDisplay(in: bounds, to: rep) return rep.representation(using: .png, properties: [:]) } struct CanvasDoc: Codable { var backgroundHex: String var elements: [DrawElement] var width: CGFloat var height: CGFloat } func serialized() -> Data? { let doc = CanvasDoc(backgroundHex: canvasBackground.hexString, elements: elements, width: bounds.width, height: bounds.height) return try? JSONEncoder().encode(doc) } func load(from data: Data) { guard let doc = try? JSONDecoder().decode(CanvasDoc.self, from: data) else { return } if let bg = NSColor(hex: doc.backgroundHex) { canvasBackground = bg } elements = doc.elements undoMgr.removeAllActions() needsDisplay = true } var isEmpty: Bool { elements.isEmpty } /// Bounding rect of all drawn elements (nil if empty), for fit/export. func contentBounds() -> NSRect? { var rect: NSRect? for e in elements { for p in e.points { let r = NSRect(x: p.x, y: p.y, width: 0, height: 0) rect = rect?.union(r) ?? r } } return rect?.insetBy(dx: -12, dy: -12) } } // MARK: - Geometry helpers private extension DrawElement { func hits(point p: NSPoint, within r: CGFloat) -> Bool { switch drawTool { case .pen, .marker, .eraser, .line, .arrow: for i in 0..= 2 else { return false } return NSRect(corner: points[0], to: points[1]).insetBy(dx: -r, dy: -r).contains(p) case .text: guard let o = points.first else { return false } return NSRect(x: o.x - r, y: o.y - r, width: 200, height: 28).contains(p) } } func distance(_ p: NSPoint, toSegment a: NSPoint, _ b: NSPoint) -> CGFloat { let dx = b.x - a.x, dy = b.y - a.y let lenSq = dx * dx + dy * dy if lenSq == 0 { return p.distance(to: a) } var t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq t = max(0, min(1, t)) return p.distance(to: NSPoint(x: a.x + t * dx, y: a.y + t * dy)) } } extension NSPoint { func distance(to o: NSPoint) -> CGFloat { hypot(x - o.x, y - o.y) } } extension NSRect { init(corner a: NSPoint, to b: NSPoint) { self.init(x: min(a.x, b.x), y: min(a.y, b.y), width: abs(a.x - b.x), height: abs(a.y - b.y)) } } // MARK: - Color hex extension NSColor { /// "#RRGGBB" when opaque, "#RRGGBBAA" when translucent (so box/shape alpha /// survives a save). var hexString: String { guard let c = usingColorSpace(.sRGB) else { return "#000000" } let r = Int(round(c.redComponent * 255)) let g = Int(round(c.greenComponent * 255)) let b = Int(round(c.blueComponent * 255)) let a = Int(round(c.alphaComponent * 255)) return a >= 255 ? String(format: "#%02X%02X%02X", r, g, b) : String(format: "#%02X%02X%02X%02X", r, g, b, a) } convenience init?(hex: String) { var s = hex.trimmingCharacters(in: .whitespaces) if s.hasPrefix("#") { s.removeFirst() } if s.count == 6, let v = UInt32(s, radix: 16) { self.init(srgbRed: CGFloat((v >> 16) & 0xFF) / 255, green: CGFloat((v >> 8) & 0xFF) / 255, blue: CGFloat(v & 0xFF) / 255, alpha: 1) } else if s.count == 8, let v = UInt32(s, radix: 16) { self.init(srgbRed: CGFloat((v >> 24) & 0xFF) / 255, green: CGFloat((v >> 16) & 0xFF) / 255, blue: CGFloat((v >> 8) & 0xFF) / 255, alpha: CGFloat(v & 0xFF) / 255) } else { return nil } } }