| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259 |
- import Cocoa
- import UniformTypeIdentifiers
- // MARK: - Service
- enum DrawPrintService {
- static func present(from window: NSWindow?) {
- let hostWindow = window ?? NSApp.keyWindow
- guard PremiumAccess.require(feature: .drawPrint, from: hostWindow) else { return }
- 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)
- }
- }
|