Переглянути джерело

Add centered modal dialog for placing text on the blank canvas.

Replace inline canvas text editing with a styled Add/Cancel popup so entered text reliably appears on the canvas after confirmation.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 місяць тому
батько
коміт
0e252a5752
2 змінених файлів з 350 додано та 57 видалено
  1. 346 57
      smart_printer/DrawPrintView.swift
  2. 4 0
      smart_printer/PrintTextView.swift

+ 346 - 57
smart_printer/DrawPrintView.swift

@@ -50,7 +50,7 @@ struct CanvasSnapshot {
 // MARK: - Overlay
 
 final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
-    private static let toolsPanelWidth: CGFloat = 56
+    private static let toolsPanelWidth: CGFloat = 48
     private static let toolsPanelGap: CGFloat = 12
 
     var onDismiss: (() -> Void)?
@@ -61,6 +61,9 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
     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")
@@ -100,6 +103,7 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         printButton.refreshAppearance()
         canvasView.refreshAppearance()
         toolsPanel.refreshAppearance()
+        addTextPromptView.refreshAppearance()
     }
 
     func present(in parent: NSView) {
@@ -160,7 +164,8 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
             self?.canvasView.activeTool = tool
         }
         toolsPanel.onAddText = { [weak self] in
-            self?.canvasView.addTextItem()
+            self?.pendingTextPlacement = nil
+            self?.presentAddTextDialog()
         }
         toolsPanel.onTextAlignment = { [weak self] alignment in
             self?.canvasView.setSelectedTextAlignment(alignment)
@@ -177,12 +182,14 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         toolsPanel.onDrawLineWidth = { [weak self] width in
             self?.canvasView.drawLineWidth = width
         }
-        toolsPanel.onAddImage = { [weak self] in
-            self?.pickImage()
-        }
         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
@@ -196,6 +203,7 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         addSubview(canvasContainer)
         addSubview(toolsPanel)
         addSubview(bottomButtonStack)
+        addSubview(addTextPromptView)
 
         NSLayoutConstraint.activate([
             backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
@@ -225,7 +233,22 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
 
             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] text in
+            self?.commitAddedText(text)
+        }
+        addTextPromptView.onCancel = { [weak self] in
+            self?.pendingTextPlacement = nil
+            self?.hideAddTextPrompt()
+        }
     }
 
     private func pickImage(at placement: NSPoint? = nil) {
@@ -243,6 +266,52 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         canvasView.addImage(image, at: placement)
     }
 
+    private func commitAddedText(_ text: String) {
+        let placement = pendingTextPlacement
+        pendingTextPlacement = nil
+        suppressCanvasTextPrompt = true
+
+        canvasView.stopInlineTextEditing()
+        layoutSubtreeIfNeeded()
+        canvasContainer.layoutSubtreeIfNeeded()
+        canvasView.layoutSubtreeIfNeeded()
+        canvasView.addTextItem(text: text, at: placement)
+
+        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()
@@ -273,13 +342,15 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
             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 = 3
+    var drawLineWidth: CGFloat = 2
     var onUndoStateChanged: ((_ canUndo: Bool, _ canRedo: Bool) -> Void)?
     var onRequestAddImage: ((NSPoint) -> Void)?
+    var onRequestAddText: ((NSPoint) -> Void)?
 
     var hasContent: Bool {
         syncTextFromFields()
@@ -322,8 +393,13 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         redoButton.refreshAppearance()
     }
 
+    func stopInlineTextEditing() {
+        endEditingText()
+    }
+
     private func setup() {
         wantsLayer = true
+        layerContentsRedrawPolicy = .onSetNeedsDisplay
         layer?.cornerRadius = AppTheme.contentPanelCornerRadius
         layer?.masksToBounds = true
 
@@ -347,6 +423,25 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
             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 .draw:
+            cursor = .crosshair
+        case .text:
+            cursor = .iBeam
+        case .image:
+            cursor = .pointingHand
+        }
+        addCursorRect(bounds, cursor: cursor)
     }
 
     override func hitTest(_ point: NSPoint) -> NSView? {
@@ -468,7 +563,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
             } else if editingTextID != nil {
                 endEditingText()
             } else {
-                addTextItem(at: point)
+                onRequestAddText?(point)
             }
         case .image:
             if let index = imageIndex(at: point) {
@@ -553,12 +648,19 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         notifyUndoState()
     }
 
-    func addTextItem(at origin: NSPoint? = nil) {
+    func addTextItem(text: String = "Text", at origin: NSPoint? = nil) {
+        guard bounds.width > 1, bounds.height > 1 else {
+            DispatchQueue.main.async { [weak self] in
+                self?.addTextItem(text: text, at: origin)
+            }
+            return
+        }
+
         pushSnapshot()
         let defaultSize = textBounds(
             for: CanvasTextData(
                 id: UUID(),
-                text: "Text",
+                text: text,
                 origin: .zero,
                 fontSize: 18,
                 color: .black,
@@ -582,7 +684,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         clampedOrigin.y = max(0, min(clampedOrigin.y, bounds.height - defaultSize.height))
         let item = CanvasTextData(
             id: UUID(),
-            text: "Text",
+            text: text,
             origin: clampedOrigin,
             fontSize: 18,
             color: .black,
@@ -590,8 +692,8 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         )
         textItems.append(item)
         selectedTextID = item.id
-        beginEditingText(id: item.id)
-        needsDisplay = true
+        setNeedsDisplay(bounds)
+        displayIfNeeded()
         notifyUndoState()
     }
 
@@ -801,7 +903,6 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
     var onTextColor: ((NSColor) -> Void)?
     var onDrawColor: ((NSColor) -> Void)?
     var onDrawLineWidth: ((CGFloat) -> Void)?
-    var onAddImage: (() -> Void)?
 
     private var activeTool: CanvasTool = .draw
     private let toolStack = NSStackView()
@@ -870,8 +971,8 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
         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),
+            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),
@@ -914,9 +1015,7 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
                 self?.pickTextColor()
             }))
         case .image:
-            stack.addArrangedSubview(makeSubButton(symbol: "photo.on.rectangle.angled", action: { [weak self] in
-                self?.onAddImage?()
-            }))
+            break
         case .draw:
             for color in drawColors {
                 stack.addArrangedSubview(makeColorButton(color: color, action: { [weak self] in
@@ -924,7 +1023,7 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
                 }))
             }
             stack.addArrangedSubview(makeSubButton(symbol: "lineweight", action: { [weak self] in
-                self?.onDrawLineWidth?(5)
+                self?.onDrawLineWidth?(3)
             }))
         }
 
@@ -936,9 +1035,11 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
             stack.bottomAnchor.constraint(equalTo: subToolbar.bottomAnchor, constant: -6),
         ])
 
+        subToolbar.isHidden = activeTool == .image
+
         let itemCount = max(stack.arrangedSubviews.count, 1)
-        subToolbarHeight?.constant = 44
-        let width = CGFloat(itemCount) * 38 + CGFloat(max(itemCount - 1, 0)) * 6 + 20
+        subToolbarHeight?.constant = 40
+        let width = CGFloat(itemCount) * 34 + CGFloat(max(itemCount - 1, 0)) * 6 + 18
         subToolbarWidth?.constant = width
     }
 
@@ -986,6 +1087,155 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
     }
 }
 
+private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextViewDelegate {
+    var onAdd: ((String) -> Void)?
+    var onCancel: (() -> Void)?
+
+    private let dimView = NSView()
+    private let cardView = NSView()
+    private let titleLabel = NSTextField(labelWithString: "Add Text")
+    private let inputScrollView = NSScrollView()
+    private let inputTextView = NSTextView()
+    private let placeholderLabel = NSTextField(labelWithString: "Enter any text here...")
+    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) {
+        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
+        placeholderLabel.textColor = AppTheme.textSecondary
+        addButton.refreshAppearance()
+        cancelButton.refreshAppearance()
+    }
+
+    func prepareForPresentation() {
+        inputTextView.string = ""
+        placeholderLabel.isHidden = false
+    }
+
+    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
+
+        inputScrollView.translatesAutoresizingMaskIntoConstraints = false
+        inputScrollView.drawsBackground = false
+        inputScrollView.hasVerticalScroller = true
+        inputScrollView.autohidesScrollers = true
+        inputScrollView.borderType = .noBorder
+
+        inputTextView.isRichText = false
+        inputTextView.font = AppTheme.regularFont(size: 20)
+        inputTextView.textContainerInset = NSSize(width: 16, height: 14)
+        inputTextView.isAutomaticQuoteSubstitutionEnabled = true
+        inputTextView.isAutomaticDashSubstitutionEnabled = true
+        inputTextView.isAutomaticTextReplacementEnabled = true
+        inputTextView.delegate = self
+        inputScrollView.documentView = inputTextView
+
+        placeholderLabel.font = AppTheme.regularFont(size: 18)
+        placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
+
+        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(inputScrollView)
+        cardView.addSubview(placeholderLabel)
+        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: 280),
+
+            titleLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 18),
+            titleLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
+            titleLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
+
+            inputScrollView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
+            inputScrollView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24),
+            inputScrollView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 14),
+            inputScrollView.bottomAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -16),
+
+            placeholderLabel.leadingAnchor.constraint(equalTo: inputScrollView.leadingAnchor, constant: 6),
+            placeholderLabel.topAnchor.constraint(equalTo: inputScrollView.topAnchor, constant: 10),
+
+            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?(text)
+    }
+
+    @objc private func handleCancel() {
+        onCancel?()
+    }
+
+    func textDidChange(_ notification: Notification) {
+        placeholderLabel.isHidden = !inputTextView.string.isEmpty
+    }
+}
+
 // MARK: - Controls
 
 private final class DrawPrintToolbarButton: NSControl, AppearanceRefreshable {
@@ -1043,22 +1293,33 @@ private final class DrawPrintSecondaryButton: NSControl, AppearanceRefreshable {
     private var hoverTracker: HoverTracker?
     private var isHovered = false
 
-    init(title: String, symbolName: String) {
+    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
-        layer?.cornerRadius = 22
+        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: 16)
+        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: 14, weight: .semibold)
+            let config = NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: .semibold)
             iconView.image = image.withSymbolConfiguration(config)
         }
         iconView.translatesAutoresizingMaskIntoConstraints = false
@@ -1066,17 +1327,20 @@ private final class DrawPrintSecondaryButton: NSControl, AppearanceRefreshable {
         addSubview(titleLabel)
         addSubview(iconView)
 
-        NSLayoutConstraint.activate([
-            heightAnchor.constraint(equalToConstant: 52),
-            widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
-            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
+        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: -24),
-            iconView.widthAnchor.constraint(equalToConstant: 18),
-            iconView.heightAnchor.constraint(equalToConstant: 18),
-        ])
+            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)
@@ -1108,8 +1372,13 @@ private final class DrawPrintSecondaryButton: NSControl, AppearanceRefreshable {
         }
     }
 
+    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 bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
+        guard !firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
         onClick?()
     }
 
@@ -1121,22 +1390,37 @@ private final class DrawPrintSecondaryButton: NSControl, AppearanceRefreshable {
 private final class DrawPrintPrintButton: NSControl, AppearanceRefreshable {
     var onClick: (() -> Void)?
 
-    private let titleLabel = NSTextField(labelWithString: "Print Canvas")
+    private let titleLabel: NSTextField
     private let iconView = NSImageView()
     private var hoverTracker: HoverTracker?
     private var isHovered = false
-
-    init() {
+    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
-        layer?.cornerRadius = 22
-
-        titleLabel.font = AppTheme.semiboldFont(size: 16)
+        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: "printer.fill", accessibilityDescription: "Print") {
-            let config = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
+        if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) {
+            let config = NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: .semibold)
             iconView.image = image.withSymbolConfiguration(config)
         }
         iconView.contentTintColor = .white
@@ -1146,14 +1430,14 @@ private final class DrawPrintPrintButton: NSControl, AppearanceRefreshable {
         addSubview(iconView)
 
         NSLayoutConstraint.activate([
-            heightAnchor.constraint(equalToConstant: 52),
-            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 28),
+            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: -28),
-            iconView.widthAnchor.constraint(equalToConstant: 20),
-            iconView.heightAnchor.constraint(equalToConstant: 20),
+            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
@@ -1187,8 +1471,13 @@ private final class DrawPrintPrintButton: NSControl, AppearanceRefreshable {
         }
     }
 
+    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 bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
+        guard !firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return }
         onClick?()
     }
 
@@ -1259,7 +1548,7 @@ private final class CanvasToolButton: NSControl, AppearanceRefreshable {
         self.tool = tool
         super.init(frame: .zero)
         wantsLayer = true
-        layer?.cornerRadius = 14
+        layer?.cornerRadius = 12
 
         let symbol: String
         switch tool {
@@ -1275,8 +1564,8 @@ private final class CanvasToolButton: NSControl, AppearanceRefreshable {
         addSubview(iconView)
 
         NSLayoutConstraint.activate([
-            widthAnchor.constraint(equalToConstant: 44),
-            heightAnchor.constraint(equalToConstant: 44),
+            widthAnchor.constraint(equalToConstant: 38),
+            heightAnchor.constraint(equalToConstant: 38),
             iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
             iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
         ])
@@ -1324,7 +1613,7 @@ private final class CanvasSubToolButton: NSControl, AppearanceRefreshable {
     init(symbolName: String) {
         super.init(frame: .zero)
         wantsLayer = true
-        layer?.cornerRadius = 16
+        layer?.cornerRadius = 14
 
         if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) {
             let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium)
@@ -1334,8 +1623,8 @@ private final class CanvasSubToolButton: NSControl, AppearanceRefreshable {
         addSubview(iconView)
 
         NSLayoutConstraint.activate([
-            widthAnchor.constraint(equalToConstant: 32),
-            heightAnchor.constraint(equalToConstant: 32),
+            widthAnchor.constraint(equalToConstant: 28),
+            heightAnchor.constraint(equalToConstant: 28),
             iconView.centerXAnchor.constraint(equalTo: centerXAnchor),
             iconView.centerYAnchor.constraint(equalTo: centerYAnchor),
         ])
@@ -1371,13 +1660,13 @@ private final class CanvasColorButton: NSControl, AppearanceRefreshable {
         self.swatchColor = color
         super.init(frame: .zero)
         wantsLayer = true
-        layer?.cornerRadius = 14
+        layer?.cornerRadius = 12
         layer?.borderWidth = 2
         layer?.borderColor = AppTheme.paywallBorder.cgColor
 
         NSLayoutConstraint.activate([
-            widthAnchor.constraint(equalToConstant: 28),
-            heightAnchor.constraint(equalToConstant: 28),
+            widthAnchor.constraint(equalToConstant: 24),
+            heightAnchor.constraint(equalToConstant: 24),
         ])
         refreshAppearance()
     }

+ 4 - 0
smart_printer/PrintTextView.swift

@@ -157,6 +157,10 @@ final class PrintTextOverlayView: NSView, AppearanceRefreshable {
         ])
     }
 
+    override func resetCursorRects() {
+        addCursorRect(textContainer.frame, cursor: .iBeam)
+    }
+
     private func printText() {
         PrintService.printText(textView.string, from: window)
     }