textplus/Sources/DrawingCanvas.swift
monster 09972ccde1 TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board
A no-Xcode AppKit app (swiftc + build.sh):
- Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find,
  live Markdown preview (split mode), selection-aware Markdown toolbar.
- Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape,
  US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel),
  freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich
  status bar. The Editor follows the selected page; layout persists to a
  foo.txt.board sidecar; the .txt stays pure plaintext.
- scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain).
- HTML scraping pipeline: custom selector engine + Discogs profile, ingests to
  Postgres (psql) into a marketplace_snapshots schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:08:59 +10:00

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