1import AppKit
2
3/// Storyboard panel editor: a canvas you draw on. Sketch tools (pencil, pen,
4/// thick pen, eraser) paint the raster layer; the shape tool family (rect,
5/// oval, triangle, star, n-gon, text, image ref) adds editable objects to a
6/// vector layer that renders below the raster unless brought to top.
7enum BoardTool: CaseIterable {
8 case select, pencil, pen, thick, eraser,
9 rect, oval, triangle, star, ngon, text, image
10
11 var label: String {
12 switch self {
13 case .select: return "Select"
14 case .pencil: return "Pencil"
15 case .pen: return "Pen"
16 case .thick: return "Thick Pen"
17 case .eraser: return "Eraser"
18 case .rect: return "Rectangle"
19 case .oval: return "Oval"
20 case .triangle: return "Triangle"
21 case .star: return "Star"
22 case .ngon: return "N-gon"
23 case .text: return "Text"
24 case .image: return "Image"
25 }
26 }
27 var symbol: String {
28 switch self {
29 case .select: return "cursorarrow"
30 case .pencil: return "pencil"
31 case .pen: return "pencil.tip"
32 case .thick: return "paintbrush.pointed.fill"
33 case .eraser: return "eraser"
34 case .rect: return "rectangle"
35 case .oval: return "oval"
36 case .triangle: return "triangle"
37 case .star: return "star"
38 case .ngon: return "pentagon"
39 case .text: return "textformat"
40 case .image: return "photo"
41 }
42 }
43 var strokeWidth: CGFloat? {
44 switch self {
45 case .pencil: return 2
46 case .pen: return 4.5
47 case .thick: return 11
48 case .eraser: return 26
49 default: return nil
50 }
51 }
52 var isDraw: Bool { strokeWidth != nil }
53 var isShape: Bool {
54 [.rect, .oval, .triangle, .star, .ngon, .text, .image].contains(self)
55 }
56 var shapeKind: BoardShape.Kind? {
57 switch self {
58 case .rect: return .rect
59 case .oval: return .oval
60 case .triangle: return .triangle
61 case .star: return .star
62 case .ngon: return .ngon
63 case .text: return .text
64 case .image: return .image
65 default: return nil
66 }
67 }
68}
69
70/// Button that fires on mouse-down without a cell tracking loop. A tracking
71/// loop that loses its mouse-up (synthetic events, activation clicks) wedges
72/// and swallows every later click in the window; a palette button has no
73/// business tracking anyway.
74final class InstantButton: NSButton {
75 var togglesState = false
76 /// Instant themed tooltip text (no system hover delay).
77 var tipText: String? { didSet { updateTrackingAreas() } }
78 /// Fired on mouse-enter (the toolbar color swatch opens its picker here).
79 var onHover: (() -> Void)?
80
81 /// When set, the button reports a fixed NxN intrinsic size, so its bounds —
82 /// and the rounded highlight background that fills them — stay square no
83 /// matter the glyph's aspect ratio. A plain size constraint isn't enough:
84 /// the glyph-derived intrinsic size fights it and Auto Layout breaks the
85 /// constraint per-button, so wide symbols (film, scissors) render as
86 /// rectangles while square images (the magnet) look fine. Fixing the
87 /// intrinsic size removes the conflict at the source.
88 var squareSide: CGFloat? { didSet { invalidateIntrinsicContentSize() } }
89 override var intrinsicContentSize: NSSize {
90 if let s = squareSide { return NSSize(width: s, height: s) }
91 return super.intrinsicContentSize
92 }
93
94 override func mouseDown(with event: NSEvent) {
95 guard isEnabled else { return }
96 InstantTip.hide()
97 if togglesState { state = state == .on ? .off : .on }
98 if let action { NSApp.sendAction(action, to: target, from: self) }
99 }
100
101 override func updateTrackingAreas() {
102 super.updateTrackingAreas()
103 trackingAreas.filter { $0.owner === self }.forEach(removeTrackingArea)
104 if tipText != nil || onHover != nil {
105 addTrackingArea(NSTrackingArea(
106 rect: bounds,
107 options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
108 owner: self, userInfo: nil))
109 }
110 }
111
112 override func mouseEntered(with event: NSEvent) {
113 // Moving onto any other button dismisses an open color picker.
114 ColorPickerPanel.close(unlessAnchor: self)
115 if isEnabled, let tip = tipText { InstantTip.show(tip, for: self) }
116 if isEnabled { onHover?() }
117 }
118 override func mouseExited(with event: NSEvent) {
119 InstantTip.hide()
120 }
121}
122
123final class StoryboardEditor: NSObject, NSWindowDelegate {
124 static let shared = StoryboardEditor()
125 static let windowID = NSUserInterfaceItemIdentifier("StoryboardEditor")
126
127 private(set) var window: NSWindow?
128 private var canvas: BoardCanvas?
129 private var toolButtons: [BoardTool: NSButton] = [:]
130 private var sidesPopup: NSPopUpButton?
131 private var fillCheck: NSButton?
132 private var colorWell: NSColorWell?
133
134 var isKeyEditor: Bool { window != nil && NSApp.keyWindow === window }
135
136 /// Open on a panel belonging to `ctx`'s document. The single editor window
137 /// re-targets to whichever document asked for it.
138 func open(clipId: UUID, ctx: DocumentContext) {
139 buildWindowIfNeeded()
140 canvas?.ctx = ctx
141 canvas?.clipId = clipId
142 syncToolbar()
143 window?.makeKeyAndOrderFront(nil)
144 if let canvas { window?.makeFirstResponder(canvas) }
145 }
146
147 func windowWillClose(_ notification: Notification) {
148 canvas?.commitTextEditing()
149 }
150
151 // MARK: - UI construction
152
153 private func buildWindowIfNeeded() {
154 guard window == nil else { return }
155 let w = NSWindow(
156 contentRect: NSRect(x: 0, y: 0, width: 1120, height: 780),
157 styleMask: [.titled, .closable, .resizable],
158 backing: .buffered, defer: false)
159 w.title = "Storyboard"
160 w.identifier = Self.windowID
161 w.isReleasedWhenClosed = false
162 w.minSize = NSSize(width: 760, height: 520)
163 w.setFrameAutosaveName("StoryboardEditor")
164 w.delegate = self
165
166 let canvas = BoardCanvas()
167 self.canvas = canvas
168 canvas.onToolChanged = { [weak self] in self?.syncToolbar() }
169
170 let bar = buildToolbar()
171 let content = NSView()
172 bar.translatesAutoresizingMaskIntoConstraints = false
173 canvas.translatesAutoresizingMaskIntoConstraints = false
174 content.addSubview(bar)
175 content.addSubview(canvas)
176 NSLayoutConstraint.activate([
177 bar.topAnchor.constraint(equalTo: content.topAnchor),
178 bar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
179 bar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
180 bar.heightAnchor.constraint(equalToConstant: 38),
181 canvas.topAnchor.constraint(equalTo: bar.bottomAnchor),
182 canvas.leadingAnchor.constraint(equalTo: content.leadingAnchor),
183 canvas.trailingAnchor.constraint(equalTo: content.trailingAnchor),
184 canvas.bottomAnchor.constraint(equalTo: content.bottomAnchor),
185 ])
186 w.contentView = content
187 window = w
188 }
189
190 private func buildToolbar() -> NSView {
191 let bar = NSView()
192 bar.wantsLayer = true
193 bar.layer?.backgroundColor = Theme.barBg.cgColor
194 NotificationCenter.default.addObserver(forName: .themeChanged, object: nil,
195 queue: .main) { [weak bar] _ in
196 bar?.layer?.backgroundColor = Theme.barBg.cgColor
197 }
198
199 var views: [NSView] = []
200 for tool in BoardTool.allCases {
201 let b = InstantButton(image: NSImage(systemSymbolName: tool.symbol,
202 accessibilityDescription: tool.label)
203 ?? NSImage(),
204 target: self, action: #selector(pickTool(_:)))
205 b.isBordered = false
206 b.setButtonType(.momentaryChange)
207 b.wantsLayer = true
208 b.layer?.cornerRadius = 5
209 b.widthAnchor.constraint(equalToConstant: 26).isActive = true
210 b.heightAnchor.constraint(equalToConstant: 22).isActive = true
211 b.toolTip = tool.label
212 toolButtons[tool] = b
213 views.append(b)
214 if tool == .eraser {
215 let sep = NSBox()
216 sep.boxType = .separator
217 views.append(sep)
218 }
219 }
220
221 let sep2 = NSBox(); sep2.boxType = .separator
222 views.append(sep2)
223
224 // Basic palette + full picker.
225 let palette: [NSColor] = [.black, .white, .systemRed, .systemOrange,
226 .systemYellow, .systemGreen, .systemBlue, .systemPurple]
227 for c in palette {
228 let b = InstantButton(title: "", target: self, action: #selector(pickColor(_:)))
229 b.isBordered = false
230 b.wantsLayer = true
231 b.layer?.backgroundColor = c.cgColor
232 b.layer?.cornerRadius = 7
233 b.layer?.borderWidth = 1
234 b.layer?.borderColor = NSColor(calibratedWhite: 0.4, alpha: 1).cgColor
235 b.widthAnchor.constraint(equalToConstant: 15).isActive = true
236 b.heightAnchor.constraint(equalToConstant: 15).isActive = true
237 views.append(b)
238 }
239 let well = NSColorWell()
240 well.color = .black
241 well.target = self
242 well.action = #selector(wellChanged(_:))
243 well.widthAnchor.constraint(equalToConstant: 34).isActive = true
244 well.heightAnchor.constraint(equalToConstant: 20).isActive = true
245 colorWell = well
246 views.append(well)
247
248 let fill = InstantButton(checkboxWithTitle: "Fill", target: self,
249 action: #selector(fillToggled(_:)))
250 fill.togglesState = true
251 fill.state = .on
252 fill.controlSize = .small
253 fillCheck = fill
254 views.append(fill)
255
256 let sides = NSPopUpButton()
257 sides.controlSize = .small
258 for n in 3...12 { sides.addItem(withTitle: "\(n)") }
259 sides.selectItem(withTitle: "5")
260 sides.target = self
261 sides.action = #selector(sidesChanged(_:))
262 sides.toolTip = "Star points / n-gon sides"
263 sidesPopup = sides
264 views.append(sides)
265
266 let sep3 = NSBox(); sep3.boxType = .separator
267 views.append(sep3)
268
269 func zButton(_ title: String, _ action: Selector, tip: String) -> NSButton {
270 let b = InstantButton(title: title, target: self, action: action)
271 b.bezelStyle = .accessoryBarAction
272 b.controlSize = .small
273 b.toolTip = tip
274 return b
275 }
276 views.append(zButton("⬇︎", #selector(sendBackward), tip: "Send backward (⌘[)"))
277 views.append(zButton("⬆︎", #selector(bringForward), tip: "Bring forward (⌘])"))
278 views.append(zButton("To Top", #selector(toggleAboveRaster),
279 tip: "Bring above the drawing layer"))
280 views.append(zButton("Clear Drawing", #selector(clearRaster),
281 tip: "Erase the whole drawing layer"))
282
283 let stack = NSStackView(views: views)
284 stack.orientation = .horizontal
285 stack.spacing = 6
286 stack.edgeInsets = NSEdgeInsets(top: 0, left: 10, bottom: 0, right: 10)
287 stack.translatesAutoresizingMaskIntoConstraints = false
288 bar.addSubview(stack)
289 NSLayoutConstraint.activate([
290 stack.leadingAnchor.constraint(equalTo: bar.leadingAnchor),
291 stack.trailingAnchor.constraint(lessThanOrEqualTo: bar.trailingAnchor),
292 stack.topAnchor.constraint(equalTo: bar.topAnchor),
293 stack.bottomAnchor.constraint(equalTo: bar.bottomAnchor),
294 ])
295 return bar
296 }
297
298 private func syncToolbar() {
299 guard let canvas else { return }
300 for (tool, b) in toolButtons {
301 let active = tool == canvas.tool
302 b.layer?.backgroundColor = active
303 ? NSColor.controlAccentColor.withAlphaComponent(0.85).cgColor
304 : NSColor.clear.cgColor
305 b.contentTintColor = active ? .white : .secondaryLabelColor
306 }
307 fillCheck?.state = canvas.fillShapes ? .on : .off
308 sidesPopup?.selectItem(withTitle: "\(canvas.currentSides)")
309 }
310
311 // MARK: - Toolbar actions
312
313 @objc private func pickTool(_ sender: NSButton) {
314 guard let tool = toolButtons.first(where: { $0.value === sender })?.key else { return }
315 canvas?.tool = tool
316 syncToolbar()
317 }
318 @objc private func pickColor(_ sender: NSButton) {
319 guard let cg = sender.layer?.backgroundColor,
320 let color = NSColor(cgColor: cg) else { return }
321 colorWell?.color = color
322 canvas?.setColor(color)
323 }
324 @objc private func wellChanged(_ sender: NSColorWell) {
325 canvas?.setColor(sender.color)
326 }
327 @objc private func fillToggled(_ sender: NSButton) {
328 canvas?.setFilled(sender.state == .on)
329 }
330 @objc private func sidesChanged(_ sender: NSPopUpButton) {
331 canvas?.setSides(Int(sender.titleOfSelectedItem ?? "5") ?? 5)
332 }
333 @objc private func sendBackward() { canvas?.reorderSelected(by: -1) }
334 @objc private func bringForward() { canvas?.reorderSelected(by: 1) }
335 @objc private func toggleAboveRaster() { canvas?.toggleSelectedAboveRaster() }
336 @objc private func clearRaster() { canvas?.clearRaster() }
337}
338
339// MARK: - Canvas
340
341final class BoardCanvas: NSView {
342 /// Document context. Facade over the shared singletons for now; injected
343 /// per-document instance later (the editor is re-targeted per document).
344 var ctx: DocumentContext = .headless
345 private var store: Store { ctx.store }
346 private var project: ProjectModel { ctx.store.project }
347 private var boards: BoardStore { ctx.boards }
348
349 var clipId: UUID? {
350 didSet {
351 if clipId != oldValue {
352 commitTextEditing()
353 strokeActive = false
354 selectedShapeId = nil
355 }
356 needsDisplay = true
357 }
358 }
359 var tool: BoardTool = .pencil {
360 didSet {
361 commitTextEditing()
362 if tool != .select { selectedShapeId = nil }
363 needsDisplay = true
364 }
365 }
366 var onToolChanged: (() -> Void)?
367 private(set) var currentColor: NSColor = .black
368 private(set) var currentSides = 5
369 private(set) var fillShapes = true
370 private var selectedShapeId: UUID?
371
372 // Raster stroke state (pixels live in BoardStore's shared stroke engine)
373 private var strokeActive = false
374 private var lastStrokePoint: CGPoint?
375
376 // Shape gesture state
377 private enum ShapeDrag { case none, create, move, resize }
378 private var shapeDrag: ShapeDrag = .none
379 private var dragShapeId: UUID?
380 private var dragOrigShape: BoardShape?
381 private var dragStartBoard = CGPoint.zero
382
383 private var textEditor: NSTextField?
384 private var editingShapeId: UUID?
385
386 override var isFlipped: Bool { true }
387 override var acceptsFirstResponder: Bool { true }
388
389 override init(frame: NSRect) {
390 super.init(frame: frame)
391 wantsLayer = true
392 layer?.backgroundColor = Theme.canvasBg.cgColor
393 NotificationCenter.default.addObserver(self, selector: #selector(modelChanged),
394 name: .projectChanged, object: nil)
395 NotificationCenter.default.addObserver(self, selector: #selector(themeChanged),
396 name: .themeChanged, object: nil)
397 }
398 required init?(coder: NSCoder) { fatalError() }
399
400 @objc private func themeChanged() {
401 layer?.backgroundColor = Theme.canvasBg.cgColor
402 needsDisplay = true
403 }
404
405 @objc private func modelChanged() {
406 // Clip removed (undo past creation, delete) → close gracefully.
407 if let clipId, store.project.clip(clipId) == nil {
408 window?.performClose(nil)
409 self.clipId = nil
410 }
411 needsDisplay = true
412 }
413
414 private var board: Board? {
415 clipId.flatMap { store.project.clip($0)?.board }
416 }
417
418 // MARK: Board mutation helpers
419
420 private func mutateBoard(_ body: (inout Board) -> Void) {
421 guard let clipId else { return }
422 store.mutate { model in
423 guard let i = model.clips.firstIndex(where: { $0.id == clipId }),
424 var b = model.clips[i].board else { return }
425 body(&b)
426 b.revision += 1
427 model.clips[i].board = b
428 }
429 }
430
431 private func updateBoardGesture(_ body: (inout Board) -> Void) {
432 guard let clipId else { return }
433 store.updateGesture { model in
434 guard let i = model.clips.firstIndex(where: { $0.id == clipId }),
435 var b = model.clips[i].board else { return }
436 body(&b)
437 b.revision += 1
438 model.clips[i].board = b
439 }
440 }
441
442 // MARK: Toolbar-driven state
443
444 func setColor(_ c: NSColor) {
445 currentColor = c
446 // With the select tool, recoloring applies to the selection.
447 if tool == .select, let id = selectedShapeId {
448 let rgba = rgbaComponents(c)
449 mutateBoard { b in
450 if let i = b.shapes.firstIndex(where: { $0.id == id }) {
451 b.shapes[i].color = rgba
452 }
453 }
454 }
455 }
456
457 func setFilled(_ f: Bool) {
458 fillShapes = f
459 if tool == .select, let id = selectedShapeId {
460 mutateBoard { b in
461 if let i = b.shapes.firstIndex(where: { $0.id == id }) {
462 b.shapes[i].filled = f
463 }
464 }
465 }
466 }
467
468 func setSides(_ n: Int) {
469 currentSides = n
470 if tool == .select, let id = selectedShapeId {
471 mutateBoard { b in
472 if let i = b.shapes.firstIndex(where: { $0.id == id }) {
473 b.shapes[i].sides = n
474 }
475 }
476 }
477 }
478
479 func reorderSelected(by delta: Int) {
480 guard let id = selectedShapeId else { return }
481 mutateBoard { b in
482 guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return }
483 let j = min(max(i + delta, 0), b.shapes.count - 1)
484 guard j != i else { return }
485 let s = b.shapes.remove(at: i)
486 b.shapes.insert(s, at: j)
487 }
488 }
489
490 func toggleSelectedAboveRaster() {
491 guard let id = selectedShapeId else { return }
492 mutateBoard { b in
493 guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return }
494 b.shapes[i].aboveRaster.toggle()
495 }
496 }
497
498 func clearRaster() {
499 guard let board else { return }
500 boards.clearRaster(board: board)
501 needsDisplay = true
502 }
503
504 // MARK: Coordinates
505
506 private var boardRect: NSRect {
507 guard let board else { return .zero }
508 let inset = bounds.insetBy(dx: 14, dy: 14)
509 guard inset.width > 10, inset.height > 10 else { return .zero }
510 let scale = min(inset.width / board.size.width, inset.height / board.size.height)
511 let w = board.size.width * scale, h = board.size.height * scale
512 return NSRect(x: inset.midX - w / 2, y: inset.midY - h / 2, width: w, height: h)
513 }
514 private var boardScale: CGFloat {
515 guard let board, board.width > 0 else { return 1 }
516 return boardRect.width / CGFloat(board.width)
517 }
518 private func toBoard(_ p: NSPoint) -> CGPoint {
519 let r = boardRect
520 let s = boardScale
521 guard s > 0 else { return .zero }
522 return CGPoint(x: (p.x - r.minX) / s, y: (p.y - r.minY) / s)
523 }
524 private func toView(_ rect: CGRect) -> NSRect {
525 let r = boardRect
526 let s = boardScale
527 return NSRect(x: r.minX + rect.minX * s, y: r.minY + rect.minY * s,
528 width: rect.width * s, height: rect.height * s)
529 }
530
531 // MARK: Drawing
532
533 override func draw(_ dirtyRect: NSRect) {
534 Theme.canvasBg.setFill()
535 bounds.fill()
536 guard let board else {
537 return
538 }
539 let r = boardRect
540 NSColor.black.withAlphaComponent(0.5).setFill()
541 NSRect(x: r.minX + 3, y: r.minY + 3, width: r.width, height: r.height).fill()
542 BoardStore.paper.setFill()
543 r.fill()
544
545 // Board-space rendering with a scaled transform.
546 NSGraphicsContext.current?.saveGraphicsState()
547 NSBezierPath(rect: r).addClip()
548 let transform = NSAffineTransform()
549 transform.translateX(by: r.minX, yBy: r.minY)
550 transform.scale(by: boardScale)
551 transform.concat()
552
553 for shape in board.shapes where !shape.aboveRaster { BoardStore.draw(shape) }
554 boards.displayRaster(board.id)?
555 .draw(in: CGRect(origin: .zero, size: board.size),
556 from: .zero, operation: .sourceOver, fraction: 1,
557 respectFlipped: true, hints: nil)
558 for shape in board.shapes where shape.aboveRaster { BoardStore.draw(shape) }
559 NSGraphicsContext.current?.restoreGraphicsState()
560
561 // Selection chrome (view space).
562 if let id = selectedShapeId,
563 let shape = board.shapes.first(where: { $0.id == id }) {
564 let vr = toView(shape.frame)
565 NSColor.controlAccentColor.setStroke()
566 let sel = NSBezierPath(rect: vr)
567 sel.lineWidth = 1.5
568 sel.setLineDash([4, 3], count: 2, phase: 0)
569 sel.stroke()
570 for corner in corners(of: vr) {
571 let h = NSRect(x: corner.x - 3.5, y: corner.y - 3.5, width: 7, height: 7)
572 NSColor.white.setFill()
573 NSBezierPath(ovalIn: h).fill()
574 NSColor.controlAccentColor.setStroke()
575 NSBezierPath(ovalIn: h).stroke()
576 }
577 }
578 }
579
580 private func corners(of r: NSRect) -> [NSPoint] {
581 [NSPoint(x: r.minX, y: r.minY), NSPoint(x: r.maxX, y: r.minY),
582 NSPoint(x: r.minX, y: r.maxY), NSPoint(x: r.maxX, y: r.maxY)]
583 }
584
585 // MARK: Raster strokes (BoardStore's engine does the pixel work)
586
587 private func strokeSegment(from a: CGPoint, to b: CGPoint, pressure: CGFloat) {
588 guard let board, let width = tool.strokeWidth else { return }
589 boards.strokeSegment(
590 board: board, from: a, to: b, width: width, color: currentColor,
591 erase: tool == .eraser, alpha: tool == .pencil ? 0.85 : 1,
592 pressure: pressure)
593 }
594
595 // MARK: Mouse
596
597 private func shapeAt(_ bp: CGPoint) -> BoardShape? {
598 guard let board else { return nil }
599 // Topmost first: above-raster shapes beat below, later beats earlier.
600 let ordered = Array(board.shapes.filter(\.aboveRaster).reversed())
601 + Array(board.shapes.filter { !$0.aboveRaster }.reversed())
602 return ordered.first { $0.frame.insetBy(dx: -4, dy: -4).contains(bp) }
603 }
604
605 override func mouseDown(with event: NSEvent) {
606 window?.makeFirstResponder(self)
607 commitTextEditing()
608 guard let board else { return }
609 let p = convert(event.locationInWindow, from: nil)
610 let bp = toBoard(p)
611 lastStrokePoint = bp
612 shapeDrag = .none
613
614 if tool.isDraw {
615 strokeActive = true
616 boards.beginStroke(board: board)
617 strokeSegment(from: bp, to: bp, pressure: CGFloat(event.pressure))
618 needsDisplay = true
619 return
620 }
621
622 if tool == .select {
623 if let id = selectedShapeId,
624 let shape = board.shapes.first(where: { $0.id == id }) {
625 let vr = toView(shape.frame)
626 if corners(of: vr).contains(where: { hypot($0.x - p.x, $0.y - p.y) < 7 }) {
627 shapeDrag = .resize
628 dragShapeId = id
629 dragOrigShape = shape
630 dragStartBoard = bp
631 store.beginGesture()
632 return
633 }
634 }
635 if let hit = shapeAt(bp) {
636 selectedShapeId = hit.id
637 if event.clickCount == 2, hit.kind == .text {
638 beginTextEditing(hit)
639 return
640 }
641 shapeDrag = .move
642 dragShapeId = hit.id
643 dragOrigShape = hit
644 dragStartBoard = bp
645 store.beginGesture()
646 } else {
647 selectedShapeId = nil
648 }
649 needsDisplay = true
650 return
651 }
652
653 if tool == .image {
654 insertImageShape(at: bp)
655 return
656 }
657
658 if let kind = tool.shapeKind {
659 var shape = BoardShape(kind: kind,
660 frame: CGRect(x: bp.x, y: bp.y, width: 1, height: 1))
661 shape.color = rgbaComponents(currentColor)
662 shape.sides = kind == .star ? max(3, currentSides) : currentSides
663 shape.filled = fillShapes
664 if kind == .text {
665 // Text places at a fixed size and edits immediately — typing
666 // should never fall through to tool shortcuts.
667 shape.frame = CGRect(x: bp.x, y: bp.y - 35, width: 420, height: 70)
668 shape.fontSize = 48
669 let new = shape
670 mutateBoard { $0.shapes.append(new) }
671 selectedShapeId = new.id
672 needsDisplay = true
673 beginTextEditing(new)
674 return
675 }
676 // Click-drag sizes the other shapes.
677 shapeDrag = .create
678 dragShapeId = shape.id
679 dragOrigShape = shape
680 dragStartBoard = bp
681 selectedShapeId = shape.id
682 store.beginGesture()
683 let new = shape
684 updateBoardGesture { $0.shapes.append(new) }
685 }
686 }
687
688 override func mouseDragged(with event: NSEvent) {
689 let p = convert(event.locationInWindow, from: nil)
690 let bp = toBoard(p)
691
692 if tool.isDraw {
693 if strokeActive, let last = lastStrokePoint {
694 strokeSegment(from: last, to: bp, pressure: CGFloat(event.pressure))
695 }
696 lastStrokePoint = bp
697 needsDisplay = true
698 return
699 }
700
701 let square = event.modifierFlags.contains(.shift)
702 guard let id = dragShapeId, let orig = dragOrigShape else { return }
703 switch shapeDrag {
704 case .create:
705 var w = max(4, abs(bp.x - dragStartBoard.x))
706 var h = max(4, abs(bp.y - dragStartBoard.y))
707 if square { w = max(w, h); h = w } // ⇧ = square / circle / regular
708 let frame = CGRect(x: bp.x < dragStartBoard.x ? dragStartBoard.x - w : dragStartBoard.x,
709 y: bp.y < dragStartBoard.y ? dragStartBoard.y - h : dragStartBoard.y,
710 width: w, height: h)
711 var shape = orig
712 if orig.kind != .text { shape.frame = frame }
713 else { shape.frame.origin = CGPoint(x: bp.x, y: bp.y) }
714 updateBoardGesture { b in
715 if let i = b.shapes.firstIndex(where: { $0.id == id }) { b.shapes[i] = shape }
716 else { b.shapes.append(shape) }
717 }
718 case .move:
719 let dx = bp.x - dragStartBoard.x, dy = bp.y - dragStartBoard.y
720 updateBoardGesture { b in
721 guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return }
722 b.shapes[i].frame.origin = CGPoint(x: orig.frame.minX + dx,
723 y: orig.frame.minY + dy)
724 }
725 case .resize:
726 // Resize relative to the corner opposite the grabbed one.
727 let f = orig.frame
728 let anchors = [CGPoint(x: f.maxX, y: f.maxY), CGPoint(x: f.minX, y: f.maxY),
729 CGPoint(x: f.maxX, y: f.minY), CGPoint(x: f.minX, y: f.minY)]
730 let grabbed = [CGPoint(x: f.minX, y: f.minY), CGPoint(x: f.maxX, y: f.minY),
731 CGPoint(x: f.minX, y: f.maxY), CGPoint(x: f.maxX, y: f.maxY)]
732 let idx = grabbed.enumerated().min {
733 hypot($0.1.x - dragStartBoard.x, $0.1.y - dragStartBoard.y)
734 < hypot($1.1.x - dragStartBoard.x, $1.1.y - dragStartBoard.y)
735 }?.0 ?? 3
736 let anchor = anchors[idx]
737 var w = max(4, abs(bp.x - anchor.x))
738 var h = max(4, abs(bp.y - anchor.y))
739 if square, orig.frame.height > 0 {
740 // ⇧ = preserve the shape's aspect while resizing.
741 let aspect = orig.frame.width / orig.frame.height
742 if w / max(1, h) > aspect { h = w / aspect } else { w = h * aspect }
743 }
744 let frame = CGRect(x: bp.x < anchor.x ? anchor.x - w : anchor.x,
745 y: bp.y < anchor.y ? anchor.y - h : anchor.y,
746 width: w, height: h)
747 updateBoardGesture { b in
748 guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return }
749 b.shapes[i].frame = frame
750 if b.shapes[i].kind == .text {
751 b.shapes[i].fontSize = max(8, Double(frame.height) * 0.66)
752 }
753 }
754 case .none:
755 break
756 }
757 needsDisplay = true
758 }
759
760 override func mouseUp(with event: NSEvent) {
761 if tool.isDraw {
762 if strokeActive, let board { boards.endStroke(board: board) }
763 strokeActive = false
764 lastStrokePoint = nil
765 needsDisplay = true
766 return
767 }
768 if shapeDrag != .none {
769 let placed = shapeDrag == .create
770 store.endGesture()
771 shapeDrag = .none
772 dragShapeId = nil
773 dragOrigShape = nil
774 if placed {
775 // Placing a shape hands you the select tool to adjust it.
776 tool = .select
777 onToolChanged?()
778 }
779 needsDisplay = true
780 }
781 }
782
783 /// Right-click: radial quick picker (tools around the cursor, colors inside).
784 override func rightMouseDown(with event: NSEvent) {
785 let screenPoint = window?.convertPoint(toScreen: event.locationInWindow) ?? .zero
786 RadialPicker.show(at: screenPoint, currentTool: tool, currentColor: currentColor,
787 onTool: { [weak self] t in
788 self?.tool = t
789 self?.onToolChanged?()
790 },
791 onColor: { [weak self] c in
792 self?.setColor(c)
793 self?.onToolChanged?()
794 })
795 }
796
797 // MARK: Text editing
798
799 private func beginTextEditing(_ shape: BoardShape) {
800 commitTextEditing()
801 let field = NSTextField(string: shape.text.isEmpty ? "Text" : shape.text)
802 field.frame = toView(shape.frame)
803 field.font = BoardStore.boardFont(size: shape.fontSize * boardScale)
804 field.textColor = BoardStore.color(shape.color)
805 field.backgroundColor = NSColor.white.withAlphaComponent(0.85)
806 field.isBordered = true
807 field.focusRingType = .default
808 field.target = self
809 field.action = #selector(textCommitted)
810 addSubview(field)
811 window?.makeFirstResponder(field)
812 textEditor = field
813 editingShapeId = shape.id
814 }
815
816 @objc private func textCommitted() { commitTextEditing() }
817
818 func commitTextEditing() {
819 guard let field = textEditor, let id = editingShapeId else { return }
820 let text = field.stringValue
821 field.removeFromSuperview()
822 textEditor = nil
823 editingShapeId = nil
824 mutateBoard { b in
825 guard let i = b.shapes.firstIndex(where: { $0.id == id }) else { return }
826 b.shapes[i].text = text
827 }
828 window?.makeFirstResponder(self)
829 needsDisplay = true
830 }
831
832 private func insertImageShape(at bp: CGPoint) {
833 let panel = NSOpenPanel()
834 panel.allowedContentTypes = [.image]
835 guard panel.runModal() == .OK, let url = panel.url, let board else { return }
836 var w = board.width * 0.35
837 var h = w * 0.66
838 if let img = NSImage(contentsOf: url), img.size.width > 0 {
839 h = w * Double(img.size.height / img.size.width)
840 }
841 w = min(w, board.width); h = min(h, board.height)
842 var shape = BoardShape(kind: .image, frame:
843 CGRect(x: bp.x - w / 2, y: bp.y - h / 2, width: w, height: h))
844 shape.imagePath = url.path
845 shape.color = rgbaComponents(currentColor)
846 let new = shape
847 mutateBoard { $0.shapes.append(new) }
848 selectedShapeId = new.id
849 tool = .select
850 onToolChanged?()
851 }
852
853 // MARK: Keyboard
854
855 override func keyDown(with event: NSEvent) {
856 switch event.charactersIgnoringModifiers?.lowercased() {
857 case "v" where !event.modifierFlags.contains(.command): tool = .select
858 case "p" where !event.modifierFlags.contains(.command): tool = .pencil
859 case "e" where !event.modifierFlags.contains(.command): tool = .eraser
860 case "]" where event.modifierFlags.contains(.command): reorderSelected(by: 1)
861 case "[" where event.modifierFlags.contains(.command): reorderSelected(by: -1)
862 case "\u{1b}":
863 if textEditor != nil { commitTextEditing() } else { selectedShapeId = nil }
864 needsDisplay = true
865 default:
866 switch event.keyCode {
867 case 51, 117: // ⌫, ⌦ — delete selected shape
868 if let id = selectedShapeId {
869 mutateBoard { $0.shapes.removeAll { $0.id == id } }
870 selectedShapeId = nil
871 } else {
872 super.keyDown(with: event)
873 }
874 default: super.keyDown(with: event)
875 }
876 }
877 onToolChanged?()
878 }
879
880 // MARK: Panel copy/paste while the editor is key
881
882 @objc func copy(_ sender: Any?) {
883 guard let board else { return }
884 boards.copyPanel(board)
885 NotificationCenter.default.post(name: .transientStatus, object: nil,
886 userInfo: ["text": "Copied panel (image + layers)"])
887 }
888
889 @objc func paste(_ sender: Any?) {
890 guard let clipId, let board else { return }
891 guard let new = boards.panelFromPasteboard(size: board.size) else { return }
892 store.mutate { model in
893 guard let i = model.clips.firstIndex(where: { $0.id == clipId }) else { return }
894 model.clips[i].board = new
895 }
896 strokeActive = false
897 selectedShapeId = nil
898 needsDisplay = true
899 }
900}
901
902private func rgbaComponents(_ color: NSColor) -> [Double] {
903 let c = color.usingColorSpace(.sRGB) ?? color
904 return [Double(c.redComponent), Double(c.greenComponent),
905 Double(c.blueComponent), Double(c.alphaComponent)]
906}