| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258 |
- import Cocoa
- import UniformTypeIdentifiers
- // MARK: - Service
- enum DrawPrintService {
- static func present(from window: NSWindow?) {
- let hostWindow = window ?? NSApp.keyWindow
- guard let viewController = hostWindow?.contentViewController as? ViewController else { return }
- viewController.presentDrawPrint()
- }
- }
- // MARK: - Canvas Model
- enum CanvasTool {
- case pointer
- case text
- case image
- case draw
- static let toolbarTools: [CanvasTool] = [.text, .image, .draw]
- }
- struct CanvasStroke {
- var points: [NSPoint]
- var color: NSColor
- var lineWidth: CGFloat
- }
- enum CanvasTextFontStyle: String, CaseIterable {
- case regular = "Regular"
- case bold = "Bold"
- case italic = "Italic"
- case boldItalic = "Bold Italic"
- case serif = "Serif"
- case monospace = "Monospace"
- func font(size: CGFloat) -> NSFont {
- switch self {
- case .regular:
- return AppTheme.regularFont(size: size)
- case .bold:
- return .systemFont(ofSize: size, weight: .bold)
- case .italic:
- let base = AppTheme.regularFont(size: size)
- return NSFontManager.shared.convert(base, toHaveTrait: .italicFontMask) ?? base
- case .boldItalic:
- let bold = NSFont.systemFont(ofSize: size, weight: .bold)
- return NSFontManager.shared.convert(bold, toHaveTrait: .italicFontMask) ?? bold
- case .serif:
- return NSFont(name: "Times New Roman", size: size)
- ?? NSFont(name: "Times-Roman", size: size)
- ?? .systemFont(ofSize: size)
- case .monospace:
- return .monospacedSystemFont(ofSize: size, weight: .regular)
- }
- }
- }
- struct CanvasTextData {
- var id: UUID
- var text: String
- var origin: NSPoint
- var fontSize: CGFloat
- var fontStyle: CanvasTextFontStyle
- var color: NSColor
- var alignment: NSTextAlignment
- }
- struct CanvasImageData {
- var id: UUID
- var origin: NSPoint
- var size: NSSize
- var image: NSImage
- }
- struct CanvasSnapshot {
- var strokes: [CanvasStroke]
- var texts: [CanvasTextData]
- var images: [CanvasImageData]
- }
- // MARK: - Overlay
- final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
- private static let toolsPanelWidth: CGFloat = 48
- private static let toolsPanelGap: CGFloat = 12
- var onDismiss: (() -> Void)?
- override var mouseDownCanMoveWindow: Bool { false }
- private let backButton = DrawPrintToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back")
- private let titleLabel = NSTextField(labelWithString: "Blank Canvas")
- private let canvasContainer = NSView()
- private let canvasView = BlankCanvasView()
- private let addTextPromptView = AddTextPromptView()
- private var pendingTextPlacement: NSPoint?
- private var suppressCanvasTextPrompt = false
- private let toolsPanel = CanvasToolsPanel()
- private let bottomButtonStack = NSStackView()
- private let savePDFButton = DrawPrintSecondaryButton(title: "Save PDF", symbolName: "square.and.arrow.down")
- private let printButton = DrawPrintPrintButton()
- init() {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- setup()
- refreshAppearance()
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(appearanceDidChange),
- name: .appearanceDidChange,
- object: nil
- )
- }
- deinit {
- NotificationCenter.default.removeObserver(self)
- }
- @objc private func appearanceDidChange() {
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.background.cgColor
- titleLabel.textColor = AppTheme.textPrimary
- canvasContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
- canvasContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
- backButton.refreshAppearance()
- savePDFButton.refreshAppearance()
- printButton.refreshAppearance()
- canvasView.refreshAppearance()
- toolsPanel.refreshAppearance()
- addTextPromptView.refreshAppearance()
- }
- func present(in parent: NSView) {
- guard superview == nil else { return }
- parent.addSubview(self)
- NSLayoutConstraint.activate([
- leadingAnchor.constraint(equalTo: parent.leadingAnchor),
- trailingAnchor.constraint(equalTo: parent.trailingAnchor),
- topAnchor.constraint(equalTo: parent.topAnchor),
- bottomAnchor.constraint(equalTo: parent.bottomAnchor),
- ])
- alphaValue = 0
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.2
- animator().alphaValue = 1
- }
- }
- func dismiss(animated: Bool = true) {
- let remove = { [weak self] in
- self?.removeFromSuperview()
- self?.onDismiss?()
- }
- guard animated else {
- remove()
- return
- }
- NSAnimationContext.runAnimationGroup({ context in
- context.duration = 0.15
- animator().alphaValue = 0
- }, completionHandler: remove)
- }
- private func setup() {
- wantsLayer = true
- titleLabel.font = AppTheme.semiboldFont(size: 18)
- titleLabel.alignment = .center
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- canvasContainer.wantsLayer = true
- canvasContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius
- canvasContainer.layer?.borderWidth = 1.5
- canvasContainer.applyCardShadow()
- canvasContainer.translatesAutoresizingMaskIntoConstraints = false
- canvasView.translatesAutoresizingMaskIntoConstraints = false
- toolsPanel.translatesAutoresizingMaskIntoConstraints = false
- backButton.translatesAutoresizingMaskIntoConstraints = false
- bottomButtonStack.translatesAutoresizingMaskIntoConstraints = false
- backButton.onClick = { [weak self] in self?.dismiss() }
- savePDFButton.onClick = { [weak self] in self?.saveCanvasPDF() }
- printButton.onClick = { [weak self] in self?.printCanvas() }
- toolsPanel.onToolChanged = { [weak self] tool in
- self?.canvasView.activeTool = tool
- }
- toolsPanel.onAddText = { [weak self] in
- self?.pendingTextPlacement = nil
- self?.presentAddTextDialog()
- }
- toolsPanel.onTextAlignment = { [weak self] alignment in
- self?.canvasView.setSelectedTextAlignment(alignment)
- }
- toolsPanel.onTextFontSize = { [weak self] size in
- self?.canvasView.setSelectedTextFontSize(size)
- }
- toolsPanel.onTextColor = { [weak self] color in
- self?.canvasView.setSelectedTextColor(color)
- }
- toolsPanel.onDrawColor = { [weak self] color in
- self?.canvasView.drawColor = color
- }
- toolsPanel.onDrawLineWidth = { [weak self] width in
- self?.canvasView.drawLineWidth = width
- }
- canvasView.onRequestAddImage = { [weak self] point in
- self?.pickImage(at: point)
- }
- canvasView.onRequestAddText = { [weak self] point in
- guard let self, !self.suppressCanvasTextPrompt else { return }
- self.pendingTextPlacement = point
- self.presentAddTextDialog()
- }
- bottomButtonStack.orientation = .horizontal
- bottomButtonStack.spacing = 12
- bottomButtonStack.addArrangedSubview(savePDFButton)
- bottomButtonStack.addArrangedSubview(printButton)
- canvasContainer.addSubview(canvasView)
- addSubview(backButton)
- addSubview(titleLabel)
- addSubview(canvasContainer)
- addSubview(toolsPanel)
- addSubview(bottomButtonStack)
- addSubview(addTextPromptView)
- NSLayoutConstraint.activate([
- backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
- backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20),
- backButton.widthAnchor.constraint(equalToConstant: 40),
- backButton.heightAnchor.constraint(equalToConstant: 40),
- titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
- titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor),
- canvasContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset),
- canvasContainer.trailingAnchor.constraint(
- equalTo: trailingAnchor,
- constant: -(AppTheme.contentPanelInset + Self.toolsPanelWidth + Self.toolsPanelGap)
- ),
- canvasContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
- canvasContainer.bottomAnchor.constraint(equalTo: bottomButtonStack.topAnchor, constant: -28),
- toolsPanel.leadingAnchor.constraint(equalTo: canvasContainer.trailingAnchor, constant: Self.toolsPanelGap),
- toolsPanel.centerYAnchor.constraint(equalTo: canvasContainer.centerYAnchor),
- toolsPanel.widthAnchor.constraint(equalToConstant: Self.toolsPanelWidth),
- canvasView.leadingAnchor.constraint(equalTo: canvasContainer.leadingAnchor),
- canvasView.trailingAnchor.constraint(equalTo: canvasContainer.trailingAnchor),
- canvasView.topAnchor.constraint(equalTo: canvasContainer.topAnchor),
- canvasView.bottomAnchor.constraint(equalTo: canvasContainer.bottomAnchor),
- bottomButtonStack.centerXAnchor.constraint(equalTo: centerXAnchor),
- bottomButtonStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
- addTextPromptView.leadingAnchor.constraint(equalTo: leadingAnchor),
- addTextPromptView.trailingAnchor.constraint(equalTo: trailingAnchor),
- addTextPromptView.topAnchor.constraint(equalTo: topAnchor),
- addTextPromptView.bottomAnchor.constraint(equalTo: bottomAnchor),
- ])
- addTextPromptView.isHidden = true
- addTextPromptView.alphaValue = 0
- addTextPromptView.onAdd = { [weak self] result in
- self?.commitAddedText(result)
- }
- addTextPromptView.onCancel = { [weak self] in
- self?.pendingTextPlacement = nil
- self?.hideAddTextPrompt()
- }
- }
- private func pickImage(at placement: NSPoint? = nil) {
- let panel = NSOpenPanel()
- panel.title = "Add Image"
- panel.message = "Select an image to place on the canvas."
- panel.prompt = "Add"
- panel.canChooseDirectories = false
- panel.canChooseFiles = true
- panel.allowsMultipleSelection = false
- panel.allowedContentTypes = [.image, .png, .jpeg, .gif, .tiff, .bmp, .heic]
- guard panel.runModal() == .OK, let url = panel.url,
- let image = NSImage(contentsOf: url) else { return }
- canvasView.addImage(image, at: placement)
- toolsPanel.setActiveTool(.pointer)
- }
- private func commitAddedText(_ result: AddTextResult) {
- let placement = pendingTextPlacement
- pendingTextPlacement = nil
- suppressCanvasTextPrompt = true
- canvasView.stopInlineTextEditing()
- layoutSubtreeIfNeeded()
- canvasContainer.layoutSubtreeIfNeeded()
- canvasView.layoutSubtreeIfNeeded()
- canvasView.addTextItem(
- text: result.text,
- fontSize: result.fontSize,
- fontStyle: result.fontStyle,
- at: placement
- )
- toolsPanel.setActiveTool(.pointer)
- DispatchQueue.main.async { [weak self] in
- self?.hideAddTextPrompt(animated: false)
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
- self?.suppressCanvasTextPrompt = false
- }
- }
- }
- private func presentAddTextDialog() {
- addSubview(addTextPromptView, positioned: .above, relativeTo: nil)
- addTextPromptView.prepareForPresentation()
- addTextPromptView.isHidden = false
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.18
- addTextPromptView.animator().alphaValue = 1
- } completionHandler: { [weak self] in
- self?.addTextPromptView.focusInput()
- }
- }
- private func hideAddTextPrompt(animated: Bool = true) {
- let hide = { [weak self] in
- self?.addTextPromptView.alphaValue = 0
- self?.addTextPromptView.isHidden = true
- }
- guard animated else {
- hide()
- return
- }
- NSAnimationContext.runAnimationGroup({ context in
- context.duration = 0.14
- addTextPromptView.animator().alphaValue = 0
- }, completionHandler: hide)
- }
- private func canvasSnapshot() -> NSImage? {
- guard canvasView.hasContent else {
- PrintService.showEmptyCanvasAlert()
- return nil
- }
- return canvasView.renderSnapshot()
- }
- private func printCanvas() {
- guard let image = canvasSnapshot() else { return }
- PrintService.printCanvas(image, from: window)
- }
- private func saveCanvasPDF() {
- guard let image = canvasSnapshot() else { return }
- PrintService.saveCanvasPDF(image)
- }
- }
- // MARK: - Canvas View
- private enum CanvasResizeHandle {
- case topLeft
- case topRight
- case bottomLeft
- case bottomRight
- }
- private enum CanvasResizeTarget {
- case text(UUID)
- case image(UUID)
- }
- final class BlankCanvasView: NSView, AppearanceRefreshable {
- private static let resizeHandleSize: CGFloat = 8
- private static let resizeHandleHitPadding: CGFloat = 6
- private static let minImageDimension: CGFloat = 24
- private static let minTextFontSize: CGFloat = 8
- private static let maxTextFontSize: CGFloat = 120
- override var isOpaque: Bool { true }
- override var mouseDownCanMoveWindow: Bool { false }
- var activeTool: CanvasTool = .draw {
- didSet {
- if oldValue == .text, activeTool != .text {
- endEditingText()
- }
- updateCursorForActiveTool()
- needsDisplay = true
- }
- }
- var drawColor: NSColor = NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1)
- var drawLineWidth: CGFloat = 2
- var onUndoStateChanged: ((_ canUndo: Bool, _ canRedo: Bool) -> Void)?
- var onRequestAddImage: ((NSPoint) -> Void)?
- var onRequestAddText: ((NSPoint) -> Void)?
- var hasContent: Bool {
- syncTextFromFields()
- let hasText = textItems.contains { !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
- return !strokes.isEmpty || !imageItems.isEmpty || hasText
- }
- private var strokes: [CanvasStroke] = []
- private var textItems: [CanvasTextData] = []
- private var imageItems: [CanvasImageData] = []
- private var undoStack: [CanvasSnapshot] = []
- private var redoStack: [CanvasSnapshot] = []
- private var currentStroke: CanvasStroke?
- private var selectedTextID: UUID?
- private var editingTextID: UUID?
- private var selectedImageID: UUID?
- private var draggingTextID: UUID?
- private var draggingImageID: UUID?
- private var dragStartPoint = NSPoint.zero
- private var dragStartOrigin = NSPoint.zero
- private var textDragSnapshotPushed = false
- private var imageDragSnapshotPushed = false
- private var resizingTextID: UUID?
- private var resizingImageID: UUID?
- private var activeResizeHandle: CanvasResizeHandle?
- private var resizeStartPoint = NSPoint.zero
- private var resizeStartRect = NSRect.zero
- private var resizeStartFontSize: CGFloat = 18
- private var resizeFixedCorner = NSPoint.zero
- private let undoButton = CanvasHistoryButton(symbolName: "arrow.uturn.backward", accessibilityLabel: "Undo")
- private let redoButton = CanvasHistoryButton(symbolName: "arrow.uturn.forward", accessibilityLabel: "Redo")
- init() {
- super.init(frame: .zero)
- setup()
- refreshAppearance()
- notifyUndoState()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- undoButton.refreshAppearance()
- redoButton.refreshAppearance()
- }
- func stopInlineTextEditing() {
- endEditingText()
- }
- private func setup() {
- wantsLayer = true
- layerContentsRedrawPolicy = .onSetNeedsDisplay
- layer?.cornerRadius = AppTheme.contentPanelCornerRadius
- layer?.masksToBounds = true
- undoButton.translatesAutoresizingMaskIntoConstraints = false
- redoButton.translatesAutoresizingMaskIntoConstraints = false
- undoButton.onClick = { [weak self] in self?.undo() }
- redoButton.onClick = { [weak self] in self?.redo() }
- addSubview(undoButton)
- addSubview(redoButton)
- NSLayoutConstraint.activate([
- undoButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
- undoButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12),
- undoButton.widthAnchor.constraint(equalToConstant: 32),
- undoButton.heightAnchor.constraint(equalToConstant: 32),
- redoButton.leadingAnchor.constraint(equalTo: undoButton.trailingAnchor, constant: 8),
- redoButton.centerYAnchor.constraint(equalTo: undoButton.centerYAnchor),
- redoButton.widthAnchor.constraint(equalToConstant: 32),
- redoButton.heightAnchor.constraint(equalToConstant: 32),
- ])
- updateCursorForActiveTool()
- }
- private func updateCursorForActiveTool() {
- window?.invalidateCursorRects(for: self)
- }
- override func resetCursorRects() {
- let cursor: NSCursor
- switch activeTool {
- case .pointer:
- cursor = .arrow
- case .draw:
- cursor = .crosshair
- case .text:
- cursor = .iBeam
- case .image:
- cursor = .pointingHand
- }
- addCursorRect(bounds, cursor: cursor)
- }
- override func hitTest(_ point: NSPoint) -> NSView? {
- for button in [redoButton, undoButton] {
- let localPoint = convert(point, to: button)
- if let hit = button.hitTest(localPoint) {
- return hit
- }
- }
- if resizeHandle(at: point) != nil {
- return self
- }
- if (activeTool == .text || activeTool == .pointer),
- editingTextID == nil, textIndex(at: point) != nil {
- return self
- }
- if (activeTool == .image || activeTool == .pointer), imageIndex(at: point) != nil {
- return self
- }
- return super.hitTest(point)
- }
- private func bringUndoButtonsToFront() {
- addSubview(redoButton, positioned: .above, relativeTo: nil)
- addSubview(undoButton, positioned: .below, relativeTo: redoButton)
- }
- private func updateUndoButtons(canUndo: Bool, canRedo: Bool) {
- undoButton.setActionEnabled(canUndo)
- redoButton.setActionEnabled(canRedo)
- }
- override func draw(_ dirtyRect: NSRect) {
- NSColor.white.setFill()
- NSBezierPath(
- roundedRect: bounds,
- xRadius: AppTheme.contentPanelCornerRadius,
- yRadius: AppTheme.contentPanelCornerRadius
- ).fill()
- let context = NSGraphicsContext.current?.cgContext
- for stroke in strokes {
- drawStroke(stroke, in: context)
- }
- if let currentStroke {
- drawStroke(currentStroke, in: context)
- }
- for item in textItems where item.id != editingTextID {
- drawTextItem(item)
- }
- for item in imageItems {
- let rect = NSRect(origin: item.origin, size: item.size)
- item.image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
- }
- if (activeTool == .text || activeTool == .pointer),
- let selectedID = selectedTextID,
- let item = textItems.first(where: { $0.id == selectedID }),
- item.id != editingTextID {
- drawTextSelection(for: item)
- }
- if (activeTool == .image || activeTool == .pointer),
- let selectedID = selectedImageID,
- let item = imageItems.first(where: { $0.id == selectedID }) {
- drawImageSelection(for: item)
- }
- }
- private func drawTextItem(_ item: CanvasTextData) {
- let rect = textBounds(for: item)
- let paragraph = NSMutableParagraphStyle()
- paragraph.alignment = item.alignment
- let attributes: [NSAttributedString.Key: Any] = [
- .font: item.fontStyle.font(size: item.fontSize),
- .foregroundColor: item.color,
- .paragraphStyle: paragraph,
- ]
- (item.text as NSString).draw(in: rect, withAttributes: attributes)
- }
- private func drawTextSelection(for item: CanvasTextData) {
- let rect = selectionRect(for: textBounds(for: item))
- drawSelectionChrome(in: rect)
- }
- private func drawImageSelection(for item: CanvasImageData) {
- let rect = selectionRect(for: NSRect(origin: item.origin, size: item.size))
- drawSelectionChrome(in: rect)
- }
- private func selectionRect(for contentRect: NSRect) -> NSRect {
- contentRect.insetBy(dx: -4, dy: -4)
- }
- private func drawSelectionChrome(in rect: NSRect) {
- NSColor.clear.setFill()
- let path = NSBezierPath(roundedRect: rect, xRadius: 4, yRadius: 4)
- path.setLineDash([4, 3], count: 2, phase: 0)
- path.lineWidth = 1.5
- NSColor(calibratedWhite: 0.55, alpha: 1).setStroke()
- path.stroke()
- drawResizeHandles(in: rect)
- }
- private func drawResizeHandles(in rect: NSRect) {
- for handleRect in resizeHandleRects(for: rect).values {
- NSColor.white.setFill()
- NSBezierPath(roundedRect: handleRect, xRadius: 2, yRadius: 2).fill()
- NSColor(calibratedWhite: 0.55, alpha: 1).setStroke()
- let border = NSBezierPath(roundedRect: handleRect, xRadius: 2, yRadius: 2)
- border.lineWidth = 1
- border.stroke()
- }
- }
- private func drawStroke(_ stroke: CanvasStroke, in context: CGContext?) {
- guard stroke.points.count > 1, let context else { return }
- context.saveGState()
- context.setStrokeColor(stroke.color.cgColor)
- context.setLineWidth(stroke.lineWidth)
- context.setLineCap(.round)
- context.setLineJoin(.round)
- context.beginPath()
- context.move(to: stroke.points[0])
- for point in stroke.points.dropFirst() {
- context.addLine(to: point)
- }
- context.strokePath()
- context.restoreGState()
- }
- override func mouseDown(with event: NSEvent) {
- let point = convert(event.locationInWindow, from: nil)
- if undoButton.frame.contains(point) || redoButton.frame.contains(point) {
- return
- }
- if let resize = resizeHandle(at: point) {
- beginResize(resize, at: point)
- return
- }
- switch activeTool {
- case .pointer:
- if let index = textIndex(at: point) {
- let id = textItems[index].id
- selectedTextID = id
- selectedImageID = nil
- if event.clickCount >= 2 {
- beginEditingText(id: id)
- } else {
- draggingTextID = id
- dragStartPoint = point
- dragStartOrigin = textItems[index].origin
- textDragSnapshotPushed = false
- }
- } else if let index = imageIndex(at: point) {
- selectedImageID = imageItems[index].id
- selectedTextID = nil
- draggingImageID = imageItems[index].id
- dragStartPoint = point
- dragStartOrigin = imageItems[index].origin
- imageDragSnapshotPushed = false
- } else {
- selectedTextID = nil
- selectedImageID = nil
- if editingTextID != nil {
- endEditingText()
- }
- needsDisplay = true
- }
- case .draw:
- pushSnapshot()
- currentStroke = CanvasStroke(points: [point], color: drawColor, lineWidth: drawLineWidth)
- needsDisplay = true
- case .text:
- if let index = textIndex(at: point) {
- let id = textItems[index].id
- selectedTextID = id
- if event.clickCount >= 2 {
- beginEditingText(id: id)
- } else {
- draggingTextID = id
- dragStartPoint = point
- dragStartOrigin = textItems[index].origin
- textDragSnapshotPushed = false
- }
- } else if editingTextID != nil {
- endEditingText()
- } else {
- onRequestAddText?(point)
- }
- case .image:
- if let index = imageIndex(at: point) {
- selectedImageID = imageItems[index].id
- draggingImageID = imageItems[index].id
- dragStartPoint = point
- dragStartOrigin = imageItems[index].origin
- imageDragSnapshotPushed = false
- } else {
- onRequestAddImage?(point)
- }
- }
- }
- override func mouseDragged(with event: NSEvent) {
- let point = convert(event.locationInWindow, from: nil)
- if let handle = activeResizeHandle {
- if let id = resizingTextID,
- let index = textItems.firstIndex(where: { $0.id == id }) {
- applyTextResize(at: point, handle: handle, index: index)
- needsDisplay = true
- return
- }
- if let id = resizingImageID,
- let index = imageItems.firstIndex(where: { $0.id == id }) {
- applyImageResize(at: point, handle: handle, index: index)
- needsDisplay = true
- return
- }
- }
- if (activeTool == .text || activeTool == .pointer),
- let id = draggingTextID,
- let index = textItems.firstIndex(where: { $0.id == id }) {
- if !textDragSnapshotPushed {
- pushSnapshot()
- textDragSnapshotPushed = true
- }
- let dx = point.x - dragStartPoint.x
- let dy = point.y - dragStartPoint.y
- var origin = NSPoint(x: dragStartOrigin.x + dx, y: dragStartOrigin.y + dy)
- let size = textBounds(for: textItems[index]).size
- origin.x = max(0, min(origin.x, bounds.width - size.width))
- origin.y = max(0, min(origin.y, bounds.height - size.height))
- textItems[index].origin = origin
- needsDisplay = true
- return
- }
- if (activeTool == .image || activeTool == .pointer),
- let id = draggingImageID,
- let index = imageItems.firstIndex(where: { $0.id == id }) {
- if !imageDragSnapshotPushed {
- pushSnapshot()
- imageDragSnapshotPushed = true
- }
- let dx = point.x - dragStartPoint.x
- let dy = point.y - dragStartPoint.y
- var origin = NSPoint(x: dragStartOrigin.x + dx, y: dragStartOrigin.y + dy)
- let size = imageItems[index].size
- origin.x = max(0, min(origin.x, bounds.width - size.width))
- origin.y = max(0, min(origin.y, bounds.height - size.height))
- imageItems[index].origin = origin
- needsDisplay = true
- return
- }
- guard activeTool == .draw, var stroke = currentStroke else { return }
- stroke.points.append(point)
- currentStroke = stroke
- needsDisplay = true
- }
- override func mouseUp(with event: NSEvent) {
- if resizingTextID != nil || resizingImageID != nil {
- resizingTextID = nil
- resizingImageID = nil
- activeResizeHandle = nil
- notifyUndoState()
- return
- }
- if (activeTool == .text || activeTool == .pointer), draggingTextID != nil {
- draggingTextID = nil
- textDragSnapshotPushed = false
- notifyUndoState()
- return
- }
- if (activeTool == .image || activeTool == .pointer), draggingImageID != nil {
- draggingImageID = nil
- imageDragSnapshotPushed = false
- notifyUndoState()
- return
- }
- guard activeTool == .draw, let stroke = currentStroke, stroke.points.count > 1 else {
- currentStroke = nil
- return
- }
- strokes.append(stroke)
- currentStroke = nil
- needsDisplay = true
- notifyUndoState()
- }
- func addTextItem(
- text: String = "Text",
- fontSize: CGFloat = 18,
- fontStyle: CanvasTextFontStyle = .regular,
- at origin: NSPoint? = nil
- ) {
- guard bounds.width > 1, bounds.height > 1 else {
- DispatchQueue.main.async { [weak self] in
- self?.addTextItem(text: text, fontSize: fontSize, fontStyle: fontStyle, at: origin)
- }
- return
- }
- pushSnapshot()
- let defaultSize = textBounds(
- for: CanvasTextData(
- id: UUID(),
- text: text,
- origin: .zero,
- fontSize: fontSize,
- fontStyle: fontStyle,
- color: .black,
- alignment: .left
- )
- ).size
- let placement: NSPoint
- if let origin {
- placement = NSPoint(
- x: origin.x - defaultSize.width / 2,
- y: origin.y - defaultSize.height / 2
- )
- } else {
- placement = NSPoint(
- x: (bounds.width - defaultSize.width) / 2,
- y: (bounds.height - defaultSize.height) / 2
- )
- }
- var clampedOrigin = placement
- clampedOrigin.x = max(0, min(clampedOrigin.x, bounds.width - defaultSize.width))
- clampedOrigin.y = max(0, min(clampedOrigin.y, bounds.height - defaultSize.height))
- let item = CanvasTextData(
- id: UUID(),
- text: text,
- origin: clampedOrigin,
- fontSize: fontSize,
- fontStyle: fontStyle,
- color: .black,
- alignment: .left
- )
- textItems.append(item)
- selectedTextID = item.id
- setNeedsDisplay(bounds)
- displayIfNeeded()
- notifyUndoState()
- }
- func addImage(_ image: NSImage, at placement: NSPoint? = nil) {
- pushSnapshot()
- let maxSide: CGFloat = min(bounds.width, bounds.height) * 0.4
- let imageSize = image.size
- let scale = min(maxSide / max(imageSize.width, 1), maxSide / max(imageSize.height, 1))
- let size = NSSize(width: imageSize.width * scale, height: imageSize.height * scale)
- var origin: NSPoint
- if let placement {
- origin = NSPoint(x: placement.x - size.width / 2, y: placement.y - size.height / 2)
- origin.x = max(0, min(origin.x, bounds.width - size.width))
- origin.y = max(0, min(origin.y, bounds.height - size.height))
- } else {
- origin = NSPoint(
- x: (bounds.width - size.width) / 2,
- y: (bounds.height - size.height) / 2
- )
- }
- let item = CanvasImageData(id: UUID(), origin: origin, size: size, image: image)
- imageItems.append(item)
- selectedImageID = item.id
- needsDisplay = true
- notifyUndoState()
- }
- func setSelectedTextAlignment(_ alignment: NSTextAlignment) {
- guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return }
- pushSnapshot()
- textItems[index].alignment = alignment
- if let field = textField(for: id) {
- field.alignment = alignment
- }
- needsDisplay = true
- notifyUndoState()
- }
- func setSelectedTextFontSize(_ size: CGFloat) {
- guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return }
- pushSnapshot()
- textItems[index].fontSize = size
- if let field = textField(for: id) {
- field.font = textItems[index].fontStyle.font(size: size)
- field.frame = textBounds(for: textItems[index])
- }
- needsDisplay = true
- notifyUndoState()
- }
- func setSelectedTextColor(_ color: NSColor) {
- guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return }
- pushSnapshot()
- textItems[index].color = color
- if let field = textField(for: id) {
- field.textColor = color
- }
- needsDisplay = true
- notifyUndoState()
- }
- func renderSnapshot() -> NSImage? {
- syncTextFromFields()
- guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil }
- cacheDisplay(in: bounds, to: rep)
- let image = NSImage(size: bounds.size)
- image.addRepresentation(rep)
- return image
- }
- private func syncTextFromFields() {
- guard let id = editingTextID,
- let index = textItems.firstIndex(where: { $0.id == id }),
- let field = textField(for: id) else { return }
- textItems[index].text = field.stringValue
- textItems[index].origin = field.frame.origin
- }
- private func pushSnapshot() {
- syncTextFromFields()
- undoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems))
- redoStack.removeAll()
- if undoStack.count > 50 { undoStack.removeFirst() }
- notifyUndoState()
- }
- private func undo() {
- guard let snapshot = undoStack.popLast() else { return }
- syncTextFromFields()
- redoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems))
- restore(snapshot)
- }
- private func redo() {
- guard let snapshot = redoStack.popLast() else { return }
- syncTextFromFields()
- undoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems))
- restore(snapshot)
- }
- private func restore(_ snapshot: CanvasSnapshot) {
- strokes = snapshot.strokes
- textItems = snapshot.texts
- imageItems = snapshot.images
- endEditingText()
- selectedTextID = nil
- selectedImageID = nil
- draggingTextID = nil
- draggingImageID = nil
- resizingTextID = nil
- resizingImageID = nil
- activeResizeHandle = nil
- bringUndoButtonsToFront()
- needsDisplay = true
- notifyUndoState()
- }
- private func notifyUndoState() {
- let canUndo = !undoStack.isEmpty
- let canRedo = !redoStack.isEmpty
- onUndoStateChanged?(canUndo, canRedo)
- updateUndoButtons(canUndo: canUndo, canRedo: canRedo)
- }
- private func beginEditingText(id: UUID) {
- guard let index = textItems.firstIndex(where: { $0.id == id }) else { return }
- endEditingText()
- editingTextID = id
- selectedTextID = id
- let item = textItems[index]
- let field = NSTextField(string: item.text)
- field.identifier = NSUserInterfaceItemIdentifier(item.id.uuidString)
- field.isBordered = false
- field.isBezeled = false
- field.drawsBackground = false
- field.font = item.fontStyle.font(size: item.fontSize)
- field.textColor = item.color
- field.alignment = item.alignment
- field.frame = textBounds(for: item)
- field.target = self
- field.action = #selector(textFieldFinishedEditing(_:))
- addSubview(field)
- bringUndoButtonsToFront()
- window?.makeFirstResponder(field)
- needsDisplay = true
- }
- private func endEditingText() {
- syncTextFromFields()
- removeTextFields()
- editingTextID = nil
- needsDisplay = true
- }
- @objc private func textFieldFinishedEditing(_ sender: NSTextField) {
- guard let idString = sender.identifier?.rawValue,
- let id = UUID(uuidString: idString),
- let index = textItems.firstIndex(where: { $0.id == id }) else { return }
- textItems[index].text = sender.stringValue
- textItems[index].origin = sender.frame.origin
- selectedTextID = id
- endEditingText()
- }
- private func textField(for id: UUID) -> NSTextField? {
- subviews.compactMap { $0 as? NSTextField }.first {
- $0.identifier?.rawValue == id.uuidString
- }
- }
- private func removeTextFields() {
- subviews.compactMap { $0 as? NSTextField }.forEach { $0.removeFromSuperview() }
- }
- private func textBounds(for item: CanvasTextData) -> NSRect {
- let attributes: [NSAttributedString.Key: Any] = [
- .font: item.fontStyle.font(size: item.fontSize),
- ]
- let textSize = (item.text as NSString).size(withAttributes: attributes)
- let width = max(textSize.width + 8, 80)
- let height = max(textSize.height + 6, 28)
- return NSRect(x: item.origin.x, y: item.origin.y, width: width, height: height)
- }
- private func textIndex(at point: NSPoint) -> Int? {
- for index in textItems.indices.reversed() {
- if textBounds(for: textItems[index]).contains(point) {
- return index
- }
- }
- return nil
- }
- private func imageIndex(at point: NSPoint) -> Int? {
- for index in imageItems.indices.reversed() {
- let rect = NSRect(origin: imageItems[index].origin, size: imageItems[index].size)
- if rect.contains(point) {
- return index
- }
- }
- return nil
- }
- private func resizeHandleRects(for rect: NSRect) -> [CanvasResizeHandle: NSRect] {
- let half = Self.resizeHandleSize / 2
- return [
- .topLeft: NSRect(x: rect.minX - half, y: rect.maxY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
- .topRight: NSRect(x: rect.maxX - half, y: rect.maxY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
- .bottomLeft: NSRect(x: rect.minX - half, y: rect.minY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
- .bottomRight: NSRect(x: rect.maxX - half, y: rect.minY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
- ]
- }
- private func resizeHandle(at point: NSPoint) -> (CanvasResizeTarget, CanvasResizeHandle)? {
- if (activeTool == .image || activeTool == .pointer),
- let id = selectedImageID,
- let item = imageItems.first(where: { $0.id == id }) {
- let rect = selectionRect(for: NSRect(origin: item.origin, size: item.size))
- if let handle = hitResizeHandle(at: point, in: rect) {
- return (.image(id), handle)
- }
- }
- if (activeTool == .text || activeTool == .pointer),
- let id = selectedTextID,
- id != editingTextID,
- let item = textItems.first(where: { $0.id == id }) {
- let rect = selectionRect(for: textBounds(for: item))
- if let handle = hitResizeHandle(at: point, in: rect) {
- return (.text(id), handle)
- }
- }
- return nil
- }
- private func hitResizeHandle(at point: NSPoint, in rect: NSRect) -> CanvasResizeHandle? {
- for (handle, handleRect) in resizeHandleRects(for: rect) {
- let hitRect = handleRect.insetBy(dx: -Self.resizeHandleHitPadding, dy: -Self.resizeHandleHitPadding)
- if hitRect.contains(point) {
- return handle
- }
- }
- return nil
- }
- private func fixedCorner(for rect: NSRect, handle: CanvasResizeHandle) -> NSPoint {
- switch handle {
- case .topLeft:
- return NSPoint(x: rect.maxX, y: rect.minY)
- case .topRight:
- return NSPoint(x: rect.minX, y: rect.minY)
- case .bottomLeft:
- return NSPoint(x: rect.maxX, y: rect.maxY)
- case .bottomRight:
- return NSPoint(x: rect.minX, y: rect.maxY)
- }
- }
- private func beginResize(_ target: (CanvasResizeTarget, CanvasResizeHandle), at point: NSPoint) {
- let (resizeTarget, handle) = target
- pushSnapshot()
- activeResizeHandle = handle
- resizeStartPoint = point
- switch resizeTarget {
- case .text(let id):
- guard let item = textItems.first(where: { $0.id == id }) else { return }
- resizingTextID = id
- resizeStartRect = textBounds(for: item)
- resizeStartFontSize = item.fontSize
- resizeFixedCorner = fixedCorner(for: resizeStartRect, handle: handle)
- case .image(let id):
- guard let item = imageItems.first(where: { $0.id == id }) else { return }
- resizingImageID = id
- resizeStartRect = NSRect(origin: item.origin, size: item.size)
- resizeFixedCorner = fixedCorner(for: resizeStartRect, handle: handle)
- }
- }
- private func applyTextResize(at point: NSPoint, handle: CanvasResizeHandle, index: Int) {
- let startDistance = hypot(resizeStartPoint.x - resizeFixedCorner.x, resizeStartPoint.y - resizeFixedCorner.y)
- let newDistance = hypot(point.x - resizeFixedCorner.x, point.y - resizeFixedCorner.y)
- guard startDistance > 1 else { return }
- let scale = max(0.2, min(5, newDistance / startDistance))
- let newFontSize = min(Self.maxTextFontSize, max(Self.minTextFontSize, resizeStartFontSize * scale))
- textItems[index].fontSize = newFontSize
- let bounds = textBounds(for: textItems[index])
- var origin = NSPoint.zero
- switch handle {
- case .topLeft:
- origin = NSPoint(x: resizeFixedCorner.x - bounds.width, y: resizeFixedCorner.y)
- case .topRight:
- origin = NSPoint(x: resizeFixedCorner.x, y: resizeFixedCorner.y)
- case .bottomLeft:
- origin = NSPoint(x: resizeFixedCorner.x - bounds.width, y: resizeFixedCorner.y - bounds.height)
- case .bottomRight:
- origin = NSPoint(x: resizeFixedCorner.x, y: resizeFixedCorner.y - bounds.height)
- }
- textItems[index].origin = clampedOrigin(origin, size: bounds.size)
- if let field = textField(for: textItems[index].id) {
- field.font = textItems[index].fontStyle.font(size: newFontSize)
- field.frame = textBounds(for: textItems[index])
- }
- }
- private func applyImageResize(at point: NSPoint, handle: CanvasResizeHandle, index: Int) {
- let dx = point.x - resizeStartPoint.x
- let dy = point.y - resizeStartPoint.y
- var rect = resizeStartRect
- switch handle {
- case .topLeft:
- rect.origin.x += dx
- rect.size.width -= dx
- rect.size.height -= dy
- rect.origin.y += dy
- case .topRight:
- rect.size.width += dx
- rect.size.height -= dy
- rect.origin.y += dy
- case .bottomLeft:
- rect.origin.x += dx
- rect.size.width -= dx
- rect.size.height += dy
- case .bottomRight:
- rect.size.width += dx
- rect.size.height += dy
- }
- rect.size.width = max(Self.minImageDimension, rect.size.width)
- rect.size.height = max(Self.minImageDimension, rect.size.height)
- switch handle {
- case .topLeft:
- rect.origin.x = resizeFixedCorner.x - rect.size.width
- rect.origin.y = resizeFixedCorner.y
- case .topRight:
- rect.origin.x = resizeFixedCorner.x
- rect.origin.y = resizeFixedCorner.y
- case .bottomLeft:
- rect.origin.x = resizeFixedCorner.x - rect.size.width
- rect.origin.y = resizeFixedCorner.y - rect.size.height
- case .bottomRight:
- rect.origin.x = resizeFixedCorner.x
- rect.origin.y = resizeFixedCorner.y - rect.size.height
- }
- let clamped = clampedRect(rect)
- imageItems[index].origin = clamped.origin
- imageItems[index].size = clamped.size
- }
- private func clampedOrigin(_ origin: NSPoint, size: NSSize) -> NSPoint {
- var clamped = origin
- clamped.x = max(0, min(clamped.x, bounds.width - size.width))
- clamped.y = max(0, min(clamped.y, bounds.height - size.height))
- return clamped
- }
- private func clampedRect(_ rect: NSRect) -> NSRect {
- var clamped = rect
- clamped.size.width = max(Self.minImageDimension, min(clamped.size.width, bounds.width))
- clamped.size.height = max(Self.minImageDimension, min(clamped.size.height, bounds.height))
- clamped.origin.x = max(0, min(clamped.origin.x, bounds.width - clamped.size.width))
- clamped.origin.y = max(0, min(clamped.origin.y, bounds.height - clamped.size.height))
- return clamped
- }
- }
- // MARK: - Tools Panel
- final class CanvasToolsPanel: NSView, AppearanceRefreshable {
- var onToolChanged: ((CanvasTool) -> Void)?
- var onAddText: (() -> Void)?
- var onTextAlignment: ((NSTextAlignment) -> Void)?
- var onTextFontSize: ((CGFloat) -> Void)?
- var onTextColor: ((NSColor) -> Void)?
- var onDrawColor: ((NSColor) -> Void)?
- var onDrawLineWidth: ((CGFloat) -> Void)?
- private var activeTool: CanvasTool = .draw
- private let toolStack = NSStackView()
- private let subToolbar = NSView()
- private var toolButtons: [CanvasTool: CanvasToolButton] = [:]
- private var subToolbarHeight: NSLayoutConstraint?
- private var subToolbarWidth: NSLayoutConstraint?
- private let drawColors: [NSColor] = [
- NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1),
- NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1),
- NSColor.black,
- NSColor.red,
- NSColor.orange,
- NSColor.purple,
- ]
- private static let fontSizeOptions: [CGFloat] = [12, 14, 16, 18, 20, 22, 24, 28, 32]
- init() {
- super.init(frame: .zero)
- setup()
- refreshAppearance()
- selectTool(.draw)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func setActiveTool(_ tool: CanvasTool) {
- selectTool(tool)
- }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.cardBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- layer?.borderWidth = 1
- subToolbar.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
- subToolbar.layer?.borderColor = AppTheme.paywallBorder.cgColor
- subToolbar.layer?.borderWidth = 1
- toolButtons.values.forEach { $0.refreshAppearance() }
- rebuildSubToolbar()
- }
- private func setup() {
- wantsLayer = true
- layer?.cornerRadius = 28
- toolStack.orientation = .vertical
- toolStack.spacing = 4
- toolStack.translatesAutoresizingMaskIntoConstraints = false
- subToolbar.wantsLayer = true
- subToolbar.layer?.cornerRadius = 20
- subToolbar.translatesAutoresizingMaskIntoConstraints = false
- for tool in CanvasTool.toolbarTools {
- let button = CanvasToolButton(tool: tool)
- button.onClick = { [weak self] in
- guard let self else { return }
- if self.activeTool == tool {
- self.selectTool(.pointer)
- } else {
- self.selectTool(tool)
- }
- }
- toolButtons[tool] = button
- toolStack.addArrangedSubview(button)
- }
- addSubview(subToolbar)
- addSubview(toolStack)
- subToolbarHeight = subToolbar.heightAnchor.constraint(equalToConstant: 44)
- subToolbarWidth = subToolbar.widthAnchor.constraint(equalToConstant: 120)
- NSLayoutConstraint.activate([
- toolStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6),
- toolStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6),
- toolStack.topAnchor.constraint(equalTo: topAnchor, constant: 8),
- toolStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
- subToolbar.trailingAnchor.constraint(equalTo: leadingAnchor, constant: -8),
- subToolbar.centerYAnchor.constraint(equalTo: toolStack.arrangedSubviews.first?.centerYAnchor ?? centerYAnchor),
- subToolbarHeight!,
- subToolbarWidth!,
- ])
- }
- private func selectTool(_ tool: CanvasTool) {
- activeTool = tool
- if tool == .pointer {
- toolButtons.values.forEach { $0.setSelected(false) }
- } else {
- toolButtons.forEach { $0.value.setSelected($0.key == tool) }
- }
- onToolChanged?(tool)
- rebuildSubToolbar()
- }
- private func rebuildSubToolbar() {
- subToolbar.subviews.forEach { $0.removeFromSuperview() }
- let stack = NSStackView()
- stack.orientation = .horizontal
- stack.spacing = 6
- stack.translatesAutoresizingMaskIntoConstraints = false
- switch activeTool {
- case .pointer:
- break
- case .text:
- stack.addArrangedSubview(makeSubButton(symbol: "text.alignleft", action: { [weak self] in
- self?.onTextAlignment?(.left)
- }))
- stack.addArrangedSubview(makeSubButton(symbol: "text.aligncenter", action: { [weak self] in
- self?.onTextAlignment?(.center)
- }))
- stack.addArrangedSubview(makeSubButton(symbol: "text.alignright", action: { [weak self] in
- self?.onTextAlignment?(.right)
- }))
- stack.addArrangedSubview(makeFontSizePopup())
- stack.addArrangedSubview(makeSubButton(symbol: "character.textbox", action: { [weak self] in
- self?.onAddText?()
- }))
- stack.addArrangedSubview(makeColorButton(color: .black, action: { [weak self] in
- self?.pickTextColor()
- }))
- case .image:
- break
- case .draw:
- for color in drawColors {
- stack.addArrangedSubview(makeColorButton(color: color, action: { [weak self] in
- self?.onDrawColor?(color)
- }))
- }
- stack.addArrangedSubview(makeSubButton(symbol: "lineweight", action: { [weak self] in
- self?.onDrawLineWidth?(3)
- }))
- }
- subToolbar.addSubview(stack)
- NSLayoutConstraint.activate([
- stack.leadingAnchor.constraint(equalTo: subToolbar.leadingAnchor, constant: 10),
- stack.trailingAnchor.constraint(equalTo: subToolbar.trailingAnchor, constant: -10),
- stack.topAnchor.constraint(equalTo: subToolbar.topAnchor, constant: 6),
- stack.bottomAnchor.constraint(equalTo: subToolbar.bottomAnchor, constant: -6),
- ])
- subToolbar.isHidden = activeTool == .image || activeTool == .pointer
- let itemCount = max(stack.arrangedSubviews.count, 1)
- subToolbarHeight?.constant = 40
- let width = CGFloat(itemCount) * 34 + CGFloat(max(itemCount - 1, 0)) * 6 + 18
- subToolbarWidth?.constant = width
- }
- private func makeSubButton(symbol: String, action: @escaping () -> Void) -> CanvasSubToolButton {
- let button = CanvasSubToolButton(symbolName: symbol)
- button.onClick = action
- return button
- }
- private func makeColorButton(color: NSColor, action: @escaping () -> Void) -> CanvasColorButton {
- let button = CanvasColorButton(color: color)
- button.onClick = action
- return button
- }
- private func makeFontSizePopup() -> NSPopUpButton {
- let popup = NSPopUpButton()
- popup.bezelStyle = .rounded
- popup.addItems(withTitles: Self.fontSizeOptions.map { "\(Int($0))" })
- popup.selectItem(withTitle: "18")
- popup.font = AppTheme.regularFont(size: 11)
- popup.target = self
- popup.action = #selector(fontSizeChanged(_:))
- popup.translatesAutoresizingMaskIntoConstraints = false
- popup.widthAnchor.constraint(equalToConstant: 58).isActive = true
- popup.heightAnchor.constraint(equalToConstant: 28).isActive = true
- return popup
- }
- @objc private func fontSizeChanged(_ sender: NSPopUpButton) {
- guard let title = sender.titleOfSelectedItem,
- let size = Double(title) else { return }
- onTextFontSize?(CGFloat(size))
- }
- private func pickTextColor() {
- let panel = NSColorPanel.shared
- panel.setTarget(self)
- panel.setAction(#selector(textColorPanelChanged(_:)))
- panel.orderFront(nil)
- }
- @objc private func textColorPanelChanged(_ sender: NSColorPanel) {
- onTextColor?(sender.color)
- }
- }
- private final class PlaceholderTextView: NSTextView {
- var placeholderString = ""
- var placeholderColor: NSColor = AppTheme.textSecondary {
- didSet { needsDisplay = true }
- }
- override func draw(_ dirtyRect: NSRect) {
- super.draw(dirtyRect)
- guard string.isEmpty, !placeholderString.isEmpty else { return }
- let attributes: [NSAttributedString.Key: Any] = [
- .font: font ?? AppTheme.regularFont(size: 20),
- .foregroundColor: placeholderColor,
- ]
- let rect = firstRect(forCharacterRange: NSRange(location: 0, length: 0), actualRange: nil)
- (placeholderString as NSString).draw(with: rect, options: .usesLineFragmentOrigin, attributes: attributes)
- }
- override func becomeFirstResponder() -> Bool {
- let becameFirstResponder = super.becomeFirstResponder()
- if becameFirstResponder { needsDisplay = true }
- return becameFirstResponder
- }
- override func resignFirstResponder() -> Bool {
- let resignedFirstResponder = super.resignFirstResponder()
- if resignedFirstResponder { needsDisplay = true }
- return resignedFirstResponder
- }
- }
- struct AddTextResult {
- let text: String
- let fontSize: CGFloat
- let fontStyle: CanvasTextFontStyle
- }
- private final class AddTextOptionPicker: NSControl, AppearanceRefreshable {
- var onChange: ((String) -> Void)?
- private let titleLabel = NSTextField()
- private let chevronView = NSImageView()
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- private let optionMenu = NSMenu()
- private var options: [String] = []
- private var selectedOption = ""
- init(options: [String], selection: String) {
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- layer?.cornerRadius = 16
- layer?.borderWidth = 1.5
- self.options = options
- selectedOption = selection
- titleLabel.stringValue = selection
- titleLabel.font = AppTheme.mediumFont(size: 16)
- titleLabel.isBordered = false
- titleLabel.isEditable = false
- titleLabel.drawsBackground = false
- titleLabel.lineBreakMode = .byTruncatingTail
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: "Options") {
- let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- chevronView.image = image.withSymbolConfiguration(config)
- }
- chevronView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(titleLabel)
- addSubview(chevronView)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 52),
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevronView.leadingAnchor, constant: -10),
- chevronView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
- chevronView.centerYAnchor.constraint(equalTo: centerYAnchor),
- chevronView.widthAnchor.constraint(equalToConstant: 16),
- chevronView.heightAnchor.constraint(equalToConstant: 16),
- ])
- rebuildMenu()
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func setSelection(_ value: String) {
- guard options.contains(value) else { return }
- selectedOption = value
- titleLabel.stringValue = value
- }
- func refreshAppearance() {
- layer?.backgroundColor = isHovered
- ? AppTheme.elevatedBackground.cgColor
- : AppTheme.cardBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- titleLabel.textColor = AppTheme.textPrimary
- chevronView.contentTintColor = AppTheme.textPrimary
- }
- private func setHovered(_ hovering: Bool) {
- isHovered = hovering
- animateHover {
- layer?.backgroundColor = hovering
- ? AppTheme.elevatedBackground.cgColor
- : AppTheme.cardBackground.cgColor
- }
- }
- private func rebuildMenu() {
- optionMenu.removeAllItems()
- for option in options {
- let item = NSMenuItem(title: option, action: #selector(menuItemSelected(_:)), keyEquivalent: "")
- item.target = self
- item.state = option == selectedOption ? .on : .off
- optionMenu.addItem(item)
- }
- }
- @objc private func menuItemSelected(_ sender: NSMenuItem) {
- let value = sender.title
- guard options.contains(value) else { return }
- selectedOption = value
- titleLabel.stringValue = value
- rebuildMenu()
- onChange?(value)
- }
- override func mouseDown(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- rebuildMenu()
- optionMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: bounds.height + 4), in: self)
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextViewDelegate {
- private static let defaultFontSize: CGFloat = 18
- private static let fontSizeOptions: [CGFloat] = [12, 14, 16, 18, 20, 22, 24, 28, 32]
- private static let inputTextInset = NSSize(width: 16, height: 14)
- private static let inputLineFragmentPadding: CGFloat = 5
- var onAdd: ((AddTextResult) -> Void)?
- var onCancel: (() -> Void)?
- private var selectedFontSize = defaultFontSize
- private var selectedFontStyle: CanvasTextFontStyle = .regular
- private let dimView = NSView()
- private let cardView = NSView()
- private let titleLabel = NSTextField(labelWithString: "Add Text")
- private let optionsStack = NSStackView()
- private let fontStylePicker: AddTextOptionPicker
- private let fontSizePicker: AddTextOptionPicker
- private let inputScrollView = NSScrollView()
- private let inputTextView = PlaceholderTextView()
- private let buttonStack = NSStackView()
- private let addButton = DrawPrintPrintButton(title: "Add", symbolName: "plus", compact: true, firesOnMouseDown: true)
- private let cancelButton = DrawPrintSecondaryButton(title: "Cancel", symbolName: "xmark", compact: true, firesOnMouseDown: true)
- override init(frame frameRect: NSRect) {
- fontStylePicker = AddTextOptionPicker(
- options: CanvasTextFontStyle.allCases.map(\.rawValue),
- selection: CanvasTextFontStyle.regular.rawValue
- )
- fontSizePicker = AddTextOptionPicker(
- options: Self.fontSizeOptions.map { "\(Int($0))" },
- selection: "\(Int(Self.defaultFontSize))"
- )
- super.init(frame: frameRect)
- translatesAutoresizingMaskIntoConstraints = false
- setup()
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- dimView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor
- cardView.layer?.backgroundColor = AppTheme.cardBackground.cgColor
- cardView.layer?.borderColor = AppTheme.paywallBorder.cgColor
- titleLabel.textColor = AppTheme.textPrimary
- inputTextView.backgroundColor = .clear
- inputTextView.textColor = AppTheme.textPrimary
- inputTextView.placeholderColor = AppTheme.textSecondary
- addButton.refreshAppearance()
- cancelButton.refreshAppearance()
- fontStylePicker.refreshAppearance()
- fontSizePicker.refreshAppearance()
- }
- func prepareForPresentation() {
- inputTextView.string = ""
- selectedFontSize = Self.defaultFontSize
- selectedFontStyle = .regular
- fontStylePicker.setSelection(CanvasTextFontStyle.regular.rawValue)
- fontSizePicker.setSelection("\(Int(Self.defaultFontSize))")
- applyInputFont()
- inputTextView.needsDisplay = true
- }
- func focusInput() {
- window?.makeFirstResponder(inputTextView)
- }
- private func setup() {
- wantsLayer = true
- dimView.wantsLayer = true
- dimView.translatesAutoresizingMaskIntoConstraints = false
- cardView.wantsLayer = true
- cardView.layer?.cornerRadius = 24
- cardView.layer?.borderWidth = 1
- cardView.applyCardShadow()
- cardView.translatesAutoresizingMaskIntoConstraints = false
- titleLabel.font = AppTheme.semiboldFont(size: 22)
- titleLabel.alignment = .center
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- optionsStack.orientation = .horizontal
- optionsStack.spacing = 12
- optionsStack.distribution = .fillEqually
- optionsStack.translatesAutoresizingMaskIntoConstraints = false
- fontStylePicker.onChange = { [weak self] value in
- self?.fontStyleChanged(value)
- }
- fontSizePicker.onChange = { [weak self] value in
- self?.fontSizeChanged(value)
- }
- inputScrollView.translatesAutoresizingMaskIntoConstraints = false
- inputScrollView.drawsBackground = false
- inputScrollView.hasVerticalScroller = true
- inputScrollView.autohidesScrollers = true
- inputScrollView.borderType = .noBorder
- inputTextView.isRichText = false
- inputTextView.font = selectedFontStyle.font(size: selectedFontSize)
- inputTextView.textContainerInset = Self.inputTextInset
- inputTextView.textContainer?.lineFragmentPadding = Self.inputLineFragmentPadding
- inputTextView.placeholderString = "Enter any text here..."
- inputTextView.isAutomaticQuoteSubstitutionEnabled = true
- inputTextView.isAutomaticDashSubstitutionEnabled = true
- inputTextView.isAutomaticTextReplacementEnabled = true
- inputTextView.delegate = self
- inputScrollView.documentView = inputTextView
- buttonStack.orientation = .horizontal
- buttonStack.spacing = 12
- buttonStack.distribution = .fillEqually
- buttonStack.translatesAutoresizingMaskIntoConstraints = false
- addButton.onClick = { [weak self] in
- self?.handleAdd()
- }
- cancelButton.onClick = { [weak self] in
- self?.handleCancel()
- }
- addSubview(dimView)
- addSubview(cardView)
- cardView.addSubview(titleLabel)
- cardView.addSubview(optionsStack)
- optionsStack.addArrangedSubview(fontStylePicker)
- optionsStack.addArrangedSubview(fontSizePicker)
- cardView.addSubview(inputScrollView)
- cardView.addSubview(buttonStack)
- buttonStack.addArrangedSubview(addButton)
- buttonStack.addArrangedSubview(cancelButton)
- NSLayoutConstraint.activate([
- dimView.leadingAnchor.constraint(equalTo: leadingAnchor),
- dimView.trailingAnchor.constraint(equalTo: trailingAnchor),
- dimView.topAnchor.constraint(equalTo: topAnchor),
- dimView.bottomAnchor.constraint(equalTo: bottomAnchor),
- cardView.centerXAnchor.constraint(equalTo: centerXAnchor),
- cardView.centerYAnchor.constraint(equalTo: centerYAnchor),
- cardView.widthAnchor.constraint(lessThanOrEqualToConstant: 560),
- cardView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.62),
- cardView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 40),
- cardView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -40),
- cardView.heightAnchor.constraint(equalToConstant: 320),
- titleLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 18),
- titleLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
- titleLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
- optionsStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
- optionsStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
- optionsStack.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12),
- inputScrollView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
- inputScrollView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
- inputScrollView.topAnchor.constraint(equalTo: optionsStack.bottomAnchor, constant: 12),
- inputScrollView.bottomAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -16),
- buttonStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
- buttonStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
- buttonStack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -20),
- buttonStack.heightAnchor.constraint(equalToConstant: 44),
- ])
- }
- private func handleAdd() {
- let raw = inputTextView.textStorage?.string ?? inputTextView.string
- let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !text.isEmpty else { return }
- onAdd?(AddTextResult(text: text, fontSize: selectedFontSize, fontStyle: selectedFontStyle))
- }
- private func fontStyleChanged(_ title: String) {
- guard let style = CanvasTextFontStyle(rawValue: title) else { return }
- selectedFontStyle = style
- applyInputFont()
- }
- private func fontSizeChanged(_ title: String) {
- guard let size = Double(title) else { return }
- selectedFontSize = CGFloat(size)
- applyInputFont()
- }
- private func applyInputFont() {
- inputTextView.font = selectedFontStyle.font(size: selectedFontSize)
- inputTextView.needsDisplay = true
- }
- @objc private func handleCancel() {
- onCancel?()
- }
- func textDidChange(_ notification: Notification) {
- inputTextView.needsDisplay = true
- }
- }
- // MARK: - Controls
- private final class DrawPrintToolbarButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let symbolName: String
- private let iconView = NSImageView()
- init(symbolName: String, accessibilityLabel: String) {
- self.symbolName = symbolName
- super.init(frame: .zero)
- toolTip = accessibilityLabel
- wantsLayer = true
- layer?.cornerRadius = 12
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
- let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- NSLayoutConstraint.activate([
- iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- layer?.borderWidth = 1.5
- iconView.contentTintColor = AppTheme.textPrimary
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- private final class DrawPrintSecondaryButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let title: String
- private let titleLabel = NSTextField()
- private let iconView = NSImageView()
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- private let firesOnMouseDown: Bool
- init(title: String, symbolName: String, compact: Bool = false, firesOnMouseDown: Bool = false) {
- self.title = title
- self.firesOnMouseDown = firesOnMouseDown
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- let height: CGFloat = compact ? 44 : 52
- let cornerRadius: CGFloat = compact ? 18 : 22
- let fontSize: CGFloat = compact ? 14 : 16
- let horizontalPadding: CGFloat = compact ? 18 : 24
- let minWidth: CGFloat = compact ? 0 : 180
- let iconSize: CGFloat = compact ? 14 : 18
- let symbolPointSize: CGFloat = compact ? 12 : 14
- layer?.cornerRadius = cornerRadius
- layer?.borderWidth = 1.5
- titleLabel.stringValue = title
- titleLabel.font = AppTheme.semiboldFont(size: fontSize)
- titleLabel.isBordered = false
- titleLabel.isEditable = false
- titleLabel.drawsBackground = false
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
- let config = NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(titleLabel)
- addSubview(iconView)
- var constraints = [
- heightAnchor.constraint(equalToConstant: height),
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalPadding),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 8),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalPadding),
- iconView.widthAnchor.constraint(equalToConstant: iconSize),
- iconView.heightAnchor.constraint(equalToConstant: iconSize),
- ]
- if minWidth > 0 {
- constraints.append(widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth))
- }
- NSLayoutConstraint.activate(constraints)
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = isHovered
- ? AppTheme.elevatedBackground.cgColor
- : AppTheme.cardBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- titleLabel.textColor = AppTheme.textPrimary
- iconView.contentTintColor = AppTheme.textPrimary
- }
- private func setHovered(_ hovering: Bool) {
- isHovered = hovering
- animateHover {
- layer?.backgroundColor = hovering
- ? AppTheme.elevatedBackground.cgColor
- : AppTheme.cardBackground.cgColor
- layer?.transform = hovering
- ? CATransform3DMakeScale(1.03, 1.03, 1)
- : CATransform3DIdentity
- }
- }
- override func mouseDown(with event: NSEvent) {
- guard firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func mouseUp(with event: NSEvent) {
- guard !firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- private final class DrawPrintPrintButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let titleLabel: NSTextField
- private let iconView = NSImageView()
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- private let firesOnMouseDown: Bool
- init(
- title: String = "Print Canvas",
- symbolName: String = "printer.fill",
- compact: Bool = false,
- firesOnMouseDown: Bool = false
- ) {
- titleLabel = NSTextField(labelWithString: title)
- self.firesOnMouseDown = firesOnMouseDown
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- let height: CGFloat = compact ? 44 : 52
- let cornerRadius: CGFloat = compact ? 18 : 22
- let fontSize: CGFloat = compact ? 14 : 16
- let horizontalPadding: CGFloat = compact ? 20 : 28
- let iconSize: CGFloat = compact ? 16 : 20
- let symbolPointSize: CGFloat = compact ? 12 : 14
- layer?.cornerRadius = cornerRadius
- titleLabel.font = AppTheme.semiboldFont(size: fontSize)
- titleLabel.textColor = .white
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
- let config = NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.contentTintColor = .white
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(titleLabel)
- addSubview(iconView)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: height),
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalPadding),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalPadding),
- iconView.widthAnchor.constraint(equalToConstant: iconSize),
- iconView.heightAnchor.constraint(equalToConstant: iconSize),
- ])
- hoverTracker = HoverTracker(view: self) { [weak self] hovering in
- self?.setHovered(hovering)
- }
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- let color = isHovered
- ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
- : AppTheme.blue
- layer?.backgroundColor = color.cgColor
- titleLabel.textColor = .white
- iconView.contentTintColor = .white
- }
- private func setHovered(_ hovering: Bool) {
- isHovered = hovering
- animateHover {
- let color = hovering
- ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue
- : AppTheme.blue
- layer?.backgroundColor = color.cgColor
- layer?.transform = hovering
- ? CATransform3DMakeScale(1.03, 1.03, 1)
- : CATransform3DIdentity
- }
- }
- override func mouseDown(with event: NSEvent) {
- guard firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func mouseUp(with event: NSEvent) {
- guard !firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- private final class CanvasHistoryButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private var actionEnabled = true {
- didSet { alphaValue = actionEnabled ? 1 : 0.35 }
- }
- private let iconView = NSImageView()
- init(symbolName: String, accessibilityLabel: String) {
- super.init(frame: .zero)
- toolTip = accessibilityLabel
- wantsLayer = true
- layer?.cornerRadius = 16
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) {
- let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- NSLayoutConstraint.activate([
- iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = NSColor(calibratedWhite: 0.88, alpha: 1).cgColor
- iconView.contentTintColor = NSColor(calibratedWhite: 0.35, alpha: 1)
- }
- func setActionEnabled(_ enabled: Bool) {
- actionEnabled = enabled
- }
- override func mouseDown(with event: NSEvent) {}
- override func mouseUp(with event: NSEvent) {
- guard actionEnabled, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- private final class CanvasToolButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let tool: CanvasTool
- private let iconView = NSImageView()
- private var selected = false
- init(tool: CanvasTool) {
- self.tool = tool
- super.init(frame: .zero)
- wantsLayer = true
- layer?.cornerRadius = 12
- let symbol: String
- switch tool {
- case .pointer: symbol = "arrow.up.left"
- case .text: symbol = "character.cursor.ibeam"
- case .image: symbol = "photo.on.rectangle.angled"
- case .draw: symbol = "paintbrush.pointed.fill"
- }
- if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: toolLabel) {
- let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .medium)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- NSLayoutConstraint.activate([
- widthAnchor.constraint(equalToConstant: 38),
- heightAnchor.constraint(equalToConstant: 38),
- iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- private var toolLabel: String {
- switch tool {
- case .pointer: return "Pointer"
- case .text: return "Text"
- case .image: return "Image"
- case .draw: return "Draw"
- }
- }
- func setSelected(_ selected: Bool) {
- self.selected = selected
- refreshAppearance()
- }
- func refreshAppearance() {
- layer?.backgroundColor = selected
- ? AppTheme.homeActiveBackground.cgColor
- : NSColor.clear.cgColor
- iconView.contentTintColor = selected ? AppTheme.homeActiveForeground : AppTheme.textPrimary
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- private final class CanvasSubToolButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let iconView = NSImageView()
- init(symbolName: String) {
- super.init(frame: .zero)
- wantsLayer = true
- layer?.cornerRadius = 14
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) {
- let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(iconView)
- NSLayoutConstraint.activate([
- widthAnchor.constraint(equalToConstant: 28),
- heightAnchor.constraint(equalToConstant: 28),
- iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = AppTheme.elevatedBackground.cgColor
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- layer?.borderWidth = 1
- iconView.contentTintColor = AppTheme.textPrimary
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- private final class CanvasColorButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let swatchColor: NSColor
- init(color: NSColor) {
- self.swatchColor = color
- super.init(frame: .zero)
- wantsLayer = true
- layer?.cornerRadius = 12
- layer?.borderWidth = 2
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- NSLayoutConstraint.activate([
- widthAnchor.constraint(equalToConstant: 24),
- heightAnchor.constraint(equalToConstant: 24),
- ])
- refreshAppearance()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- func refreshAppearance() {
- layer?.backgroundColor = swatchColor.cgColor
- }
- override func mouseUp(with event: NSEvent) {
- guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
- onClick?()
- }
- override func resetCursorRects() {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
|