DrawPrintView.swift 82 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258
  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 {
  13. case pointer
  14. case text
  15. case image
  16. case draw
  17. static let toolbarTools: [CanvasTool] = [.text, .image, .draw]
  18. }
  19. struct CanvasStroke {
  20. var points: [NSPoint]
  21. var color: NSColor
  22. var lineWidth: CGFloat
  23. }
  24. enum CanvasTextFontStyle: String, CaseIterable {
  25. case regular = "Regular"
  26. case bold = "Bold"
  27. case italic = "Italic"
  28. case boldItalic = "Bold Italic"
  29. case serif = "Serif"
  30. case monospace = "Monospace"
  31. func font(size: CGFloat) -> NSFont {
  32. switch self {
  33. case .regular:
  34. return AppTheme.regularFont(size: size)
  35. case .bold:
  36. return .systemFont(ofSize: size, weight: .bold)
  37. case .italic:
  38. let base = AppTheme.regularFont(size: size)
  39. return NSFontManager.shared.convert(base, toHaveTrait: .italicFontMask) ?? base
  40. case .boldItalic:
  41. let bold = NSFont.systemFont(ofSize: size, weight: .bold)
  42. return NSFontManager.shared.convert(bold, toHaveTrait: .italicFontMask) ?? bold
  43. case .serif:
  44. return NSFont(name: "Times New Roman", size: size)
  45. ?? NSFont(name: "Times-Roman", size: size)
  46. ?? .systemFont(ofSize: size)
  47. case .monospace:
  48. return .monospacedSystemFont(ofSize: size, weight: .regular)
  49. }
  50. }
  51. }
  52. struct CanvasTextData {
  53. var id: UUID
  54. var text: String
  55. var origin: NSPoint
  56. var fontSize: CGFloat
  57. var fontStyle: CanvasTextFontStyle
  58. var color: NSColor
  59. var alignment: NSTextAlignment
  60. }
  61. struct CanvasImageData {
  62. var id: UUID
  63. var origin: NSPoint
  64. var size: NSSize
  65. var image: NSImage
  66. }
  67. struct CanvasSnapshot {
  68. var strokes: [CanvasStroke]
  69. var texts: [CanvasTextData]
  70. var images: [CanvasImageData]
  71. }
  72. // MARK: - Overlay
  73. final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
  74. private static let toolsPanelWidth: CGFloat = 48
  75. private static let toolsPanelGap: CGFloat = 12
  76. var onDismiss: (() -> Void)?
  77. override var mouseDownCanMoveWindow: Bool { false }
  78. private let backButton = DrawPrintToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
  79. private let titleLabel = NSTextField(labelWithString: "Blank Canvas")
  80. private let canvasContainer = NSView()
  81. private let canvasView = BlankCanvasView()
  82. private let addTextPromptView = AddTextPromptView()
  83. private var pendingTextPlacement: NSPoint?
  84. private var suppressCanvasTextPrompt = false
  85. private let toolsPanel = CanvasToolsPanel()
  86. private let bottomButtonStack = NSStackView()
  87. private let savePDFButton = DrawPrintSecondaryButton(title: "Save PDF", symbolName: "square.and.arrow.down")
  88. private let printButton = DrawPrintPrintButton()
  89. init() {
  90. super.init(frame: .zero)
  91. translatesAutoresizingMaskIntoConstraints = false
  92. setup()
  93. refreshAppearance()
  94. NotificationCenter.default.addObserver(
  95. self,
  96. selector: #selector(appearanceDidChange),
  97. name: .appearanceDidChange,
  98. object: nil
  99. )
  100. }
  101. deinit {
  102. NotificationCenter.default.removeObserver(self)
  103. }
  104. @objc private func appearanceDidChange() {
  105. refreshAppearance()
  106. }
  107. @available(*, unavailable)
  108. required init?(coder: NSCoder) { nil }
  109. func refreshAppearance() {
  110. layer?.backgroundColor = AppTheme.background.cgColor
  111. titleLabel.textColor = AppTheme.textPrimary
  112. canvasContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  113. canvasContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
  114. backButton.refreshAppearance()
  115. savePDFButton.refreshAppearance()
  116. printButton.refreshAppearance()
  117. canvasView.refreshAppearance()
  118. toolsPanel.refreshAppearance()
  119. addTextPromptView.refreshAppearance()
  120. }
  121. func present(in parent: NSView) {
  122. guard superview == nil else { return }
  123. parent.addSubview(self)
  124. NSLayoutConstraint.activate([
  125. leadingAnchor.constraint(equalTo: parent.leadingAnchor),
  126. trailingAnchor.constraint(equalTo: parent.trailingAnchor),
  127. topAnchor.constraint(equalTo: parent.topAnchor),
  128. bottomAnchor.constraint(equalTo: parent.bottomAnchor),
  129. ])
  130. alphaValue = 0
  131. NSAnimationContext.runAnimationGroup { context in
  132. context.duration = 0.2
  133. animator().alphaValue = 1
  134. }
  135. }
  136. func dismiss(animated: Bool = true) {
  137. let remove = { [weak self] in
  138. self?.removeFromSuperview()
  139. self?.onDismiss?()
  140. }
  141. guard animated else {
  142. remove()
  143. return
  144. }
  145. NSAnimationContext.runAnimationGroup({ context in
  146. context.duration = 0.15
  147. animator().alphaValue = 0
  148. }, completionHandler: remove)
  149. }
  150. private func setup() {
  151. wantsLayer = true
  152. titleLabel.font = AppTheme.semiboldFont(size: 18)
  153. titleLabel.alignment = .center
  154. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  155. canvasContainer.wantsLayer = true
  156. canvasContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  157. canvasContainer.layer?.borderWidth = 1.5
  158. canvasContainer.applyCardShadow()
  159. canvasContainer.translatesAutoresizingMaskIntoConstraints = false
  160. canvasView.translatesAutoresizingMaskIntoConstraints = false
  161. toolsPanel.translatesAutoresizingMaskIntoConstraints = false
  162. backButton.translatesAutoresizingMaskIntoConstraints = false
  163. bottomButtonStack.translatesAutoresizingMaskIntoConstraints = false
  164. backButton.onClick = { [weak self] in self?.dismiss() }
  165. savePDFButton.onClick = { [weak self] in self?.saveCanvasPDF() }
  166. printButton.onClick = { [weak self] in self?.printCanvas() }
  167. toolsPanel.onToolChanged = { [weak self] tool in
  168. self?.canvasView.activeTool = tool
  169. }
  170. toolsPanel.onAddText = { [weak self] in
  171. self?.pendingTextPlacement = nil
  172. self?.presentAddTextDialog()
  173. }
  174. toolsPanel.onTextAlignment = { [weak self] alignment in
  175. self?.canvasView.setSelectedTextAlignment(alignment)
  176. }
  177. toolsPanel.onTextFontSize = { [weak self] size in
  178. self?.canvasView.setSelectedTextFontSize(size)
  179. }
  180. toolsPanel.onTextColor = { [weak self] color in
  181. self?.canvasView.setSelectedTextColor(color)
  182. }
  183. toolsPanel.onDrawColor = { [weak self] color in
  184. self?.canvasView.drawColor = color
  185. }
  186. toolsPanel.onDrawLineWidth = { [weak self] width in
  187. self?.canvasView.drawLineWidth = width
  188. }
  189. canvasView.onRequestAddImage = { [weak self] point in
  190. self?.pickImage(at: point)
  191. }
  192. canvasView.onRequestAddText = { [weak self] point in
  193. guard let self, !self.suppressCanvasTextPrompt else { return }
  194. self.pendingTextPlacement = point
  195. self.presentAddTextDialog()
  196. }
  197. bottomButtonStack.orientation = .horizontal
  198. bottomButtonStack.spacing = 12
  199. bottomButtonStack.addArrangedSubview(savePDFButton)
  200. bottomButtonStack.addArrangedSubview(printButton)
  201. canvasContainer.addSubview(canvasView)
  202. addSubview(backButton)
  203. addSubview(titleLabel)
  204. addSubview(canvasContainer)
  205. addSubview(toolsPanel)
  206. addSubview(bottomButtonStack)
  207. addSubview(addTextPromptView)
  208. NSLayoutConstraint.activate([
  209. backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
  210. backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
  211. backButton.widthAnchor.constraint(equalToConstant: 40),
  212. backButton.heightAnchor.constraint(equalToConstant: 40),
  213. titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  214. titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
  215. canvasContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
  216. canvasContainer.trailingAnchor.constraint(
  217. equalTo: trailingAnchor,
  218. constant: -(AppTheme.contentPanelInset + Self.toolsPanelWidth + Self.toolsPanelGap)
  219. ),
  220. canvasContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
  221. canvasContainer.bottomAnchor.constraint(equalTo: bottomButtonStack.topAnchor, constant: -28),
  222. toolsPanel.leadingAnchor.constraint(equalTo: canvasContainer.trailingAnchor, constant: Self.toolsPanelGap),
  223. toolsPanel.centerYAnchor.constraint(equalTo: canvasContainer.centerYAnchor),
  224. toolsPanel.widthAnchor.constraint(equalToConstant: Self.toolsPanelWidth),
  225. canvasView.leadingAnchor.constraint(equalTo: canvasContainer.leadingAnchor),
  226. canvasView.trailingAnchor.constraint(equalTo: canvasContainer.trailingAnchor),
  227. canvasView.topAnchor.constraint(equalTo: canvasContainer.topAnchor),
  228. canvasView.bottomAnchor.constraint(equalTo: canvasContainer.bottomAnchor),
  229. bottomButtonStack.centerXAnchor.constraint(equalTo: centerXAnchor),
  230. bottomButtonStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
  231. addTextPromptView.leadingAnchor.constraint(equalTo: leadingAnchor),
  232. addTextPromptView.trailingAnchor.constraint(equalTo: trailingAnchor),
  233. addTextPromptView.topAnchor.constraint(equalTo: topAnchor),
  234. addTextPromptView.bottomAnchor.constraint(equalTo: bottomAnchor),
  235. ])
  236. addTextPromptView.isHidden = true
  237. addTextPromptView.alphaValue = 0
  238. addTextPromptView.onAdd = { [weak self] result in
  239. self?.commitAddedText(result)
  240. }
  241. addTextPromptView.onCancel = { [weak self] in
  242. self?.pendingTextPlacement = nil
  243. self?.hideAddTextPrompt()
  244. }
  245. }
  246. private func pickImage(at placement: NSPoint? = nil) {
  247. let panel = NSOpenPanel()
  248. panel.title = "Add Image"
  249. panel.message = "Select an image to place on the canvas."
  250. panel.prompt = "Add"
  251. panel.canChooseDirectories = false
  252. panel.canChooseFiles = true
  253. panel.allowsMultipleSelection = false
  254. panel.allowedContentTypes = [.image, .png, .jpeg, .gif, .tiff, .bmp, .heic]
  255. guard panel.runModal() == .OK, let url = panel.url,
  256. let image = NSImage(contentsOf: url) else { return }
  257. canvasView.addImage(image, at: placement)
  258. toolsPanel.setActiveTool(.pointer)
  259. }
  260. private func commitAddedText(_ result: AddTextResult) {
  261. let placement = pendingTextPlacement
  262. pendingTextPlacement = nil
  263. suppressCanvasTextPrompt = true
  264. canvasView.stopInlineTextEditing()
  265. layoutSubtreeIfNeeded()
  266. canvasContainer.layoutSubtreeIfNeeded()
  267. canvasView.layoutSubtreeIfNeeded()
  268. canvasView.addTextItem(
  269. text: result.text,
  270. fontSize: result.fontSize,
  271. fontStyle: result.fontStyle,
  272. at: placement
  273. )
  274. toolsPanel.setActiveTool(.pointer)
  275. DispatchQueue.main.async { [weak self] in
  276. self?.hideAddTextPrompt(animated: false)
  277. DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
  278. self?.suppressCanvasTextPrompt = false
  279. }
  280. }
  281. }
  282. private func presentAddTextDialog() {
  283. addSubview(addTextPromptView, positioned: .above, relativeTo: nil)
  284. addTextPromptView.prepareForPresentation()
  285. addTextPromptView.isHidden = false
  286. NSAnimationContext.runAnimationGroup { context in
  287. context.duration = 0.18
  288. addTextPromptView.animator().alphaValue = 1
  289. } completionHandler: { [weak self] in
  290. self?.addTextPromptView.focusInput()
  291. }
  292. }
  293. private func hideAddTextPrompt(animated: Bool = true) {
  294. let hide = { [weak self] in
  295. self?.addTextPromptView.alphaValue = 0
  296. self?.addTextPromptView.isHidden = true
  297. }
  298. guard animated else {
  299. hide()
  300. return
  301. }
  302. NSAnimationContext.runAnimationGroup({ context in
  303. context.duration = 0.14
  304. addTextPromptView.animator().alphaValue = 0
  305. }, completionHandler: hide)
  306. }
  307. private func canvasSnapshot() -> NSImage? {
  308. guard canvasView.hasContent else {
  309. PrintService.showEmptyCanvasAlert()
  310. return nil
  311. }
  312. return canvasView.renderSnapshot()
  313. }
  314. private func printCanvas() {
  315. guard let image = canvasSnapshot() else { return }
  316. PrintService.printCanvas(image, from: window)
  317. }
  318. private func saveCanvasPDF() {
  319. guard let image = canvasSnapshot() else { return }
  320. PrintService.saveCanvasPDF(image)
  321. }
  322. }
  323. // MARK: - Canvas View
  324. private enum CanvasResizeHandle {
  325. case topLeft
  326. case topRight
  327. case bottomLeft
  328. case bottomRight
  329. }
  330. private enum CanvasResizeTarget {
  331. case text(UUID)
  332. case image(UUID)
  333. }
  334. final class BlankCanvasView: NSView, AppearanceRefreshable {
  335. private static let resizeHandleSize: CGFloat = 8
  336. private static let resizeHandleHitPadding: CGFloat = 6
  337. private static let minImageDimension: CGFloat = 24
  338. private static let minTextFontSize: CGFloat = 8
  339. private static let maxTextFontSize: CGFloat = 120
  340. override var isOpaque: Bool { true }
  341. override var mouseDownCanMoveWindow: Bool { false }
  342. var activeTool: CanvasTool = .draw {
  343. didSet {
  344. if oldValue == .text, activeTool != .text {
  345. endEditingText()
  346. }
  347. updateCursorForActiveTool()
  348. needsDisplay = true
  349. }
  350. }
  351. var drawColor: NSColor = NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1)
  352. var drawLineWidth: CGFloat = 2
  353. var onUndoStateChanged: ((_ canUndo: Bool, _ canRedo: Bool) -> Void)?
  354. var onRequestAddImage: ((NSPoint) -> Void)?
  355. var onRequestAddText: ((NSPoint) -> Void)?
  356. var hasContent: Bool {
  357. syncTextFromFields()
  358. let hasText = textItems.contains { !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
  359. return !strokes.isEmpty || !imageItems.isEmpty || hasText
  360. }
  361. private var strokes: [CanvasStroke] = []
  362. private var textItems: [CanvasTextData] = []
  363. private var imageItems: [CanvasImageData] = []
  364. private var undoStack: [CanvasSnapshot] = []
  365. private var redoStack: [CanvasSnapshot] = []
  366. private var currentStroke: CanvasStroke?
  367. private var selectedTextID: UUID?
  368. private var editingTextID: UUID?
  369. private var selectedImageID: UUID?
  370. private var draggingTextID: UUID?
  371. private var draggingImageID: UUID?
  372. private var dragStartPoint = NSPoint.zero
  373. private var dragStartOrigin = NSPoint.zero
  374. private var textDragSnapshotPushed = false
  375. private var imageDragSnapshotPushed = false
  376. private var resizingTextID: UUID?
  377. private var resizingImageID: UUID?
  378. private var activeResizeHandle: CanvasResizeHandle?
  379. private var resizeStartPoint = NSPoint.zero
  380. private var resizeStartRect = NSRect.zero
  381. private var resizeStartFontSize: CGFloat = 18
  382. private var resizeFixedCorner = NSPoint.zero
  383. private let undoButton = CanvasHistoryButton(symbolName: "arrow.uturn.backward", accessibilityLabel: "Undo")
  384. private let redoButton = CanvasHistoryButton(symbolName: "arrow.uturn.forward", accessibilityLabel: "Redo")
  385. init() {
  386. super.init(frame: .zero)
  387. setup()
  388. refreshAppearance()
  389. notifyUndoState()
  390. }
  391. @available(*, unavailable)
  392. required init?(coder: NSCoder) { nil }
  393. func refreshAppearance() {
  394. undoButton.refreshAppearance()
  395. redoButton.refreshAppearance()
  396. }
  397. func stopInlineTextEditing() {
  398. endEditingText()
  399. }
  400. private func setup() {
  401. wantsLayer = true
  402. layerContentsRedrawPolicy = .onSetNeedsDisplay
  403. layer?.cornerRadius = AppTheme.contentPanelCornerRadius
  404. layer?.masksToBounds = true
  405. undoButton.translatesAutoresizingMaskIntoConstraints = false
  406. redoButton.translatesAutoresizingMaskIntoConstraints = false
  407. undoButton.onClick = { [weak self] in self?.undo() }
  408. redoButton.onClick = { [weak self] in self?.redo() }
  409. addSubview(undoButton)
  410. addSubview(redoButton)
  411. NSLayoutConstraint.activate([
  412. undoButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
  413. undoButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12),
  414. undoButton.widthAnchor.constraint(equalToConstant: 32),
  415. undoButton.heightAnchor.constraint(equalToConstant: 32),
  416. redoButton.leadingAnchor.constraint(equalTo: undoButton.trailingAnchor, constant: 8),
  417. redoButton.centerYAnchor.constraint(equalTo: undoButton.centerYAnchor),
  418. redoButton.widthAnchor.constraint(equalToConstant: 32),
  419. redoButton.heightAnchor.constraint(equalToConstant: 32),
  420. ])
  421. updateCursorForActiveTool()
  422. }
  423. private func updateCursorForActiveTool() {
  424. window?.invalidateCursorRects(for: self)
  425. }
  426. override func resetCursorRects() {
  427. let cursor: NSCursor
  428. switch activeTool {
  429. case .pointer:
  430. cursor = .arrow
  431. case .draw:
  432. cursor = .crosshair
  433. case .text:
  434. cursor = .iBeam
  435. case .image:
  436. cursor = .pointingHand
  437. }
  438. addCursorRect(bounds, cursor: cursor)
  439. }
  440. override func hitTest(_ point: NSPoint) -> NSView? {
  441. for button in [redoButton, undoButton] {
  442. let localPoint = convert(point, to: button)
  443. if let hit = button.hitTest(localPoint) {
  444. return hit
  445. }
  446. }
  447. if resizeHandle(at: point) != nil {
  448. return self
  449. }
  450. if (activeTool == .text || activeTool == .pointer),
  451. editingTextID == nil, textIndex(at: point) != nil {
  452. return self
  453. }
  454. if (activeTool == .image || activeTool == .pointer), imageIndex(at: point) != nil {
  455. return self
  456. }
  457. return super.hitTest(point)
  458. }
  459. private func bringUndoButtonsToFront() {
  460. addSubview(redoButton, positioned: .above, relativeTo: nil)
  461. addSubview(undoButton, positioned: .below, relativeTo: redoButton)
  462. }
  463. private func updateUndoButtons(canUndo: Bool, canRedo: Bool) {
  464. undoButton.setActionEnabled(canUndo)
  465. redoButton.setActionEnabled(canRedo)
  466. }
  467. override func draw(_ dirtyRect: NSRect) {
  468. NSColor.white.setFill()
  469. NSBezierPath(
  470. roundedRect: bounds,
  471. xRadius: AppTheme.contentPanelCornerRadius,
  472. yRadius: AppTheme.contentPanelCornerRadius
  473. ).fill()
  474. let context = NSGraphicsContext.current?.cgContext
  475. for stroke in strokes {
  476. drawStroke(stroke, in: context)
  477. }
  478. if let currentStroke {
  479. drawStroke(currentStroke, in: context)
  480. }
  481. for item in textItems where item.id != editingTextID {
  482. drawTextItem(item)
  483. }
  484. for item in imageItems {
  485. let rect = NSRect(origin: item.origin, size: item.size)
  486. item.image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
  487. }
  488. if (activeTool == .text || activeTool == .pointer),
  489. let selectedID = selectedTextID,
  490. let item = textItems.first(where: { $0.id == selectedID }),
  491. item.id != editingTextID {
  492. drawTextSelection(for: item)
  493. }
  494. if (activeTool == .image || activeTool == .pointer),
  495. let selectedID = selectedImageID,
  496. let item = imageItems.first(where: { $0.id == selectedID }) {
  497. drawImageSelection(for: item)
  498. }
  499. }
  500. private func drawTextItem(_ item: CanvasTextData) {
  501. let rect = textBounds(for: item)
  502. let paragraph = NSMutableParagraphStyle()
  503. paragraph.alignment = item.alignment
  504. let attributes: [NSAttributedString.Key: Any] = [
  505. .font: item.fontStyle.font(size: item.fontSize),
  506. .foregroundColor: item.color,
  507. .paragraphStyle: paragraph,
  508. ]
  509. (item.text as NSString).draw(in: rect, withAttributes: attributes)
  510. }
  511. private func drawTextSelection(for item: CanvasTextData) {
  512. let rect = selectionRect(for: textBounds(for: item))
  513. drawSelectionChrome(in: rect)
  514. }
  515. private func drawImageSelection(for item: CanvasImageData) {
  516. let rect = selectionRect(for: NSRect(origin: item.origin, size: item.size))
  517. drawSelectionChrome(in: rect)
  518. }
  519. private func selectionRect(for contentRect: NSRect) -> NSRect {
  520. contentRect.insetBy(dx: -4, dy: -4)
  521. }
  522. private func drawSelectionChrome(in rect: NSRect) {
  523. NSColor.clear.setFill()
  524. let path = NSBezierPath(roundedRect: rect, xRadius: 4, yRadius: 4)
  525. path.setLineDash([4, 3], count: 2, phase: 0)
  526. path.lineWidth = 1.5
  527. NSColor(calibratedWhite: 0.55, alpha: 1).setStroke()
  528. path.stroke()
  529. drawResizeHandles(in: rect)
  530. }
  531. private func drawResizeHandles(in rect: NSRect) {
  532. for handleRect in resizeHandleRects(for: rect).values {
  533. NSColor.white.setFill()
  534. NSBezierPath(roundedRect: handleRect, xRadius: 2, yRadius: 2).fill()
  535. NSColor(calibratedWhite: 0.55, alpha: 1).setStroke()
  536. let border = NSBezierPath(roundedRect: handleRect, xRadius: 2, yRadius: 2)
  537. border.lineWidth = 1
  538. border.stroke()
  539. }
  540. }
  541. private func drawStroke(_ stroke: CanvasStroke, in context: CGContext?) {
  542. guard stroke.points.count > 1, let context else { return }
  543. context.saveGState()
  544. context.setStrokeColor(stroke.color.cgColor)
  545. context.setLineWidth(stroke.lineWidth)
  546. context.setLineCap(.round)
  547. context.setLineJoin(.round)
  548. context.beginPath()
  549. context.move(to: stroke.points[0])
  550. for point in stroke.points.dropFirst() {
  551. context.addLine(to: point)
  552. }
  553. context.strokePath()
  554. context.restoreGState()
  555. }
  556. override func mouseDown(with event: NSEvent) {
  557. let point = convert(event.locationInWindow, from: nil)
  558. if undoButton.frame.contains(point) || redoButton.frame.contains(point) {
  559. return
  560. }
  561. if let resize = resizeHandle(at: point) {
  562. beginResize(resize, at: point)
  563. return
  564. }
  565. switch activeTool {
  566. case .pointer:
  567. if let index = textIndex(at: point) {
  568. let id = textItems[index].id
  569. selectedTextID = id
  570. selectedImageID = nil
  571. if event.clickCount >= 2 {
  572. beginEditingText(id: id)
  573. } else {
  574. draggingTextID = id
  575. dragStartPoint = point
  576. dragStartOrigin = textItems[index].origin
  577. textDragSnapshotPushed = false
  578. }
  579. } else if let index = imageIndex(at: point) {
  580. selectedImageID = imageItems[index].id
  581. selectedTextID = nil
  582. draggingImageID = imageItems[index].id
  583. dragStartPoint = point
  584. dragStartOrigin = imageItems[index].origin
  585. imageDragSnapshotPushed = false
  586. } else {
  587. selectedTextID = nil
  588. selectedImageID = nil
  589. if editingTextID != nil {
  590. endEditingText()
  591. }
  592. needsDisplay = true
  593. }
  594. case .draw:
  595. pushSnapshot()
  596. currentStroke = CanvasStroke(points: [point], color: drawColor, lineWidth: drawLineWidth)
  597. needsDisplay = true
  598. case .text:
  599. if let index = textIndex(at: point) {
  600. let id = textItems[index].id
  601. selectedTextID = id
  602. if event.clickCount >= 2 {
  603. beginEditingText(id: id)
  604. } else {
  605. draggingTextID = id
  606. dragStartPoint = point
  607. dragStartOrigin = textItems[index].origin
  608. textDragSnapshotPushed = false
  609. }
  610. } else if editingTextID != nil {
  611. endEditingText()
  612. } else {
  613. onRequestAddText?(point)
  614. }
  615. case .image:
  616. if let index = imageIndex(at: point) {
  617. selectedImageID = imageItems[index].id
  618. draggingImageID = imageItems[index].id
  619. dragStartPoint = point
  620. dragStartOrigin = imageItems[index].origin
  621. imageDragSnapshotPushed = false
  622. } else {
  623. onRequestAddImage?(point)
  624. }
  625. }
  626. }
  627. override func mouseDragged(with event: NSEvent) {
  628. let point = convert(event.locationInWindow, from: nil)
  629. if let handle = activeResizeHandle {
  630. if let id = resizingTextID,
  631. let index = textItems.firstIndex(where: { $0.id == id }) {
  632. applyTextResize(at: point, handle: handle, index: index)
  633. needsDisplay = true
  634. return
  635. }
  636. if let id = resizingImageID,
  637. let index = imageItems.firstIndex(where: { $0.id == id }) {
  638. applyImageResize(at: point, handle: handle, index: index)
  639. needsDisplay = true
  640. return
  641. }
  642. }
  643. if (activeTool == .text || activeTool == .pointer),
  644. let id = draggingTextID,
  645. let index = textItems.firstIndex(where: { $0.id == id }) {
  646. if !textDragSnapshotPushed {
  647. pushSnapshot()
  648. textDragSnapshotPushed = true
  649. }
  650. let dx = point.x - dragStartPoint.x
  651. let dy = point.y - dragStartPoint.y
  652. var origin = NSPoint(x: dragStartOrigin.x + dx, y: dragStartOrigin.y + dy)
  653. let size = textBounds(for: textItems[index]).size
  654. origin.x = max(0, min(origin.x, bounds.width - size.width))
  655. origin.y = max(0, min(origin.y, bounds.height - size.height))
  656. textItems[index].origin = origin
  657. needsDisplay = true
  658. return
  659. }
  660. if (activeTool == .image || activeTool == .pointer),
  661. let id = draggingImageID,
  662. let index = imageItems.firstIndex(where: { $0.id == id }) {
  663. if !imageDragSnapshotPushed {
  664. pushSnapshot()
  665. imageDragSnapshotPushed = true
  666. }
  667. let dx = point.x - dragStartPoint.x
  668. let dy = point.y - dragStartPoint.y
  669. var origin = NSPoint(x: dragStartOrigin.x + dx, y: dragStartOrigin.y + dy)
  670. let size = imageItems[index].size
  671. origin.x = max(0, min(origin.x, bounds.width - size.width))
  672. origin.y = max(0, min(origin.y, bounds.height - size.height))
  673. imageItems[index].origin = origin
  674. needsDisplay = true
  675. return
  676. }
  677. guard activeTool == .draw, var stroke = currentStroke else { return }
  678. stroke.points.append(point)
  679. currentStroke = stroke
  680. needsDisplay = true
  681. }
  682. override func mouseUp(with event: NSEvent) {
  683. if resizingTextID != nil || resizingImageID != nil {
  684. resizingTextID = nil
  685. resizingImageID = nil
  686. activeResizeHandle = nil
  687. notifyUndoState()
  688. return
  689. }
  690. if (activeTool == .text || activeTool == .pointer), draggingTextID != nil {
  691. draggingTextID = nil
  692. textDragSnapshotPushed = false
  693. notifyUndoState()
  694. return
  695. }
  696. if (activeTool == .image || activeTool == .pointer), draggingImageID != nil {
  697. draggingImageID = nil
  698. imageDragSnapshotPushed = false
  699. notifyUndoState()
  700. return
  701. }
  702. guard activeTool == .draw, let stroke = currentStroke, stroke.points.count > 1 else {
  703. currentStroke = nil
  704. return
  705. }
  706. strokes.append(stroke)
  707. currentStroke = nil
  708. needsDisplay = true
  709. notifyUndoState()
  710. }
  711. func addTextItem(
  712. text: String = "Text",
  713. fontSize: CGFloat = 18,
  714. fontStyle: CanvasTextFontStyle = .regular,
  715. at origin: NSPoint? = nil
  716. ) {
  717. guard bounds.width > 1, bounds.height > 1 else {
  718. DispatchQueue.main.async { [weak self] in
  719. self?.addTextItem(text: text, fontSize: fontSize, fontStyle: fontStyle, at: origin)
  720. }
  721. return
  722. }
  723. pushSnapshot()
  724. let defaultSize = textBounds(
  725. for: CanvasTextData(
  726. id: UUID(),
  727. text: text,
  728. origin: .zero,
  729. fontSize: fontSize,
  730. fontStyle: fontStyle,
  731. color: .black,
  732. alignment: .left
  733. )
  734. ).size
  735. let placement: NSPoint
  736. if let origin {
  737. placement = NSPoint(
  738. x: origin.x - defaultSize.width / 2,
  739. y: origin.y - defaultSize.height / 2
  740. )
  741. } else {
  742. placement = NSPoint(
  743. x: (bounds.width - defaultSize.width) / 2,
  744. y: (bounds.height - defaultSize.height) / 2
  745. )
  746. }
  747. var clampedOrigin = placement
  748. clampedOrigin.x = max(0, min(clampedOrigin.x, bounds.width - defaultSize.width))
  749. clampedOrigin.y = max(0, min(clampedOrigin.y, bounds.height - defaultSize.height))
  750. let item = CanvasTextData(
  751. id: UUID(),
  752. text: text,
  753. origin: clampedOrigin,
  754. fontSize: fontSize,
  755. fontStyle: fontStyle,
  756. color: .black,
  757. alignment: .left
  758. )
  759. textItems.append(item)
  760. selectedTextID = item.id
  761. setNeedsDisplay(bounds)
  762. displayIfNeeded()
  763. notifyUndoState()
  764. }
  765. func addImage(_ image: NSImage, at placement: NSPoint? = nil) {
  766. pushSnapshot()
  767. let maxSide: CGFloat = min(bounds.width, bounds.height) * 0.4
  768. let imageSize = image.size
  769. let scale = min(maxSide / max(imageSize.width, 1), maxSide / max(imageSize.height, 1))
  770. let size = NSSize(width: imageSize.width * scale, height: imageSize.height * scale)
  771. var origin: NSPoint
  772. if let placement {
  773. origin = NSPoint(x: placement.x - size.width / 2, y: placement.y - size.height / 2)
  774. origin.x = max(0, min(origin.x, bounds.width - size.width))
  775. origin.y = max(0, min(origin.y, bounds.height - size.height))
  776. } else {
  777. origin = NSPoint(
  778. x: (bounds.width - size.width) / 2,
  779. y: (bounds.height - size.height) / 2
  780. )
  781. }
  782. let item = CanvasImageData(id: UUID(), origin: origin, size: size, image: image)
  783. imageItems.append(item)
  784. selectedImageID = item.id
  785. needsDisplay = true
  786. notifyUndoState()
  787. }
  788. func setSelectedTextAlignment(_ alignment: NSTextAlignment) {
  789. guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return }
  790. pushSnapshot()
  791. textItems[index].alignment = alignment
  792. if let field = textField(for: id) {
  793. field.alignment = alignment
  794. }
  795. needsDisplay = true
  796. notifyUndoState()
  797. }
  798. func setSelectedTextFontSize(_ size: CGFloat) {
  799. guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return }
  800. pushSnapshot()
  801. textItems[index].fontSize = size
  802. if let field = textField(for: id) {
  803. field.font = textItems[index].fontStyle.font(size: size)
  804. field.frame = textBounds(for: textItems[index])
  805. }
  806. needsDisplay = true
  807. notifyUndoState()
  808. }
  809. func setSelectedTextColor(_ color: NSColor) {
  810. guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return }
  811. pushSnapshot()
  812. textItems[index].color = color
  813. if let field = textField(for: id) {
  814. field.textColor = color
  815. }
  816. needsDisplay = true
  817. notifyUndoState()
  818. }
  819. func renderSnapshot() -> NSImage? {
  820. syncTextFromFields()
  821. guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil }
  822. cacheDisplay(in: bounds, to: rep)
  823. let image = NSImage(size: bounds.size)
  824. image.addRepresentation(rep)
  825. return image
  826. }
  827. private func syncTextFromFields() {
  828. guard let id = editingTextID,
  829. let index = textItems.firstIndex(where: { $0.id == id }),
  830. let field = textField(for: id) else { return }
  831. textItems[index].text = field.stringValue
  832. textItems[index].origin = field.frame.origin
  833. }
  834. private func pushSnapshot() {
  835. syncTextFromFields()
  836. undoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems))
  837. redoStack.removeAll()
  838. if undoStack.count > 50 { undoStack.removeFirst() }
  839. notifyUndoState()
  840. }
  841. private func undo() {
  842. guard let snapshot = undoStack.popLast() else { return }
  843. syncTextFromFields()
  844. redoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems))
  845. restore(snapshot)
  846. }
  847. private func redo() {
  848. guard let snapshot = redoStack.popLast() else { return }
  849. syncTextFromFields()
  850. undoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems))
  851. restore(snapshot)
  852. }
  853. private func restore(_ snapshot: CanvasSnapshot) {
  854. strokes = snapshot.strokes
  855. textItems = snapshot.texts
  856. imageItems = snapshot.images
  857. endEditingText()
  858. selectedTextID = nil
  859. selectedImageID = nil
  860. draggingTextID = nil
  861. draggingImageID = nil
  862. resizingTextID = nil
  863. resizingImageID = nil
  864. activeResizeHandle = nil
  865. bringUndoButtonsToFront()
  866. needsDisplay = true
  867. notifyUndoState()
  868. }
  869. private func notifyUndoState() {
  870. let canUndo = !undoStack.isEmpty
  871. let canRedo = !redoStack.isEmpty
  872. onUndoStateChanged?(canUndo, canRedo)
  873. updateUndoButtons(canUndo: canUndo, canRedo: canRedo)
  874. }
  875. private func beginEditingText(id: UUID) {
  876. guard let index = textItems.firstIndex(where: { $0.id == id }) else { return }
  877. endEditingText()
  878. editingTextID = id
  879. selectedTextID = id
  880. let item = textItems[index]
  881. let field = NSTextField(string: item.text)
  882. field.identifier = NSUserInterfaceItemIdentifier(item.id.uuidString)
  883. field.isBordered = false
  884. field.isBezeled = false
  885. field.drawsBackground = false
  886. field.font = item.fontStyle.font(size: item.fontSize)
  887. field.textColor = item.color
  888. field.alignment = item.alignment
  889. field.frame = textBounds(for: item)
  890. field.target = self
  891. field.action = #selector(textFieldFinishedEditing(_:))
  892. addSubview(field)
  893. bringUndoButtonsToFront()
  894. window?.makeFirstResponder(field)
  895. needsDisplay = true
  896. }
  897. private func endEditingText() {
  898. syncTextFromFields()
  899. removeTextFields()
  900. editingTextID = nil
  901. needsDisplay = true
  902. }
  903. @objc private func textFieldFinishedEditing(_ sender: NSTextField) {
  904. guard let idString = sender.identifier?.rawValue,
  905. let id = UUID(uuidString: idString),
  906. let index = textItems.firstIndex(where: { $0.id == id }) else { return }
  907. textItems[index].text = sender.stringValue
  908. textItems[index].origin = sender.frame.origin
  909. selectedTextID = id
  910. endEditingText()
  911. }
  912. private func textField(for id: UUID) -> NSTextField? {
  913. subviews.compactMap { $0 as? NSTextField }.first {
  914. $0.identifier?.rawValue == id.uuidString
  915. }
  916. }
  917. private func removeTextFields() {
  918. subviews.compactMap { $0 as? NSTextField }.forEach { $0.removeFromSuperview() }
  919. }
  920. private func textBounds(for item: CanvasTextData) -> NSRect {
  921. let attributes: [NSAttributedString.Key: Any] = [
  922. .font: item.fontStyle.font(size: item.fontSize),
  923. ]
  924. let textSize = (item.text as NSString).size(withAttributes: attributes)
  925. let width = max(textSize.width + 8, 80)
  926. let height = max(textSize.height + 6, 28)
  927. return NSRect(x: item.origin.x, y: item.origin.y, width: width, height: height)
  928. }
  929. private func textIndex(at point: NSPoint) -> Int? {
  930. for index in textItems.indices.reversed() {
  931. if textBounds(for: textItems[index]).contains(point) {
  932. return index
  933. }
  934. }
  935. return nil
  936. }
  937. private func imageIndex(at point: NSPoint) -> Int? {
  938. for index in imageItems.indices.reversed() {
  939. let rect = NSRect(origin: imageItems[index].origin, size: imageItems[index].size)
  940. if rect.contains(point) {
  941. return index
  942. }
  943. }
  944. return nil
  945. }
  946. private func resizeHandleRects(for rect: NSRect) -> [CanvasResizeHandle: NSRect] {
  947. let half = Self.resizeHandleSize / 2
  948. return [
  949. .topLeft: NSRect(x: rect.minX - half, y: rect.maxY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
  950. .topRight: NSRect(x: rect.maxX - half, y: rect.maxY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
  951. .bottomLeft: NSRect(x: rect.minX - half, y: rect.minY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
  952. .bottomRight: NSRect(x: rect.maxX - half, y: rect.minY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
  953. ]
  954. }
  955. private func resizeHandle(at point: NSPoint) -> (CanvasResizeTarget, CanvasResizeHandle)? {
  956. if (activeTool == .image || activeTool == .pointer),
  957. let id = selectedImageID,
  958. let item = imageItems.first(where: { $0.id == id }) {
  959. let rect = selectionRect(for: NSRect(origin: item.origin, size: item.size))
  960. if let handle = hitResizeHandle(at: point, in: rect) {
  961. return (.image(id), handle)
  962. }
  963. }
  964. if (activeTool == .text || activeTool == .pointer),
  965. let id = selectedTextID,
  966. id != editingTextID,
  967. let item = textItems.first(where: { $0.id == id }) {
  968. let rect = selectionRect(for: textBounds(for: item))
  969. if let handle = hitResizeHandle(at: point, in: rect) {
  970. return (.text(id), handle)
  971. }
  972. }
  973. return nil
  974. }
  975. private func hitResizeHandle(at point: NSPoint, in rect: NSRect) -> CanvasResizeHandle? {
  976. for (handle, handleRect) in resizeHandleRects(for: rect) {
  977. let hitRect = handleRect.insetBy(dx: -Self.resizeHandleHitPadding, dy: -Self.resizeHandleHitPadding)
  978. if hitRect.contains(point) {
  979. return handle
  980. }
  981. }
  982. return nil
  983. }
  984. private func fixedCorner(for rect: NSRect, handle: CanvasResizeHandle) -> NSPoint {
  985. switch handle {
  986. case .topLeft:
  987. return NSPoint(x: rect.maxX, y: rect.minY)
  988. case .topRight:
  989. return NSPoint(x: rect.minX, y: rect.minY)
  990. case .bottomLeft:
  991. return NSPoint(x: rect.maxX, y: rect.maxY)
  992. case .bottomRight:
  993. return NSPoint(x: rect.minX, y: rect.maxY)
  994. }
  995. }
  996. private func beginResize(_ target: (CanvasResizeTarget, CanvasResizeHandle), at point: NSPoint) {
  997. let (resizeTarget, handle) = target
  998. pushSnapshot()
  999. activeResizeHandle = handle
  1000. resizeStartPoint = point
  1001. switch resizeTarget {
  1002. case .text(let id):
  1003. guard let item = textItems.first(where: { $0.id == id }) else { return }
  1004. resizingTextID = id
  1005. resizeStartRect = textBounds(for: item)
  1006. resizeStartFontSize = item.fontSize
  1007. resizeFixedCorner = fixedCorner(for: resizeStartRect, handle: handle)
  1008. case .image(let id):
  1009. guard let item = imageItems.first(where: { $0.id == id }) else { return }
  1010. resizingImageID = id
  1011. resizeStartRect = NSRect(origin: item.origin, size: item.size)
  1012. resizeFixedCorner = fixedCorner(for: resizeStartRect, handle: handle)
  1013. }
  1014. }
  1015. private func applyTextResize(at point: NSPoint, handle: CanvasResizeHandle, index: Int) {
  1016. let startDistance = hypot(resizeStartPoint.x - resizeFixedCorner.x, resizeStartPoint.y - resizeFixedCorner.y)
  1017. let newDistance = hypot(point.x - resizeFixedCorner.x, point.y - resizeFixedCorner.y)
  1018. guard startDistance > 1 else { return }
  1019. let scale = max(0.2, min(5, newDistance / startDistance))
  1020. let newFontSize = min(Self.maxTextFontSize, max(Self.minTextFontSize, resizeStartFontSize * scale))
  1021. textItems[index].fontSize = newFontSize
  1022. let bounds = textBounds(for: textItems[index])
  1023. var origin = NSPoint.zero
  1024. switch handle {
  1025. case .topLeft:
  1026. origin = NSPoint(x: resizeFixedCorner.x - bounds.width, y: resizeFixedCorner.y)
  1027. case .topRight:
  1028. origin = NSPoint(x: resizeFixedCorner.x, y: resizeFixedCorner.y)
  1029. case .bottomLeft:
  1030. origin = NSPoint(x: resizeFixedCorner.x - bounds.width, y: resizeFixedCorner.y - bounds.height)
  1031. case .bottomRight:
  1032. origin = NSPoint(x: resizeFixedCorner.x, y: resizeFixedCorner.y - bounds.height)
  1033. }
  1034. textItems[index].origin = clampedOrigin(origin, size: bounds.size)
  1035. if let field = textField(for: textItems[index].id) {
  1036. field.font = textItems[index].fontStyle.font(size: newFontSize)
  1037. field.frame = textBounds(for: textItems[index])
  1038. }
  1039. }
  1040. private func applyImageResize(at point: NSPoint, handle: CanvasResizeHandle, index: Int) {
  1041. let dx = point.x - resizeStartPoint.x
  1042. let dy = point.y - resizeStartPoint.y
  1043. var rect = resizeStartRect
  1044. switch handle {
  1045. case .topLeft:
  1046. rect.origin.x += dx
  1047. rect.size.width -= dx
  1048. rect.size.height -= dy
  1049. rect.origin.y += dy
  1050. case .topRight:
  1051. rect.size.width += dx
  1052. rect.size.height -= dy
  1053. rect.origin.y += dy
  1054. case .bottomLeft:
  1055. rect.origin.x += dx
  1056. rect.size.width -= dx
  1057. rect.size.height += dy
  1058. case .bottomRight:
  1059. rect.size.width += dx
  1060. rect.size.height += dy
  1061. }
  1062. rect.size.width = max(Self.minImageDimension, rect.size.width)
  1063. rect.size.height = max(Self.minImageDimension, rect.size.height)
  1064. switch handle {
  1065. case .topLeft:
  1066. rect.origin.x = resizeFixedCorner.x - rect.size.width
  1067. rect.origin.y = resizeFixedCorner.y
  1068. case .topRight:
  1069. rect.origin.x = resizeFixedCorner.x
  1070. rect.origin.y = resizeFixedCorner.y
  1071. case .bottomLeft:
  1072. rect.origin.x = resizeFixedCorner.x - rect.size.width
  1073. rect.origin.y = resizeFixedCorner.y - rect.size.height
  1074. case .bottomRight:
  1075. rect.origin.x = resizeFixedCorner.x
  1076. rect.origin.y = resizeFixedCorner.y - rect.size.height
  1077. }
  1078. let clamped = clampedRect(rect)
  1079. imageItems[index].origin = clamped.origin
  1080. imageItems[index].size = clamped.size
  1081. }
  1082. private func clampedOrigin(_ origin: NSPoint, size: NSSize) -> NSPoint {
  1083. var clamped = origin
  1084. clamped.x = max(0, min(clamped.x, bounds.width - size.width))
  1085. clamped.y = max(0, min(clamped.y, bounds.height - size.height))
  1086. return clamped
  1087. }
  1088. private func clampedRect(_ rect: NSRect) -> NSRect {
  1089. var clamped = rect
  1090. clamped.size.width = max(Self.minImageDimension, min(clamped.size.width, bounds.width))
  1091. clamped.size.height = max(Self.minImageDimension, min(clamped.size.height, bounds.height))
  1092. clamped.origin.x = max(0, min(clamped.origin.x, bounds.width - clamped.size.width))
  1093. clamped.origin.y = max(0, min(clamped.origin.y, bounds.height - clamped.size.height))
  1094. return clamped
  1095. }
  1096. }
  1097. // MARK: - Tools Panel
  1098. final class CanvasToolsPanel: NSView, AppearanceRefreshable {
  1099. var onToolChanged: ((CanvasTool) -> Void)?
  1100. var onAddText: (() -> Void)?
  1101. var onTextAlignment: ((NSTextAlignment) -> Void)?
  1102. var onTextFontSize: ((CGFloat) -> Void)?
  1103. var onTextColor: ((NSColor) -> Void)?
  1104. var onDrawColor: ((NSColor) -> Void)?
  1105. var onDrawLineWidth: ((CGFloat) -> Void)?
  1106. private var activeTool: CanvasTool = .draw
  1107. private let toolStack = NSStackView()
  1108. private let subToolbar = NSView()
  1109. private var toolButtons: [CanvasTool: CanvasToolButton] = [:]
  1110. private var subToolbarHeight: NSLayoutConstraint?
  1111. private var subToolbarWidth: NSLayoutConstraint?
  1112. private let drawColors: [NSColor] = [
  1113. NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1),
  1114. NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1),
  1115. NSColor.black,
  1116. NSColor.red,
  1117. NSColor.orange,
  1118. NSColor.purple,
  1119. ]
  1120. private static let fontSizeOptions: [CGFloat] = [12, 14, 16, 18, 20, 22, 24, 28, 32]
  1121. init() {
  1122. super.init(frame: .zero)
  1123. setup()
  1124. refreshAppearance()
  1125. selectTool(.draw)
  1126. }
  1127. @available(*, unavailable)
  1128. required init?(coder: NSCoder) { nil }
  1129. func setActiveTool(_ tool: CanvasTool) {
  1130. selectTool(tool)
  1131. }
  1132. func refreshAppearance() {
  1133. layer?.backgroundColor = AppTheme.cardBackground.cgColor
  1134. layer?.borderColor = AppTheme.paywallBorder.cgColor
  1135. layer?.borderWidth = 1
  1136. subToolbar.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  1137. subToolbar.layer?.borderColor = AppTheme.paywallBorder.cgColor
  1138. subToolbar.layer?.borderWidth = 1
  1139. toolButtons.values.forEach { $0.refreshAppearance() }
  1140. rebuildSubToolbar()
  1141. }
  1142. private func setup() {
  1143. wantsLayer = true
  1144. layer?.cornerRadius = 28
  1145. toolStack.orientation = .vertical
  1146. toolStack.spacing = 4
  1147. toolStack.translatesAutoresizingMaskIntoConstraints = false
  1148. subToolbar.wantsLayer = true
  1149. subToolbar.layer?.cornerRadius = 20
  1150. subToolbar.translatesAutoresizingMaskIntoConstraints = false
  1151. for tool in CanvasTool.toolbarTools {
  1152. let button = CanvasToolButton(tool: tool)
  1153. button.onClick = { [weak self] in
  1154. guard let self else { return }
  1155. if self.activeTool == tool {
  1156. self.selectTool(.pointer)
  1157. } else {
  1158. self.selectTool(tool)
  1159. }
  1160. }
  1161. toolButtons[tool] = button
  1162. toolStack.addArrangedSubview(button)
  1163. }
  1164. addSubview(subToolbar)
  1165. addSubview(toolStack)
  1166. subToolbarHeight = subToolbar.heightAnchor.constraint(equalToConstant: 44)
  1167. subToolbarWidth = subToolbar.widthAnchor.constraint(equalToConstant: 120)
  1168. NSLayoutConstraint.activate([
  1169. toolStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6),
  1170. toolStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6),
  1171. toolStack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
  1172. toolStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
  1173. subToolbar.trailingAnchor.constraint(equalTo: leadingAnchor, constant: -8),
  1174. subToolbar.centerYAnchor.constraint(equalTo: toolStack.arrangedSubviews.first?.centerYAnchor ?? centerYAnchor),
  1175. subToolbarHeight!,
  1176. subToolbarWidth!,
  1177. ])
  1178. }
  1179. private func selectTool(_ tool: CanvasTool) {
  1180. activeTool = tool
  1181. if tool == .pointer {
  1182. toolButtons.values.forEach { $0.setSelected(false) }
  1183. } else {
  1184. toolButtons.forEach { $0.value.setSelected($0.key == tool) }
  1185. }
  1186. onToolChanged?(tool)
  1187. rebuildSubToolbar()
  1188. }
  1189. private func rebuildSubToolbar() {
  1190. subToolbar.subviews.forEach { $0.removeFromSuperview() }
  1191. let stack = NSStackView()
  1192. stack.orientation = .horizontal
  1193. stack.spacing = 6
  1194. stack.translatesAutoresizingMaskIntoConstraints = false
  1195. switch activeTool {
  1196. case .pointer:
  1197. break
  1198. case .text:
  1199. stack.addArrangedSubview(makeSubButton(symbol: "text.alignleft", action: { [weak self] in
  1200. self?.onTextAlignment?(.left)
  1201. }))
  1202. stack.addArrangedSubview(makeSubButton(symbol: "text.aligncenter", action: { [weak self] in
  1203. self?.onTextAlignment?(.center)
  1204. }))
  1205. stack.addArrangedSubview(makeSubButton(symbol: "text.alignright", action: { [weak self] in
  1206. self?.onTextAlignment?(.right)
  1207. }))
  1208. stack.addArrangedSubview(makeFontSizePopup())
  1209. stack.addArrangedSubview(makeSubButton(symbol: "character.textbox", action: { [weak self] in
  1210. self?.onAddText?()
  1211. }))
  1212. stack.addArrangedSubview(makeColorButton(color: .black, action: { [weak self] in
  1213. self?.pickTextColor()
  1214. }))
  1215. case .image:
  1216. break
  1217. case .draw:
  1218. for color in drawColors {
  1219. stack.addArrangedSubview(makeColorButton(color: color, action: { [weak self] in
  1220. self?.onDrawColor?(color)
  1221. }))
  1222. }
  1223. stack.addArrangedSubview(makeSubButton(symbol: "lineweight", action: { [weak self] in
  1224. self?.onDrawLineWidth?(3)
  1225. }))
  1226. }
  1227. subToolbar.addSubview(stack)
  1228. NSLayoutConstraint.activate([
  1229. stack.leadingAnchor.constraint(equalTo: subToolbar.leadingAnchor, constant: 10),
  1230. stack.trailingAnchor.constraint(equalTo: subToolbar.trailingAnchor, constant: -10),
  1231. stack.topAnchor.constraint(equalTo: subToolbar.topAnchor, constant: 6),
  1232. stack.bottomAnchor.constraint(equalTo: subToolbar.bottomAnchor, constant: -6),
  1233. ])
  1234. subToolbar.isHidden = activeTool == .image || activeTool == .pointer
  1235. let itemCount = max(stack.arrangedSubviews.count, 1)
  1236. subToolbarHeight?.constant = 40
  1237. let width = CGFloat(itemCount) * 34 + CGFloat(max(itemCount - 1, 0)) * 6 + 18
  1238. subToolbarWidth?.constant = width
  1239. }
  1240. private func makeSubButton(symbol: String, action: @escaping () -> Void) -> CanvasSubToolButton {
  1241. let button = CanvasSubToolButton(symbolName: symbol)
  1242. button.onClick = action
  1243. return button
  1244. }
  1245. private func makeColorButton(color: NSColor, action: @escaping () -> Void) -> CanvasColorButton {
  1246. let button = CanvasColorButton(color: color)
  1247. button.onClick = action
  1248. return button
  1249. }
  1250. private func makeFontSizePopup() -> NSPopUpButton {
  1251. let popup = NSPopUpButton()
  1252. popup.bezelStyle = .rounded
  1253. popup.addItems(withTitles: Self.fontSizeOptions.map { "\(Int($0))" })
  1254. popup.selectItem(withTitle: "18")
  1255. popup.font = AppTheme.regularFont(size: 11)
  1256. popup.target = self
  1257. popup.action = #selector(fontSizeChanged(_:))
  1258. popup.translatesAutoresizingMaskIntoConstraints = false
  1259. popup.widthAnchor.constraint(equalToConstant: 58).isActive = true
  1260. popup.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1261. return popup
  1262. }
  1263. @objc private func fontSizeChanged(_ sender: NSPopUpButton) {
  1264. guard let title = sender.titleOfSelectedItem,
  1265. let size = Double(title) else { return }
  1266. onTextFontSize?(CGFloat(size))
  1267. }
  1268. private func pickTextColor() {
  1269. let panel = NSColorPanel.shared
  1270. panel.setTarget(self)
  1271. panel.setAction(#selector(textColorPanelChanged(_:)))
  1272. panel.orderFront(nil)
  1273. }
  1274. @objc private func textColorPanelChanged(_ sender: NSColorPanel) {
  1275. onTextColor?(sender.color)
  1276. }
  1277. }
  1278. private final class PlaceholderTextView: NSTextView {
  1279. var placeholderString = ""
  1280. var placeholderColor: NSColor = AppTheme.textSecondary {
  1281. didSet { needsDisplay = true }
  1282. }
  1283. override func draw(_ dirtyRect: NSRect) {
  1284. super.draw(dirtyRect)
  1285. guard string.isEmpty, !placeholderString.isEmpty else { return }
  1286. let attributes: [NSAttributedString.Key: Any] = [
  1287. .font: font ?? AppTheme.regularFont(size: 20),
  1288. .foregroundColor: placeholderColor,
  1289. ]
  1290. let rect = firstRect(forCharacterRange: NSRange(location: 0, length: 0), actualRange: nil)
  1291. (placeholderString as NSString).draw(with: rect, options: .usesLineFragmentOrigin, attributes: attributes)
  1292. }
  1293. override func becomeFirstResponder() -> Bool {
  1294. let becameFirstResponder = super.becomeFirstResponder()
  1295. if becameFirstResponder { needsDisplay = true }
  1296. return becameFirstResponder
  1297. }
  1298. override func resignFirstResponder() -> Bool {
  1299. let resignedFirstResponder = super.resignFirstResponder()
  1300. if resignedFirstResponder { needsDisplay = true }
  1301. return resignedFirstResponder
  1302. }
  1303. }
  1304. struct AddTextResult {
  1305. let text: String
  1306. let fontSize: CGFloat
  1307. let fontStyle: CanvasTextFontStyle
  1308. }
  1309. private final class AddTextOptionPicker: NSControl, AppearanceRefreshable {
  1310. var onChange: ((String) -> Void)?
  1311. private let titleLabel = NSTextField()
  1312. private let chevronView = NSImageView()
  1313. private var hoverTracker: HoverTracker?
  1314. private var isHovered = false
  1315. private let optionMenu = NSMenu()
  1316. private var options: [String] = []
  1317. private var selectedOption = ""
  1318. init(options: [String], selection: String) {
  1319. super.init(frame: .zero)
  1320. translatesAutoresizingMaskIntoConstraints = false
  1321. wantsLayer = true
  1322. layer?.cornerRadius = 16
  1323. layer?.borderWidth = 1.5
  1324. self.options = options
  1325. selectedOption = selection
  1326. titleLabel.stringValue = selection
  1327. titleLabel.font = AppTheme.mediumFont(size: 16)
  1328. titleLabel.isBordered = false
  1329. titleLabel.isEditable = false
  1330. titleLabel.drawsBackground = false
  1331. titleLabel.lineBreakMode = .byTruncatingTail
  1332. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  1333. if let image = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: "Options") {
  1334. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  1335. chevronView.image = image.withSymbolConfiguration(config)
  1336. }
  1337. chevronView.translatesAutoresizingMaskIntoConstraints = false
  1338. addSubview(titleLabel)
  1339. addSubview(chevronView)
  1340. NSLayoutConstraint.activate([
  1341. heightAnchor.constraint(equalToConstant: 52),
  1342. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
  1343. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  1344. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevronView.leadingAnchor, constant: -10),
  1345. chevronView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
  1346. chevronView.centerYAnchor.constraint(equalTo: centerYAnchor),
  1347. chevronView.widthAnchor.constraint(equalToConstant: 16),
  1348. chevronView.heightAnchor.constraint(equalToConstant: 16),
  1349. ])
  1350. rebuildMenu()
  1351. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  1352. self?.setHovered(hovering)
  1353. }
  1354. refreshAppearance()
  1355. }
  1356. @available(*, unavailable)
  1357. required init?(coder: NSCoder) { nil }
  1358. func setSelection(_ value: String) {
  1359. guard options.contains(value) else { return }
  1360. selectedOption = value
  1361. titleLabel.stringValue = value
  1362. }
  1363. func refreshAppearance() {
  1364. layer?.backgroundColor = isHovered
  1365. ? AppTheme.elevatedBackground.cgColor
  1366. : AppTheme.cardBackground.cgColor
  1367. layer?.borderColor = AppTheme.paywallBorder.cgColor
  1368. titleLabel.textColor = AppTheme.textPrimary
  1369. chevronView.contentTintColor = AppTheme.textPrimary
  1370. }
  1371. private func setHovered(_ hovering: Bool) {
  1372. isHovered = hovering
  1373. animateHover {
  1374. layer?.backgroundColor = hovering
  1375. ? AppTheme.elevatedBackground.cgColor
  1376. : AppTheme.cardBackground.cgColor
  1377. }
  1378. }
  1379. private func rebuildMenu() {
  1380. optionMenu.removeAllItems()
  1381. for option in options {
  1382. let item = NSMenuItem(title: option, action: #selector(menuItemSelected(_:)), keyEquivalent: "")
  1383. item.target = self
  1384. item.state = option == selectedOption ? .on : .off
  1385. optionMenu.addItem(item)
  1386. }
  1387. }
  1388. @objc private func menuItemSelected(_ sender: NSMenuItem) {
  1389. let value = sender.title
  1390. guard options.contains(value) else { return }
  1391. selectedOption = value
  1392. titleLabel.stringValue = value
  1393. rebuildMenu()
  1394. onChange?(value)
  1395. }
  1396. override func mouseDown(with event: NSEvent) {
  1397. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1398. rebuildMenu()
  1399. optionMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: bounds.height + 4), in: self)
  1400. }
  1401. override func resetCursorRects() {
  1402. addCursorRect(bounds, cursor: .pointingHand)
  1403. }
  1404. }
  1405. private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextViewDelegate {
  1406. private static let defaultFontSize: CGFloat = 18
  1407. private static let fontSizeOptions: [CGFloat] = [12, 14, 16, 18, 20, 22, 24, 28, 32]
  1408. private static let inputTextInset = NSSize(width: 16, height: 14)
  1409. private static let inputLineFragmentPadding: CGFloat = 5
  1410. var onAdd: ((AddTextResult) -> Void)?
  1411. var onCancel: (() -> Void)?
  1412. private var selectedFontSize = defaultFontSize
  1413. private var selectedFontStyle: CanvasTextFontStyle = .regular
  1414. private let dimView = NSView()
  1415. private let cardView = NSView()
  1416. private let titleLabel = NSTextField(labelWithString: "Add Text")
  1417. private let optionsStack = NSStackView()
  1418. private let fontStylePicker: AddTextOptionPicker
  1419. private let fontSizePicker: AddTextOptionPicker
  1420. private let inputScrollView = NSScrollView()
  1421. private let inputTextView = PlaceholderTextView()
  1422. private let buttonStack = NSStackView()
  1423. private let addButton = DrawPrintPrintButton(title: "Add", symbolName: "plus", compact: true, firesOnMouseDown: true)
  1424. private let cancelButton = DrawPrintSecondaryButton(title: "Cancel", symbolName: "xmark", compact: true, firesOnMouseDown: true)
  1425. override init(frame frameRect: NSRect) {
  1426. fontStylePicker = AddTextOptionPicker(
  1427. options: CanvasTextFontStyle.allCases.map(\.rawValue),
  1428. selection: CanvasTextFontStyle.regular.rawValue
  1429. )
  1430. fontSizePicker = AddTextOptionPicker(
  1431. options: Self.fontSizeOptions.map { "\(Int($0))" },
  1432. selection: "\(Int(Self.defaultFontSize))"
  1433. )
  1434. super.init(frame: frameRect)
  1435. translatesAutoresizingMaskIntoConstraints = false
  1436. setup()
  1437. refreshAppearance()
  1438. }
  1439. @available(*, unavailable)
  1440. required init?(coder: NSCoder) { nil }
  1441. func refreshAppearance() {
  1442. dimView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor
  1443. cardView.layer?.backgroundColor = AppTheme.cardBackground.cgColor
  1444. cardView.layer?.borderColor = AppTheme.paywallBorder.cgColor
  1445. titleLabel.textColor = AppTheme.textPrimary
  1446. inputTextView.backgroundColor = .clear
  1447. inputTextView.textColor = AppTheme.textPrimary
  1448. inputTextView.placeholderColor = AppTheme.textSecondary
  1449. addButton.refreshAppearance()
  1450. cancelButton.refreshAppearance()
  1451. fontStylePicker.refreshAppearance()
  1452. fontSizePicker.refreshAppearance()
  1453. }
  1454. func prepareForPresentation() {
  1455. inputTextView.string = ""
  1456. selectedFontSize = Self.defaultFontSize
  1457. selectedFontStyle = .regular
  1458. fontStylePicker.setSelection(CanvasTextFontStyle.regular.rawValue)
  1459. fontSizePicker.setSelection("\(Int(Self.defaultFontSize))")
  1460. applyInputFont()
  1461. inputTextView.needsDisplay = true
  1462. }
  1463. func focusInput() {
  1464. window?.makeFirstResponder(inputTextView)
  1465. }
  1466. private func setup() {
  1467. wantsLayer = true
  1468. dimView.wantsLayer = true
  1469. dimView.translatesAutoresizingMaskIntoConstraints = false
  1470. cardView.wantsLayer = true
  1471. cardView.layer?.cornerRadius = 24
  1472. cardView.layer?.borderWidth = 1
  1473. cardView.applyCardShadow()
  1474. cardView.translatesAutoresizingMaskIntoConstraints = false
  1475. titleLabel.font = AppTheme.semiboldFont(size: 22)
  1476. titleLabel.alignment = .center
  1477. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  1478. optionsStack.orientation = .horizontal
  1479. optionsStack.spacing = 12
  1480. optionsStack.distribution = .fillEqually
  1481. optionsStack.translatesAutoresizingMaskIntoConstraints = false
  1482. fontStylePicker.onChange = { [weak self] value in
  1483. self?.fontStyleChanged(value)
  1484. }
  1485. fontSizePicker.onChange = { [weak self] value in
  1486. self?.fontSizeChanged(value)
  1487. }
  1488. inputScrollView.translatesAutoresizingMaskIntoConstraints = false
  1489. inputScrollView.drawsBackground = false
  1490. inputScrollView.hasVerticalScroller = true
  1491. inputScrollView.autohidesScrollers = true
  1492. inputScrollView.borderType = .noBorder
  1493. inputTextView.isRichText = false
  1494. inputTextView.font = selectedFontStyle.font(size: selectedFontSize)
  1495. inputTextView.textContainerInset = Self.inputTextInset
  1496. inputTextView.textContainer?.lineFragmentPadding = Self.inputLineFragmentPadding
  1497. inputTextView.placeholderString = "Enter any text here..."
  1498. inputTextView.isAutomaticQuoteSubstitutionEnabled = true
  1499. inputTextView.isAutomaticDashSubstitutionEnabled = true
  1500. inputTextView.isAutomaticTextReplacementEnabled = true
  1501. inputTextView.delegate = self
  1502. inputScrollView.documentView = inputTextView
  1503. buttonStack.orientation = .horizontal
  1504. buttonStack.spacing = 12
  1505. buttonStack.distribution = .fillEqually
  1506. buttonStack.translatesAutoresizingMaskIntoConstraints = false
  1507. addButton.onClick = { [weak self] in
  1508. self?.handleAdd()
  1509. }
  1510. cancelButton.onClick = { [weak self] in
  1511. self?.handleCancel()
  1512. }
  1513. addSubview(dimView)
  1514. addSubview(cardView)
  1515. cardView.addSubview(titleLabel)
  1516. cardView.addSubview(optionsStack)
  1517. optionsStack.addArrangedSubview(fontStylePicker)
  1518. optionsStack.addArrangedSubview(fontSizePicker)
  1519. cardView.addSubview(inputScrollView)
  1520. cardView.addSubview(buttonStack)
  1521. buttonStack.addArrangedSubview(addButton)
  1522. buttonStack.addArrangedSubview(cancelButton)
  1523. NSLayoutConstraint.activate([
  1524. dimView.leadingAnchor.constraint(equalTo: leadingAnchor),
  1525. dimView.trailingAnchor.constraint(equalTo: trailingAnchor),
  1526. dimView.topAnchor.constraint(equalTo: topAnchor),
  1527. dimView.bottomAnchor.constraint(equalTo: bottomAnchor),
  1528. cardView.centerXAnchor.constraint(equalTo: centerXAnchor),
  1529. cardView.centerYAnchor.constraint(equalTo: centerYAnchor),
  1530. cardView.widthAnchor.constraint(lessThanOrEqualToConstant: 560),
  1531. cardView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.62),
  1532. cardView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 40),
  1533. cardView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -40),
  1534. cardView.heightAnchor.constraint(equalToConstant: 320),
  1535. titleLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 18),
  1536. titleLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
  1537. titleLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
  1538. optionsStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
  1539. optionsStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
  1540. optionsStack.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12),
  1541. inputScrollView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
  1542. inputScrollView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
  1543. inputScrollView.topAnchor.constraint(equalTo: optionsStack.bottomAnchor, constant: 12),
  1544. inputScrollView.bottomAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -16),
  1545. buttonStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
  1546. buttonStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
  1547. buttonStack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -20),
  1548. buttonStack.heightAnchor.constraint(equalToConstant: 44),
  1549. ])
  1550. }
  1551. private func handleAdd() {
  1552. let raw = inputTextView.textStorage?.string ?? inputTextView.string
  1553. let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  1554. guard !text.isEmpty else { return }
  1555. onAdd?(AddTextResult(text: text, fontSize: selectedFontSize, fontStyle: selectedFontStyle))
  1556. }
  1557. private func fontStyleChanged(_ title: String) {
  1558. guard let style = CanvasTextFontStyle(rawValue: title) else { return }
  1559. selectedFontStyle = style
  1560. applyInputFont()
  1561. }
  1562. private func fontSizeChanged(_ title: String) {
  1563. guard let size = Double(title) else { return }
  1564. selectedFontSize = CGFloat(size)
  1565. applyInputFont()
  1566. }
  1567. private func applyInputFont() {
  1568. inputTextView.font = selectedFontStyle.font(size: selectedFontSize)
  1569. inputTextView.needsDisplay = true
  1570. }
  1571. @objc private func handleCancel() {
  1572. onCancel?()
  1573. }
  1574. func textDidChange(_ notification: Notification) {
  1575. inputTextView.needsDisplay = true
  1576. }
  1577. }
  1578. // MARK: - Controls
  1579. private final class DrawPrintToolbarButton: NSControl, AppearanceRefreshable {
  1580. var onClick: (() -> Void)?
  1581. private let symbolName: String
  1582. private let iconView = NSImageView()
  1583. init(symbolName: String, accessibilityLabel: String) {
  1584. self.symbolName = symbolName
  1585. super.init(frame: .zero)
  1586. toolTip = accessibilityLabel
  1587. wantsLayer = true
  1588. layer?.cornerRadius = 12
  1589. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  1590. let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
  1591. iconView.image = image.withSymbolConfiguration(config)
  1592. }
  1593. iconView.translatesAutoresizingMaskIntoConstraints = false
  1594. addSubview(iconView)
  1595. NSLayoutConstraint.activate([
  1596. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  1597. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  1598. ])
  1599. refreshAppearance()
  1600. }
  1601. @available(*, unavailable)
  1602. required init?(coder: NSCoder) { nil }
  1603. func refreshAppearance() {
  1604. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  1605. layer?.borderColor = AppTheme.paywallBorder.cgColor
  1606. layer?.borderWidth = 1.5
  1607. iconView.contentTintColor = AppTheme.textPrimary
  1608. }
  1609. override func mouseUp(with event: NSEvent) {
  1610. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1611. onClick?()
  1612. }
  1613. override func resetCursorRects() {
  1614. addCursorRect(bounds, cursor: .pointingHand)
  1615. }
  1616. }
  1617. private final class DrawPrintSecondaryButton: NSControl, AppearanceRefreshable {
  1618. var onClick: (() -> Void)?
  1619. private let title: String
  1620. private let titleLabel = NSTextField()
  1621. private let iconView = NSImageView()
  1622. private var hoverTracker: HoverTracker?
  1623. private var isHovered = false
  1624. private let firesOnMouseDown: Bool
  1625. init(title: String, symbolName: String, compact: Bool = false, firesOnMouseDown: Bool = false) {
  1626. self.title = title
  1627. self.firesOnMouseDown = firesOnMouseDown
  1628. super.init(frame: .zero)
  1629. translatesAutoresizingMaskIntoConstraints = false
  1630. wantsLayer = true
  1631. let height: CGFloat = compact ? 44 : 52
  1632. let cornerRadius: CGFloat = compact ? 18 : 22
  1633. let fontSize: CGFloat = compact ? 14 : 16
  1634. let horizontalPadding: CGFloat = compact ? 18 : 24
  1635. let minWidth: CGFloat = compact ? 0 : 180
  1636. let iconSize: CGFloat = compact ? 14 : 18
  1637. let symbolPointSize: CGFloat = compact ? 12 : 14
  1638. layer?.cornerRadius = cornerRadius
  1639. layer?.borderWidth = 1.5
  1640. titleLabel.stringValue = title
  1641. titleLabel.font = AppTheme.semiboldFont(size: fontSize)
  1642. titleLabel.isBordered = false
  1643. titleLabel.isEditable = false
  1644. titleLabel.drawsBackground = false
  1645. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  1646. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
  1647. let config = NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: .semibold)
  1648. iconView.image = image.withSymbolConfiguration(config)
  1649. }
  1650. iconView.translatesAutoresizingMaskIntoConstraints = false
  1651. addSubview(titleLabel)
  1652. addSubview(iconView)
  1653. var constraints = [
  1654. heightAnchor.constraint(equalToConstant: height),
  1655. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalPadding),
  1656. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  1657. iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 8),
  1658. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  1659. iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalPadding),
  1660. iconView.widthAnchor.constraint(equalToConstant: iconSize),
  1661. iconView.heightAnchor.constraint(equalToConstant: iconSize),
  1662. ]
  1663. if minWidth > 0 {
  1664. constraints.append(widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth))
  1665. }
  1666. NSLayoutConstraint.activate(constraints)
  1667. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  1668. self?.setHovered(hovering)
  1669. }
  1670. refreshAppearance()
  1671. }
  1672. @available(*, unavailable)
  1673. required init?(coder: NSCoder) { nil }
  1674. func refreshAppearance() {
  1675. layer?.backgroundColor = isHovered
  1676. ? AppTheme.elevatedBackground.cgColor
  1677. : AppTheme.cardBackground.cgColor
  1678. layer?.borderColor = AppTheme.paywallBorder.cgColor
  1679. titleLabel.textColor = AppTheme.textPrimary
  1680. iconView.contentTintColor = AppTheme.textPrimary
  1681. }
  1682. private func setHovered(_ hovering: Bool) {
  1683. isHovered = hovering
  1684. animateHover {
  1685. layer?.backgroundColor = hovering
  1686. ? AppTheme.elevatedBackground.cgColor
  1687. : AppTheme.cardBackground.cgColor
  1688. layer?.transform = hovering
  1689. ? CATransform3DMakeScale(1.03, 1.03, 1)
  1690. : CATransform3DIdentity
  1691. }
  1692. }
  1693. override func mouseDown(with event: NSEvent) {
  1694. guard firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1695. onClick?()
  1696. }
  1697. override func mouseUp(with event: NSEvent) {
  1698. guard !firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1699. onClick?()
  1700. }
  1701. override func resetCursorRects() {
  1702. addCursorRect(bounds, cursor: .pointingHand)
  1703. }
  1704. }
  1705. private final class DrawPrintPrintButton: NSControl, AppearanceRefreshable {
  1706. var onClick: (() -> Void)?
  1707. private let titleLabel: NSTextField
  1708. private let iconView = NSImageView()
  1709. private var hoverTracker: HoverTracker?
  1710. private var isHovered = false
  1711. private let firesOnMouseDown: Bool
  1712. init(
  1713. title: String = "Print Canvas",
  1714. symbolName: String = "printer.fill",
  1715. compact: Bool = false,
  1716. firesOnMouseDown: Bool = false
  1717. ) {
  1718. titleLabel = NSTextField(labelWithString: title)
  1719. self.firesOnMouseDown = firesOnMouseDown
  1720. super.init(frame: .zero)
  1721. translatesAutoresizingMaskIntoConstraints = false
  1722. wantsLayer = true
  1723. let height: CGFloat = compact ? 44 : 52
  1724. let cornerRadius: CGFloat = compact ? 18 : 22
  1725. let fontSize: CGFloat = compact ? 14 : 16
  1726. let horizontalPadding: CGFloat = compact ? 20 : 28
  1727. let iconSize: CGFloat = compact ? 16 : 20
  1728. let symbolPointSize: CGFloat = compact ? 12 : 14
  1729. layer?.cornerRadius = cornerRadius
  1730. titleLabel.font = AppTheme.semiboldFont(size: fontSize)
  1731. titleLabel.textColor = .white
  1732. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  1733. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
  1734. let config = NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: .semibold)
  1735. iconView.image = image.withSymbolConfiguration(config)
  1736. }
  1737. iconView.contentTintColor = .white
  1738. iconView.translatesAutoresizingMaskIntoConstraints = false
  1739. addSubview(titleLabel)
  1740. addSubview(iconView)
  1741. NSLayoutConstraint.activate([
  1742. heightAnchor.constraint(equalToConstant: height),
  1743. titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalPadding),
  1744. titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
  1745. iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
  1746. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  1747. iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalPadding),
  1748. iconView.widthAnchor.constraint(equalToConstant: iconSize),
  1749. iconView.heightAnchor.constraint(equalToConstant: iconSize),
  1750. ])
  1751. hoverTracker = HoverTracker(view: self) { [weak self] hovering in
  1752. self?.setHovered(hovering)
  1753. }
  1754. refreshAppearance()
  1755. }
  1756. @available(*, unavailable)
  1757. required init?(coder: NSCoder) { nil }
  1758. func refreshAppearance() {
  1759. let color = isHovered
  1760. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  1761. : AppTheme.blue
  1762. layer?.backgroundColor = color.cgColor
  1763. titleLabel.textColor = .white
  1764. iconView.contentTintColor = .white
  1765. }
  1766. private func setHovered(_ hovering: Bool) {
  1767. isHovered = hovering
  1768. animateHover {
  1769. let color = hovering
  1770. ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
  1771. : AppTheme.blue
  1772. layer?.backgroundColor = color.cgColor
  1773. layer?.transform = hovering
  1774. ? CATransform3DMakeScale(1.03, 1.03, 1)
  1775. : CATransform3DIdentity
  1776. }
  1777. }
  1778. override func mouseDown(with event: NSEvent) {
  1779. guard firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1780. onClick?()
  1781. }
  1782. override func mouseUp(with event: NSEvent) {
  1783. guard !firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1784. onClick?()
  1785. }
  1786. override func resetCursorRects() {
  1787. addCursorRect(bounds, cursor: .pointingHand)
  1788. }
  1789. }
  1790. private final class CanvasHistoryButton: NSControl, AppearanceRefreshable {
  1791. var onClick: (() -> Void)?
  1792. private var actionEnabled = true {
  1793. didSet { alphaValue = actionEnabled ? 1 : 0.35 }
  1794. }
  1795. private let iconView = NSImageView()
  1796. init(symbolName: String, accessibilityLabel: String) {
  1797. super.init(frame: .zero)
  1798. toolTip = accessibilityLabel
  1799. wantsLayer = true
  1800. layer?.cornerRadius = 16
  1801. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
  1802. let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  1803. iconView.image = image.withSymbolConfiguration(config)
  1804. }
  1805. iconView.translatesAutoresizingMaskIntoConstraints = false
  1806. addSubview(iconView)
  1807. NSLayoutConstraint.activate([
  1808. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  1809. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  1810. ])
  1811. refreshAppearance()
  1812. }
  1813. @available(*, unavailable)
  1814. required init?(coder: NSCoder) { nil }
  1815. func refreshAppearance() {
  1816. layer?.backgroundColor = NSColor(calibratedWhite: 0.88, alpha: 1).cgColor
  1817. iconView.contentTintColor = NSColor(calibratedWhite: 0.35, alpha: 1)
  1818. }
  1819. func setActionEnabled(_ enabled: Bool) {
  1820. actionEnabled = enabled
  1821. }
  1822. override func mouseDown(with event: NSEvent) {}
  1823. override func mouseUp(with event: NSEvent) {
  1824. guard actionEnabled, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1825. onClick?()
  1826. }
  1827. override func resetCursorRects() {
  1828. addCursorRect(bounds, cursor: .pointingHand)
  1829. }
  1830. }
  1831. private final class CanvasToolButton: NSControl, AppearanceRefreshable {
  1832. var onClick: (() -> Void)?
  1833. private let tool: CanvasTool
  1834. private let iconView = NSImageView()
  1835. private var selected = false
  1836. init(tool: CanvasTool) {
  1837. self.tool = tool
  1838. super.init(frame: .zero)
  1839. wantsLayer = true
  1840. layer?.cornerRadius = 12
  1841. let symbol: String
  1842. switch tool {
  1843. case .pointer: symbol = "arrow.up.left"
  1844. case .text: symbol = "character.cursor.ibeam"
  1845. case .image: symbol = "photo.on.rectangle.angled"
  1846. case .draw: symbol = "paintbrush.pointed.fill"
  1847. }
  1848. if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: toolLabel) {
  1849. let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .medium)
  1850. iconView.image = image.withSymbolConfiguration(config)
  1851. }
  1852. iconView.translatesAutoresizingMaskIntoConstraints = false
  1853. addSubview(iconView)
  1854. NSLayoutConstraint.activate([
  1855. widthAnchor.constraint(equalToConstant: 38),
  1856. heightAnchor.constraint(equalToConstant: 38),
  1857. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  1858. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  1859. ])
  1860. refreshAppearance()
  1861. }
  1862. @available(*, unavailable)
  1863. required init?(coder: NSCoder) { nil }
  1864. private var toolLabel: String {
  1865. switch tool {
  1866. case .pointer: return "Pointer"
  1867. case .text: return "Text"
  1868. case .image: return "Image"
  1869. case .draw: return "Draw"
  1870. }
  1871. }
  1872. func setSelected(_ selected: Bool) {
  1873. self.selected = selected
  1874. refreshAppearance()
  1875. }
  1876. func refreshAppearance() {
  1877. layer?.backgroundColor = selected
  1878. ? AppTheme.homeActiveBackground.cgColor
  1879. : NSColor.clear.cgColor
  1880. iconView.contentTintColor = selected ? AppTheme.homeActiveForeground : AppTheme.textPrimary
  1881. }
  1882. override func mouseUp(with event: NSEvent) {
  1883. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1884. onClick?()
  1885. }
  1886. override func resetCursorRects() {
  1887. addCursorRect(bounds, cursor: .pointingHand)
  1888. }
  1889. }
  1890. private final class CanvasSubToolButton: NSControl, AppearanceRefreshable {
  1891. var onClick: (() -> Void)?
  1892. private let iconView = NSImageView()
  1893. init(symbolName: String) {
  1894. super.init(frame: .zero)
  1895. wantsLayer = true
  1896. layer?.cornerRadius = 14
  1897. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) {
  1898. let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
  1899. iconView.image = image.withSymbolConfiguration(config)
  1900. }
  1901. iconView.translatesAutoresizingMaskIntoConstraints = false
  1902. addSubview(iconView)
  1903. NSLayoutConstraint.activate([
  1904. widthAnchor.constraint(equalToConstant: 28),
  1905. heightAnchor.constraint(equalToConstant: 28),
  1906. iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
  1907. iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
  1908. ])
  1909. refreshAppearance()
  1910. }
  1911. @available(*, unavailable)
  1912. required init?(coder: NSCoder) { nil }
  1913. func refreshAppearance() {
  1914. layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
  1915. layer?.borderColor = AppTheme.paywallBorder.cgColor
  1916. layer?.borderWidth = 1
  1917. iconView.contentTintColor = AppTheme.textPrimary
  1918. }
  1919. override func mouseUp(with event: NSEvent) {
  1920. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1921. onClick?()
  1922. }
  1923. override func resetCursorRects() {
  1924. addCursorRect(bounds, cursor: .pointingHand)
  1925. }
  1926. }
  1927. private final class CanvasColorButton: NSControl, AppearanceRefreshable {
  1928. var onClick: (() -> Void)?
  1929. private let swatchColor: NSColor
  1930. init(color: NSColor) {
  1931. self.swatchColor = color
  1932. super.init(frame: .zero)
  1933. wantsLayer = true
  1934. layer?.cornerRadius = 12
  1935. layer?.borderWidth = 2
  1936. layer?.borderColor = AppTheme.paywallBorder.cgColor
  1937. NSLayoutConstraint.activate([
  1938. widthAnchor.constraint(equalToConstant: 24),
  1939. heightAnchor.constraint(equalToConstant: 24),
  1940. ])
  1941. refreshAppearance()
  1942. }
  1943. @available(*, unavailable)
  1944. required init?(coder: NSCoder) { nil }
  1945. func refreshAppearance() {
  1946. layer?.backgroundColor = swatchColor.cgColor
  1947. }
  1948. override func mouseUp(with event: NSEvent) {
  1949. guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
  1950. onClick?()
  1951. }
  1952. override func resetCursorRects() {
  1953. addCursorRect(bounds, cursor: .pointingHand)
  1954. }
  1955. }