DrawPrintView.swift 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056
  1. import Cocoa
  2. import UniformTypeIdentifiers
  3. // MARK: - Service
  4. enum DrawPrintService {
  5. static func present(from window: NSWindow?) {
  6. let hostWindow = window ?? NSApp.keyWindow
  7. guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
  8. viewController.presentDrawPrint()
  9. }
  10. }
  11. // MARK: - Canvas Model
  12. enum CanvasTool: CaseIterable {
  13. case text
  14. case image
  15. case draw
  16. }
  17. struct CanvasStroke {
  18. var points: [NSPoint]
  19. var color: NSColor
  20. var lineWidth: CGFloat
  21. }
  22. struct CanvasTextData {
  23. var id: UUID
  24. var text: String
  25. var origin: NSPoint
  26. var fontSize: CGFloat
  27. var color: NSColor
  28. var alignment: NSTextAlignment
  29. }
  30. struct CanvasImageData {
  31. var id: UUID
  32. var origin: NSPoint
  33. var size: NSSize
  34. var image: NSImage
  35. }
  36. struct CanvasSnapshot {
  37. var strokes: [CanvasStroke]
  38. var texts: [CanvasTextData]
  39. var images: [CanvasImageData]
  40. }
  41. // MARK: - Overlay
  42. final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
  43. private static let toolsPanelWidth: CGFloat = 56
  44. private static let toolsPanelGap: CGFloat = 12
  45. var onDismiss: (() -> Void)?
  46. private let backButton = DrawPrintToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
  47. private let titleLabel = NSTextField(labelWithString: "Blank Canvas")
  48. private let canvasContainer = NSView()
  49. private let canvasView = BlankCanvasView()
  50. private let toolsPanel = CanvasToolsPanel()
  51. private let printButton = DrawPrintPrintButton()
  52. init() {
  53. super.init(frame: .zero)
  54. translatesAutoresizingMaskIntoConstraints = false
  55. setup()
  56. refreshAppearance()
  57. NotificationCenter.default.addObserver(
  58. self,
  59. selector: #selector(appearanceDidChange),
  60. name: .appearanceDidChange,
  61. object: nil
  62. )
  63. }
  64. deinit {
  65. NotificationCenter.default.removeObserver(self)
  66. }
  67. @objc private func appearanceDidChange() {
  68. refreshAppearance()
  69. }
  70. @available(*, unavailable)
  71. required init?(coder: NSCoder) { nil }
  72. func refreshAppearance() {
  73. layer?.backgroundColor = AppTheme.background.cgColor
  74. titleLabel.textColor = AppTheme.textPrimary
  75. canvasContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  76. canvasContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
  77. backButton.refreshAppearance()
  78. printButton.refreshAppearance()
  79. canvasView.refreshAppearance()
  80. toolsPanel.refreshAppearance()
  81. }
  82. func present(in parent: NSView) {
  83. guard superview == nil else { return }
  84. parent.addSubview(self)
  85. NSLayoutConstraint.activate([
  86. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  87. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  88. topAnchor.constraint(equalTo: parent.topAnchor),
  89. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  90. ])
  91. alphaValue = 0
  92. NSAnimationContext.runAnimationGroup { context in
  93. context.duration = 0.2
  94. animator().alphaValue = 1
  95. }
  96. }
  97. func dismiss(animated: Bool = true) {
  98. let remove = { [weak self] in
  99. self?.removeFromSuperview()
  100. self?.onDismiss?()
  101. }
  102. guard animated else {
  103. remove()
  104. return
  105. }
  106. NSAnimationContext.runAnimationGroup({ context in
  107. context.duration = 0.15
  108. animator().alphaValue = 0
  109. }, completionHandler: remove)
  110. }
  111. private func setup() {
  112. wantsLayer = true
  113. titleLabel.font = AppTheme.semiboldFont(size: 18)
  114. titleLabel.alignment = .center
  115. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  116. canvasContainer.wantsLayer = true
  117. canvasContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  118. canvasContainer.layer?.borderWidth = 1.5
  119. canvasContainer.applyCardShadow()
  120. canvasContainer.translatesAutoresizingMaskIntoConstraints = false
  121. canvasView.translatesAutoresizingMaskIntoConstraints = false
  122. toolsPanel.translatesAutoresizingMaskIntoConstraints = false
  123. backButton.translatesAutoresizingMaskIntoConstraints = false
  124. printButton.translatesAutoresizingMaskIntoConstraints = false
  125. backButton.onClick = { [weak self] in self?.dismiss() }
  126. printButton.onClick = { [weak self] in self?.printCanvas() }
  127. toolsPanel.onToolChanged = { [weak self] tool in
  128. self?.canvasView.activeTool = tool
  129. }
  130. toolsPanel.onAddText = { [weak self] in
  131. self?.canvasView.addTextItem()
  132. }
  133. toolsPanel.onTextAlignment = { [weak self] alignment in
  134. self?.canvasView.setSelectedTextAlignment(alignment)
  135. }
  136. toolsPanel.onTextFontSize = { [weak self] size in
  137. self?.canvasView.setSelectedTextFontSize(size)
  138. }
  139. toolsPanel.onTextColor = { [weak self] color in
  140. self?.canvasView.setSelectedTextColor(color)
  141. }
  142. toolsPanel.onDrawColor = { [weak self] color in
  143. self?.canvasView.drawColor = color
  144. }
  145. toolsPanel.onDrawLineWidth = { [weak self] width in
  146. self?.canvasView.drawLineWidth = width
  147. }
  148. toolsPanel.onAddImage = { [weak self] in
  149. self?.pickImage()
  150. }
  151. canvasView.onUndoStateChanged = { [weak self] canUndo, canRedo in
  152. self?.canvasView.updateUndoButtons(canUndo: canUndo, canRedo: canRedo)
  153. }
  154. canvasContainer.addSubview(canvasView)
  155. addSubview(backButton)
  156. addSubview(titleLabel)
  157. addSubview(canvasContainer)
  158. addSubview(toolsPanel)
  159. addSubview(printButton)
  160. NSLayoutConstraint.activate([
  161. backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
  162. backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  163. backButton.widthAnchor.constraint(equalToConstant: 40),
  164. backButton.heightAnchor.constraint(equalToConstant: 40),
  165. titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  166. titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
  167. canvasContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
  168. canvasContainer.trailingAnchor.constraint(
  169. equalTo: trailingAnchor,
  170. constant: -(AppTheme.contentPanelInset + Self.toolsPanelWidth + Self.toolsPanelGap)
  171. ),
  172. canvasContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
  173. canvasContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, constant: -28),
  174. toolsPanel.leadingAnchor.constraint(equalTo: canvasContainer.trailingAnchor, constant: Self.toolsPanelGap),
  175. toolsPanel.centerYAnchor.constraint(equalTo: canvasContainer.centerYAnchor),
  176. toolsPanel.widthAnchor.constraint(equalToConstant: Self.toolsPanelWidth),
  177. canvasView.leadingAnchor.constraint(equalTo: canvasContainer.leadingAnchor),
  178. canvasView.trailingAnchor.constraint(equalTo: canvasContainer.trailingAnchor),
  179. canvasView.topAnchor.constraint(equalTo: canvasContainer.topAnchor),
  180. canvasView.bottomAnchor.constraint(equalTo: canvasContainer.bottomAnchor),
  181. printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
  182. printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
  183. printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
  184. ])
  185. }
  186. private func pickImage() {
  187. let panel = NSOpenPanel()
  188. panel.title = "Add Image"
  189. panel.message = "Select an image to place on the canvas."
  190. panel.prompt = "Add"
  191. panel.canChooseDirectories = false
  192. panel.canChooseFiles = true
  193. panel.allowsMultipleSelection = false
  194. panel.allowedContentTypes = [.image, .png, .jpeg, .gif, .tiff, .bmp, .heic]
  195. guard panel.runModal() == .OK, let url = panel.url,
  196. let image = NSImage(contentsOf: url) else { return }
  197. canvasView.addImage(image)
  198. }
  199. private func printCanvas() {
  200. guard canvasView.hasContent else {
  201. PrintService.showEmptyCanvasAlert()
  202. return
  203. }
  204. guard let image = canvasView.renderSnapshot() else { return }
  205. PrintService.printCanvas(image, from: window)
  206. }
  207. }
  208. // MARK: - Canvas View
  209. final class BlankCanvasView: NSView, AppearanceRefreshable {
  210. var activeTool: CanvasTool = .draw {
  211. didSet { needsDisplay = true }
  212. }
  213. var drawColor: NSColor = NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1)
  214. var drawLineWidth: CGFloat = 3
  215. var onUndoStateChanged: ((_ canUndo: Bool, _ canRedo: Bool) -> Void)?
  216. var hasContent: Bool {
  217. syncTextFromFields()
  218. let hasText = textItems.contains { !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  219. return !strokes.isEmpty || !imageItems.isEmpty || hasText
  220. }
  221. private var strokes: [CanvasStroke] = []
  222. private var textItems: [CanvasTextData] = []
  223. private var imageItems: [CanvasImageData] = []
  224. private var undoStack: [CanvasSnapshot] = []
  225. private var redoStack: [CanvasSnapshot] = []
  226. private var currentStroke: CanvasStroke?
  227. private var selectedTextID: UUID?
  228. private let undoButton = CanvasHistoryButton(symbolName: "arrow.uturn.backward", accessibilityLabel: "Undo")
  229. private let redoButton = CanvasHistoryButton(symbolName: "arrow.uturn.forward", accessibilityLabel: "Redo")
  230. init() {
  231. super.init(frame: .zero)
  232. setup()
  233. refreshAppearance()
  234. notifyUndoState()
  235. }
  236. @available(*, unavailable)
  237. required init?(coder: NSCoder) { nil }
  238. func refreshAppearance() {
  239. undoButton.refreshAppearance()
  240. redoButton.refreshAppearance()
  241. }
  242. func updateUndoButtons(canUndo: Bool, canRedo: Bool) {
  243. undoButton.setActionEnabled(canUndo)
  244. redoButton.setActionEnabled(canRedo)
  245. }
  246. private func setup() {
  247. wantsLayer = true
  248. layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  249. layer?.masksToBounds = true
  250. undoButton.translatesAutoresizingMaskIntoConstraints = false
  251. redoButton.translatesAutoresizingMaskIntoConstraints = false
  252. undoButton.onClick = { [weak self] in self?.undo() }
  253. redoButton.onClick = { [weak self] in self?.redo() }
  254. addSubview(undoButton)
  255. addSubview(redoButton)
  256. NSLayoutConstraint.activate([
  257. undoButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
  258. undoButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12),
  259. undoButton.widthAnchor.constraint(equalToConstant: 32),
  260. undoButton.heightAnchor.constraint(equalToConstant: 32),
  261. redoButton.leadingAnchor.constraint(equalTo: undoButton.trailingAnchor, constant: 8),
  262. redoButton.centerYAnchor.constraint(equalTo: undoButton.centerYAnchor),
  263. redoButton.widthAnchor.constraint(equalToConstant: 32),
  264. redoButton.heightAnchor.constraint(equalToConstant: 32),
  265. ])
  266. }
  267. override func draw(_ dirtyRect: NSRect) {
  268. NSColor.white.setFill()
  269. NSBezierPath(
  270. roundedRect: bounds,
  271. xRadius: AppTheme.contentPanelCornerRadius,
  272. yRadius: AppTheme.contentPanelCornerRadius
  273. ).fill()
  274. let context = NSGraphicsContext.current?.cgContext
  275. for stroke in strokes {
  276. drawStroke(stroke, in: context)
  277. }
  278. if let currentStroke {
  279. drawStroke(currentStroke, in: context)
  280. }
  281. for item in imageItems {
  282. let rect = NSRect(origin: item.origin, size: item.size)
  283. item.image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
  284. }
  285. }
  286. private func drawStroke(_ stroke: CanvasStroke, in context: CGContext?) {
  287. guard stroke.points.count > 1, let context else { return }
  288. context.saveGState()
  289. context.setStrokeColor(stroke.color.cgColor)
  290. context.setLineWidth(stroke.lineWidth)
  291. context.setLineCap(.round)
  292. context.setLineJoin(.round)
  293. context.beginPath()
  294. context.move(to: stroke.points[0])
  295. for point in stroke.points.dropFirst() {
  296. context.addLine(to: point)
  297. }
  298. context.strokePath()
  299. context.restoreGState()
  300. }
  301. override func mouseDown(with event: NSEvent) {
  302. let point = convert(event.locationInWindow, from: nil)
  303. switch activeTool {
  304. case .draw:
  305. pushSnapshot()
  306. currentStroke = CanvasStroke(points: [point], color: drawColor, lineWidth: drawLineWidth)
  307. needsDisplay = true
  308. case .text:
  309. if let hitID = textField(at: point)?.identifier.flatMap({ UUID(uuidString: $0.rawValue) }) {
  310. selectedTextID = hitID
  311. window?.makeFirstResponder(textField(for: hitID))
  312. } else {
  313. addTextItem(at: point)
  314. }
  315. case .image:
  316. break
  317. }
  318. }
  319. override func mouseDragged(with event: NSEvent) {
  320. guard activeTool == .draw, var stroke = currentStroke else { return }
  321. let point = convert(event.locationInWindow, from: nil)
  322. stroke.points.append(point)
  323. currentStroke = stroke
  324. needsDisplay = true
  325. }
  326. override func mouseUp(with event: NSEvent) {
  327. guard activeTool == .draw, let stroke = currentStroke, stroke.points.count > 1 else {
  328. currentStroke = nil
  329. return
  330. }
  331. strokes.append(stroke)
  332. currentStroke = nil
  333. needsDisplay = true
  334. notifyUndoState()
  335. }
  336. func addTextItem(at origin: NSPoint? = nil) {
  337. pushSnapshot()
  338. let placement = origin ?? NSPoint(x: 24, y: bounds.height - 48)
  339. let item = CanvasTextData(
  340. id: UUID(),
  341. text: "Text",
  342. origin: placement,
  343. fontSize: 18,
  344. color: .black,
  345. alignment: .left
  346. )
  347. textItems.append(item)
  348. installTextField(for: item)
  349. selectedTextID = item.id
  350. window?.makeFirstResponder(textField(for: item.id))
  351. notifyUndoState()
  352. }
  353. func addImage(_ image: NSImage) {
  354. pushSnapshot()
  355. let maxSide: CGFloat = min(bounds.width, bounds.height) * 0.4
  356. let imageSize = image.size
  357. let scale = min(maxSide / max(imageSize.width, 1), maxSide / max(imageSize.height, 1))
  358. let size = NSSize(width: imageSize.width * scale, height: imageSize.height * scale)
  359. let origin = NSPoint(
  360. x: (bounds.width - size.width) / 2,
  361. y: (bounds.height - size.height) / 2
  362. )
  363. imageItems.append(CanvasImageData(id: UUID(), origin: origin, size: size, image: image))
  364. needsDisplay = true
  365. notifyUndoState()
  366. }
  367. func setSelectedTextAlignment(_ alignment: NSTextAlignment) {
  368. guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return }
  369. pushSnapshot()
  370. textItems[index].alignment = alignment
  371. if let field = textField(for: id) {
  372. field.alignment = alignment
  373. }
  374. notifyUndoState()
  375. }
  376. func setSelectedTextFontSize(_ size: CGFloat) {
  377. guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return }
  378. pushSnapshot()
  379. textItems[index].fontSize = size
  380. if let field = textField(for: id) {
  381. field.font = AppTheme.regularFont(size: size)
  382. }
  383. notifyUndoState()
  384. }
  385. func setSelectedTextColor(_ color: NSColor) {
  386. guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return }
  387. pushSnapshot()
  388. textItems[index].color = color
  389. if let field = textField(for: id) {
  390. field.textColor = color
  391. }
  392. notifyUndoState()
  393. }
  394. func renderSnapshot() -> NSImage? {
  395. syncTextFromFields()
  396. guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil }
  397. cacheDisplay(in: bounds, to: rep)
  398. let image = NSImage(size: bounds.size)
  399. image.addRepresentation(rep)
  400. return image
  401. }
  402. private func syncTextFromFields() {
  403. for index in textItems.indices {
  404. if let field = textField(for: textItems[index].id) {
  405. textItems[index].text = field.stringValue
  406. textItems[index].origin = field.frame.origin
  407. }
  408. }
  409. }
  410. private func pushSnapshot() {
  411. syncTextFromFields()
  412. undoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems))
  413. redoStack.removeAll()
  414. if undoStack.count > 50 { undoStack.removeFirst() }
  415. }
  416. private func undo() {
  417. guard let snapshot = undoStack.popLast() else { return }
  418. syncTextFromFields()
  419. redoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems))
  420. restore(snapshot)
  421. }
  422. private func redo() {
  423. guard let snapshot = redoStack.popLast() else { return }
  424. syncTextFromFields()
  425. undoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems))
  426. restore(snapshot)
  427. }
  428. private func restore(_ snapshot: CanvasSnapshot) {
  429. strokes = snapshot.strokes
  430. textItems = snapshot.texts
  431. imageItems = snapshot.images
  432. removeTextFields()
  433. for item in textItems {
  434. installTextField(for: item)
  435. }
  436. needsDisplay = true
  437. notifyUndoState()
  438. }
  439. private func notifyUndoState() {
  440. onUndoStateChanged?(!undoStack.isEmpty, !redoStack.isEmpty)
  441. updateUndoButtons(canUndo: !undoStack.isEmpty, canRedo: !redoStack.isEmpty)
  442. }
  443. private func installTextField(for item: CanvasTextData) {
  444. let field = NSTextField(string: item.text)
  445. field.identifier = NSUserInterfaceItemIdentifier(item.id.uuidString)
  446. field.isBordered = false
  447. field.isBezeled = false
  448. field.drawsBackground = false
  449. field.font = AppTheme.regularFont(size: item.fontSize)
  450. field.textColor = item.color
  451. field.alignment = item.alignment
  452. field.frame = NSRect(x: item.origin.x, y: item.origin.y, width: 200, height: 28)
  453. field.target = self
  454. field.action = #selector(textFieldEdited(_:))
  455. addSubview(field)
  456. }
  457. @objc private func textFieldEdited(_ sender: NSTextField) {
  458. guard let idString = sender.identifier?.rawValue,
  459. let id = UUID(uuidString: idString),
  460. let index = textItems.firstIndex(where: { $0.id == id }) else { return }
  461. textItems[index].text = sender.stringValue
  462. textItems[index].origin = sender.frame.origin
  463. }
  464. private func textField(for id: UUID) -> NSTextField? {
  465. subviews.compactMap { $0 as? NSTextField }.first {
  466. $0.identifier?.rawValue == id.uuidString
  467. }
  468. }
  469. private func textField(at point: NSPoint) -> NSTextField? {
  470. subviews.compactMap { $0 as? NSTextField }.first { $0.frame.contains(point) }
  471. }
  472. private func removeTextFields() {
  473. subviews.compactMap { $0 as? NSTextField }.forEach { $0.removeFromSuperview() }
  474. }
  475. }
  476. // MARK: - Tools Panel
  477. final class CanvasToolsPanel: NSView, AppearanceRefreshable {
  478. var onToolChanged: ((CanvasTool) -> Void)?
  479. var onAddText: (() -> Void)?
  480. var onTextAlignment: ((NSTextAlignment) -> Void)?
  481. var onTextFontSize: ((CGFloat) -> Void)?
  482. var onTextColor: ((NSColor) -> Void)?
  483. var onDrawColor: ((NSColor) -> Void)?
  484. var onDrawLineWidth: ((CGFloat) -> Void)?
  485. var onAddImage: (() -> Void)?
  486. private var activeTool: CanvasTool = .draw
  487. private let toolStack = NSStackView()
  488. private let subToolbar = NSView()
  489. private var toolButtons: [CanvasTool: CanvasToolButton] = [:]
  490. private var subToolbarHeight: NSLayoutConstraint?
  491. private var subToolbarWidth: NSLayoutConstraint?
  492. private let drawColors: [NSColor] = [
  493. NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1),
  494. NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1),
  495. NSColor.black,
  496. NSColor.red,
  497. NSColor.orange,
  498. NSColor.purple,
  499. ]
  500. init() {
  501. super.init(frame: .zero)
  502. setup()
  503. refreshAppearance()
  504. selectTool(.draw)
  505. }
  506. @available(*, unavailable)
  507. required init?(coder: NSCoder) { nil }
  508. func refreshAppearance() {
  509. layer?.backgroundColor = AppTheme.cardBackground.cgColor
  510. layer?.borderColor = AppTheme.paywallBorder.cgColor
  511. layer?.borderWidth = 1
  512. subToolbar.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  513. subToolbar.layer?.borderColor = AppTheme.paywallBorder.cgColor
  514. subToolbar.layer?.borderWidth = 1
  515. toolButtons.values.forEach { $0.refreshAppearance() }
  516. rebuildSubToolbar()
  517. }
  518. private func setup() {
  519. wantsLayer = true
  520. layer?.cornerRadius = 28
  521. toolStack.orientation = .vertical
  522. toolStack.spacing = 4
  523. toolStack.translatesAutoresizingMaskIntoConstraints = false
  524. subToolbar.wantsLayer = true
  525. subToolbar.layer?.cornerRadius = 20
  526. subToolbar.translatesAutoresizingMaskIntoConstraints = false
  527. for tool in CanvasTool.allCases {
  528. let button = CanvasToolButton(tool: tool)
  529. button.onClick = { [weak self] in self?.selectTool(tool) }
  530. toolButtons[tool] = button
  531. toolStack.addArrangedSubview(button)
  532. }
  533. addSubview(subToolbar)
  534. addSubview(toolStack)
  535. subToolbarHeight = subToolbar.heightAnchor.constraint(equalToConstant: 44)
  536. subToolbarWidth = subToolbar.widthAnchor.constraint(equalToConstant: 120)
  537. NSLayoutConstraint.activate([
  538. toolStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6),
  539. toolStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6),
  540. toolStack.topAnchor.constraint(equalTo: topAnchor, constant: 10),
  541. toolStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10),
  542. subToolbar.trailingAnchor.constraint(equalTo: leadingAnchor, constant: -8),
  543. subToolbar.centerYAnchor.constraint(equalTo: toolStack.arrangedSubviews.first?.centerYAnchor ?? centerYAnchor),
  544. subToolbarHeight!,
  545. subToolbarWidth!,
  546. ])
  547. }
  548. private func selectTool(_ tool: CanvasTool) {
  549. activeTool = tool
  550. toolButtons.forEach { $0.value.setSelected($0.key == tool) }
  551. onToolChanged?(tool)
  552. rebuildSubToolbar()
  553. }
  554. private func rebuildSubToolbar() {
  555. subToolbar.subviews.forEach { $0.removeFromSuperview() }
  556. let stack = NSStackView()
  557. stack.orientation = .horizontal
  558. stack.spacing = 6
  559. stack.translatesAutoresizingMaskIntoConstraints = false
  560. switch activeTool {
  561. case .text:
  562. stack.addArrangedSubview(makeSubButton(symbol: "text.alignleft", action: { [weak self] in
  563. self?.onTextAlignment?(.left)
  564. }))
  565. stack.addArrangedSubview(makeSubButton(symbol: "text.aligncenter", action: { [weak self] in
  566. self?.onTextAlignment?(.center)
  567. }))
  568. stack.addArrangedSubview(makeSubButton(symbol: "text.alignright", action: { [weak self] in
  569. self?.onTextAlignment?(.right)
  570. }))
  571. stack.addArrangedSubview(makeSubButton(symbol: "textformat.size", action: { [weak self] in
  572. self?.onTextFontSize?(22)
  573. }))
  574. stack.addArrangedSubview(makeSubButton(symbol: "character.textbox", action: { [weak self] in
  575. self?.onAddText?()
  576. }))
  577. stack.addArrangedSubview(makeColorButton(color: .black, action: { [weak self] in
  578. self?.pickTextColor()
  579. }))
  580. case .image:
  581. stack.addArrangedSubview(makeSubButton(symbol: "photo.on.rectangle.angled", action: { [weak self] in
  582. self?.onAddImage?()
  583. }))
  584. case .draw:
  585. for color in drawColors {
  586. stack.addArrangedSubview(makeColorButton(color: color, action: { [weak self] in
  587. self?.onDrawColor?(color)
  588. }))
  589. }
  590. stack.addArrangedSubview(makeSubButton(symbol: "lineweight", action: { [weak self] in
  591. self?.onDrawLineWidth?(5)
  592. }))
  593. }
  594. subToolbar.addSubview(stack)
  595. NSLayoutConstraint.activate([
  596. stack.leadingAnchor.constraint(equalTo: subToolbar.leadingAnchor, constant: 10),
  597. stack.trailingAnchor.constraint(equalTo: subToolbar.trailingAnchor, constant: -10),
  598. stack.topAnchor.constraint(equalTo: subToolbar.topAnchor, constant: 6),
  599. stack.bottomAnchor.constraint(equalTo: subToolbar.bottomAnchor, constant: -6),
  600. ])
  601. let itemCount = max(stack.arrangedSubviews.count, 1)
  602. subToolbarHeight?.constant = 44
  603. let width = CGFloat(itemCount) * 38 + CGFloat(max(itemCount - 1, 0)) * 6 + 20
  604. subToolbarWidth?.constant = width
  605. }
  606. private func makeSubButton(symbol: String, action: @escaping () -> Void) -> CanvasSubToolButton {
  607. let button = CanvasSubToolButton(symbolName: symbol)
  608. button.onClick = action
  609. return button
  610. }
  611. private func makeColorButton(color: NSColor, action: @escaping () -> Void) -> CanvasColorButton {
  612. let button = CanvasColorButton(color: color)
  613. button.onClick = action
  614. return button
  615. }
  616. private func pickTextColor() {
  617. let panel = NSColorPanel.shared
  618. panel.setTarget(self)
  619. panel.setAction(#selector(textColorPanelChanged(_:)))
  620. panel.orderFront(nil)
  621. }
  622. @objc private func textColorPanelChanged(_ sender: NSColorPanel) {
  623. onTextColor?(sender.color)
  624. }
  625. }
  626. // MARK: - Controls
  627. private final class DrawPrintToolbarButton: NSControl, AppearanceRefreshable {
  628. var onClick: (() -> Void)?
  629. private let symbolName: String
  630. private let iconView = NSImageView()
  631. init(symbolName: String, accessibilityLabel: String) {
  632. self.symbolName = symbolName
  633. super.init(frame: .zero)
  634. toolTip = accessibilityLabel
  635. wantsLayer = true
  636. layer?.cornerRadius = 12
  637. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  638. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  639. iconView.image = image.withSymbolConfiguration(config)
  640. }
  641. iconView.translatesAutoresizingMaskIntoConstraints = false
  642. addSubview(iconView)
  643. NSLayoutConstraint.activate([
  644. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  645. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  646. ])
  647. refreshAppearance()
  648. }
  649. @available(*, unavailable)
  650. required init?(coder: NSCoder) { nil }
  651. func refreshAppearance() {
  652. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  653. layer?.borderColor = AppTheme.paywallBorder.cgColor
  654. layer?.borderWidth = 1.5
  655. iconView.contentTintColor = AppTheme.textPrimary
  656. }
  657. override func mouseUp(with event: NSEvent) {
  658. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  659. onClick?()
  660. }
  661. override func resetCursorRects() {
  662. addCursorRect(bounds, cursor: .pointingHand)
  663. }
  664. }
  665. private final class DrawPrintPrintButton: NSControl, AppearanceRefreshable {
  666. var onClick: (() -> Void)?
  667. private let titleLabel = NSTextField(labelWithString: "Print Canvas")
  668. private let iconView = NSImageView()
  669. private var hoverTracker: HoverTracker?
  670. private var isHovered = false
  671. init() {
  672. super.init(frame: .zero)
  673. wantsLayer = true
  674. layer?.cornerRadius = 22
  675. titleLabel.font = AppTheme.semiboldFont(size: 16)
  676. titleLabel.textColor = .white
  677. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  678. if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
  679. let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  680. iconView.image = image.withSymbolConfiguration(config)
  681. }
  682. iconView.contentTintColor = .white
  683. iconView.translatesAutoresizingMaskIntoConstraints = false
  684. addSubview(titleLabel)
  685. addSubview(iconView)
  686. NSLayoutConstraint.activate([
  687. heightAnchor.constraint(equalToConstant: 52),
  688. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
  689. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  690. iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
  691. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  692. iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
  693. iconView.widthAnchor.constraint(equalToConstant: 20),
  694. iconView.heightAnchor.constraint(equalToConstant: 20),
  695. ])
  696. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  697. self?.setHovered(hovering)
  698. }
  699. refreshAppearance()
  700. }
  701. @available(*, unavailable)
  702. required init?(coder: NSCoder) { nil }
  703. func refreshAppearance() {
  704. let color = isHovered
  705. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  706. : AppTheme.blue
  707. layer?.backgroundColor = color.cgColor
  708. titleLabel.textColor = .white
  709. iconView.contentTintColor = .white
  710. }
  711. private func setHovered(_ hovering: Bool) {
  712. isHovered = hovering
  713. animateHover {
  714. let color = hovering
  715. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  716. : AppTheme.blue
  717. layer?.backgroundColor = color.cgColor
  718. layer?.transform = hovering
  719. ? CATransform3DMakeScale(1.03, 1.03, 1)
  720. : CATransform3DIdentity
  721. }
  722. }
  723. override func mouseUp(with event: NSEvent) {
  724. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  725. onClick?()
  726. }
  727. override func resetCursorRects() {
  728. addCursorRect(bounds, cursor: .pointingHand)
  729. }
  730. }
  731. private final class CanvasHistoryButton: NSControl, AppearanceRefreshable {
  732. var onClick: (() -> Void)?
  733. private var actionEnabled = true {
  734. didSet { alphaValue = actionEnabled ? 1 : 0.35 }
  735. }
  736. private let iconView = NSImageView()
  737. init(symbolName: String, accessibilityLabel: String) {
  738. super.init(frame: .zero)
  739. toolTip = accessibilityLabel
  740. wantsLayer = true
  741. layer?.cornerRadius = 16
  742. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  743. let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  744. iconView.image = image.withSymbolConfiguration(config)
  745. }
  746. iconView.translatesAutoresizingMaskIntoConstraints = false
  747. addSubview(iconView)
  748. NSLayoutConstraint.activate([
  749. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  750. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  751. ])
  752. refreshAppearance()
  753. }
  754. @available(*, unavailable)
  755. required init?(coder: NSCoder) { nil }
  756. func refreshAppearance() {
  757. layer?.backgroundColor = NSColor(calibratedWhite: 0.88, alpha: 1).cgColor
  758. iconView.contentTintColor = NSColor(calibratedWhite: 0.35, alpha: 1)
  759. }
  760. func setActionEnabled(_ enabled: Bool) {
  761. actionEnabled = enabled
  762. }
  763. override func mouseUp(with event: NSEvent) {
  764. guard actionEnabled, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  765. onClick?()
  766. }
  767. override func resetCursorRects() {
  768. addCursorRect(bounds, cursor: .pointingHand)
  769. }
  770. }
  771. private final class CanvasToolButton: NSControl, AppearanceRefreshable {
  772. var onClick: (() -> Void)?
  773. private let tool: CanvasTool
  774. private let iconView = NSImageView()
  775. private var selected = false
  776. init(tool: CanvasTool) {
  777. self.tool = tool
  778. super.init(frame: .zero)
  779. wantsLayer = true
  780. layer?.cornerRadius = 14
  781. let symbol: String
  782. switch tool {
  783. case .text: symbol = "character.cursor.ibeam"
  784. case .image: symbol = "photo.on.rectangle.angled"
  785. case .draw: symbol = "paintbrush.pointed.fill"
  786. }
  787. if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: toolLabel) {
  788. let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .medium)
  789. iconView.image = image.withSymbolConfiguration(config)
  790. }
  791. iconView.translatesAutoresizingMaskIntoConstraints = false
  792. addSubview(iconView)
  793. NSLayoutConstraint.activate([
  794. widthAnchor.constraint(equalToConstant: 44),
  795. heightAnchor.constraint(equalToConstant: 44),
  796. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  797. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  798. ])
  799. refreshAppearance()
  800. }
  801. @available(*, unavailable)
  802. required init?(coder: NSCoder) { nil }
  803. private var toolLabel: String {
  804. switch tool {
  805. case .text: return "Text"
  806. case .image: return "Image"
  807. case .draw: return "Draw"
  808. }
  809. }
  810. func setSelected(_ selected: Bool) {
  811. self.selected = selected
  812. refreshAppearance()
  813. }
  814. func refreshAppearance() {
  815. layer?.backgroundColor = selected
  816. ? AppTheme.homeActiveBackground.cgColor
  817. : NSColor.clear.cgColor
  818. iconView.contentTintColor = selected ? AppTheme.homeActiveForeground : AppTheme.textPrimary
  819. }
  820. override func mouseUp(with event: NSEvent) {
  821. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  822. onClick?()
  823. }
  824. override func resetCursorRects() {
  825. addCursorRect(bounds, cursor: .pointingHand)
  826. }
  827. }
  828. private final class CanvasSubToolButton: NSControl, AppearanceRefreshable {
  829. var onClick: (() -> Void)?
  830. private let iconView = NSImageView()
  831. init(symbolName: String) {
  832. super.init(frame: .zero)
  833. wantsLayer = true
  834. layer?.cornerRadius = 16
  835. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) {
  836. let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
  837. iconView.image = image.withSymbolConfiguration(config)
  838. }
  839. iconView.translatesAutoresizingMaskIntoConstraints = false
  840. addSubview(iconView)
  841. NSLayoutConstraint.activate([
  842. widthAnchor.constraint(equalToConstant: 32),
  843. heightAnchor.constraint(equalToConstant: 32),
  844. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  845. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  846. ])
  847. refreshAppearance()
  848. }
  849. @available(*, unavailable)
  850. required init?(coder: NSCoder) { nil }
  851. func refreshAppearance() {
  852. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  853. layer?.borderColor = AppTheme.paywallBorder.cgColor
  854. layer?.borderWidth = 1
  855. iconView.contentTintColor = AppTheme.textPrimary
  856. }
  857. override func mouseUp(with event: NSEvent) {
  858. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  859. onClick?()
  860. }
  861. override func resetCursorRects() {
  862. addCursorRect(bounds, cursor: .pointingHand)
  863. }
  864. }
  865. private final class CanvasColorButton: NSControl, AppearanceRefreshable {
  866. var onClick: (() -> Void)?
  867. private let swatchColor: NSColor
  868. init(color: NSColor) {
  869. self.swatchColor = color
  870. super.init(frame: .zero)
  871. wantsLayer = true
  872. layer?.cornerRadius = 14
  873. layer?.borderWidth = 2
  874. layer?.borderColor = AppTheme.paywallBorder.cgColor
  875. NSLayoutConstraint.activate([
  876. widthAnchor.constraint(equalToConstant: 28),
  877. heightAnchor.constraint(equalToConstant: 28),
  878. ])
  879. refreshAppearance()
  880. }
  881. @available(*, unavailable)
  882. required init?(coder: NSCoder) { nil }
  883. func refreshAppearance() {
  884. layer?.backgroundColor = swatchColor.cgColor
  885. }
  886. override func mouseUp(with event: NSEvent) {
  887. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  888. onClick?()
  889. }
  890. override func resetCursorRects() {
  891. addCursorRect(bounds, cursor: .pointingHand)
  892. }
  893. }