| 1 | import 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. |
| 7 | enum 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. |
| 74 | final 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 | |
| 123 | final 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 | |
| 341 | final 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 | |
| 902 | private 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 | } |