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>
396 lines
14 KiB
Swift
396 lines
14 KiB
Swift
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..<max(0, points.count - 1) {
|
|
if distance(p, toSegment: points[i], points[i + 1]) <= r + lineWidth { return true }
|
|
}
|
|
if let only = points.first, points.count == 1 { return only.distance(to: p) <= r }
|
|
return false
|
|
case .rect, .oval:
|
|
guard points.count >= 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
|
|
}
|
|
}
|
|
}
|