| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056 |
- 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: CaseIterable {
- case text
- case image
- case draw
- }
- struct CanvasStroke {
- var points: [NSPoint]
- var color: NSColor
- var lineWidth: CGFloat
- }
- struct CanvasTextData {
- var id: UUID
- var text: String
- var origin: NSPoint
- var fontSize: CGFloat
- 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 = 56
- private static let toolsPanelGap: CGFloat = 12
- var onDismiss: (() -> Void)?
- 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 toolsPanel = CanvasToolsPanel()
- 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()
- printButton.refreshAppearance()
- canvasView.refreshAppearance()
- toolsPanel.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
- printButton.translatesAutoresizingMaskIntoConstraints = false
- backButton.onClick = { [weak self] in self?.dismiss() }
- printButton.onClick = { [weak self] in self?.printCanvas() }
- toolsPanel.onToolChanged = { [weak self] tool in
- self?.canvasView.activeTool = tool
- }
- toolsPanel.onAddText = { [weak self] in
- self?.canvasView.addTextItem()
- }
- 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
- }
- toolsPanel.onAddImage = { [weak self] in
- self?.pickImage()
- }
- canvasView.onUndoStateChanged = { [weak self] canUndo, canRedo in
- self?.canvasView.updateUndoButtons(canUndo: canUndo, canRedo: canRedo)
- }
- canvasContainer.addSubview(canvasView)
- addSubview(backButton)
- addSubview(titleLabel)
- addSubview(canvasContainer)
- addSubview(toolsPanel)
- addSubview(printButton)
- 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: printButton.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),
- printButton.centerXAnchor.constraint(equalTo: centerXAnchor),
- printButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
- printButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
- ])
- }
- private func pickImage() {
- 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)
- }
- private func printCanvas() {
- guard canvasView.hasContent else {
- PrintService.showEmptyCanvasAlert()
- return
- }
- guard let image = canvasView.renderSnapshot() else { return }
- PrintService.printCanvas(image, from: window)
- }
- }
- // MARK: - Canvas View
- final class BlankCanvasView: NSView, AppearanceRefreshable {
- var activeTool: CanvasTool = .draw {
- didSet { needsDisplay = true }
- }
- var drawColor: NSColor = NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1)
- var drawLineWidth: CGFloat = 3
- var onUndoStateChanged: ((_ canUndo: Bool, _ canRedo: Bool) -> 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 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 updateUndoButtons(canUndo: Bool, canRedo: Bool) {
- undoButton.setActionEnabled(canUndo)
- redoButton.setActionEnabled(canRedo)
- }
- private func setup() {
- wantsLayer = true
- 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),
- ])
- }
- 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 imageItems {
- let rect = NSRect(origin: item.origin, size: item.size)
- item.image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
- }
- }
- 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)
- switch activeTool {
- case .draw:
- pushSnapshot()
- currentStroke = CanvasStroke(points: [point], color: drawColor, lineWidth: drawLineWidth)
- needsDisplay = true
- case .text:
- if let hitID = textField(at: point)?.identifier.flatMap({ UUID(uuidString: $0.rawValue) }) {
- selectedTextID = hitID
- window?.makeFirstResponder(textField(for: hitID))
- } else {
- addTextItem(at: point)
- }
- case .image:
- break
- }
- }
- override func mouseDragged(with event: NSEvent) {
- guard activeTool == .draw, var stroke = currentStroke else { return }
- let point = convert(event.locationInWindow, from: nil)
- stroke.points.append(point)
- currentStroke = stroke
- needsDisplay = true
- }
- override func mouseUp(with event: NSEvent) {
- guard activeTool == .draw, let stroke = currentStroke, stroke.points.count > 1 else {
- currentStroke = nil
- return
- }
- strokes.append(stroke)
- currentStroke = nil
- needsDisplay = true
- notifyUndoState()
- }
- func addTextItem(at origin: NSPoint? = nil) {
- pushSnapshot()
- let placement = origin ?? NSPoint(x: 24, y: bounds.height - 48)
- let item = CanvasTextData(
- id: UUID(),
- text: "Text",
- origin: placement,
- fontSize: 18,
- color: .black,
- alignment: .left
- )
- textItems.append(item)
- installTextField(for: item)
- selectedTextID = item.id
- window?.makeFirstResponder(textField(for: item.id))
- notifyUndoState()
- }
- func addImage(_ image: NSImage) {
- 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)
- let origin = NSPoint(
- x: (bounds.width - size.width) / 2,
- y: (bounds.height - size.height) / 2
- )
- imageItems.append(CanvasImageData(id: UUID(), origin: origin, size: size, image: image))
- 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
- }
- 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 = AppTheme.regularFont(size: size)
- }
- 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
- }
- 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() {
- for index in textItems.indices {
- if let field = textField(for: textItems[index].id) {
- 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() }
- }
- 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
- removeTextFields()
- for item in textItems {
- installTextField(for: item)
- }
- needsDisplay = true
- notifyUndoState()
- }
- private func notifyUndoState() {
- onUndoStateChanged?(!undoStack.isEmpty, !redoStack.isEmpty)
- updateUndoButtons(canUndo: !undoStack.isEmpty, canRedo: !redoStack.isEmpty)
- }
- private func installTextField(for item: CanvasTextData) {
- let field = NSTextField(string: item.text)
- field.identifier = NSUserInterfaceItemIdentifier(item.id.uuidString)
- field.isBordered = false
- field.isBezeled = false
- field.drawsBackground = false
- field.font = AppTheme.regularFont(size: item.fontSize)
- field.textColor = item.color
- field.alignment = item.alignment
- field.frame = NSRect(x: item.origin.x, y: item.origin.y, width: 200, height: 28)
- field.target = self
- field.action = #selector(textFieldEdited(_:))
- addSubview(field)
- }
- @objc private func textFieldEdited(_ 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
- }
- private func textField(for id: UUID) -> NSTextField? {
- subviews.compactMap { $0 as? NSTextField }.first {
- $0.identifier?.rawValue == id.uuidString
- }
- }
- private func textField(at point: NSPoint) -> NSTextField? {
- subviews.compactMap { $0 as? NSTextField }.first { $0.frame.contains(point) }
- }
- private func removeTextFields() {
- subviews.compactMap { $0 as? NSTextField }.forEach { $0.removeFromSuperview() }
- }
- }
- // 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)?
- var onAddImage: (() -> 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,
- ]
- init() {
- super.init(frame: .zero)
- setup()
- refreshAppearance()
- selectTool(.draw)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- 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.allCases {
- let button = CanvasToolButton(tool: tool)
- button.onClick = { [weak self] in 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: 10),
- toolStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -10),
- 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
- 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 .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(makeSubButton(symbol: "textformat.size", action: { [weak self] in
- self?.onTextFontSize?(22)
- }))
- 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:
- stack.addArrangedSubview(makeSubButton(symbol: "photo.on.rectangle.angled", action: { [weak self] in
- self?.onAddImage?()
- }))
- 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?(5)
- }))
- }
- 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),
- ])
- let itemCount = max(stack.arrangedSubviews.count, 1)
- subToolbarHeight?.constant = 44
- let width = CGFloat(itemCount) * 38 + CGFloat(max(itemCount - 1, 0)) * 6 + 20
- 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 pickTextColor() {
- let panel = NSColorPanel.shared
- panel.setTarget(self)
- panel.setAction(#selector(textColorPanelChanged(_:)))
- panel.orderFront(nil)
- }
- @objc private func textColorPanelChanged(_ sender: NSColorPanel) {
- onTextColor?(sender.color)
- }
- }
- // 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 DrawPrintPrintButton: NSControl, AppearanceRefreshable {
- var onClick: (() -> Void)?
- private let titleLabel = NSTextField(labelWithString: "Print Canvas")
- private let iconView = NSImageView()
- private var hoverTracker: HoverTracker?
- private var isHovered = false
- init() {
- super.init(frame: .zero)
- wantsLayer = true
- layer?.cornerRadius = 22
- titleLabel.font = AppTheme.semiboldFont(size: 16)
- titleLabel.textColor = .white
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- if let image = NSImage(systemSymbolName: "printer.fill", accessibilityDescription: "Print") {
- let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
- iconView.image = image.withSymbolConfiguration(config)
- }
- iconView.contentTintColor = .white
- iconView.translatesAutoresizingMaskIntoConstraints = false
- addSubview(titleLabel)
- addSubview(iconView)
- NSLayoutConstraint.activate([
- heightAnchor.constraint(equalToConstant: 52),
- titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
- titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10),
- iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
- iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -28),
- iconView.widthAnchor.constraint(equalToConstant: 20),
- iconView.heightAnchor.constraint(equalToConstant: 20),
- ])
- 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 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 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 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 = 14
- let symbol: String
- switch tool {
- 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: 44),
- heightAnchor.constraint(equalToConstant: 44),
- 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 .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 = 16
- 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: 32),
- heightAnchor.constraint(equalToConstant: 32),
- 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 = 14
- layer?.borderWidth = 2
- layer?.borderColor = AppTheme.paywallBorder.cgColor
- NSLayoutConstraint.activate([
- widthAnchor.constraint(equalToConstant: 28),
- heightAnchor.constraint(equalToConstant: 28),
- ])
- 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)
- }
- }
|