DrawPrintView.swift 82 KB

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