Prechádzať zdrojové kódy

Improve draw canvas text and image placement, dragging, and PDF export.

Text now places and moves like images on the canvas, and users can save the canvas as a PDF from the editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 mesiac pred
rodič
commit
c8efdf80a5

+ 366 - 48
smart_printer/DrawPrintView.swift

@@ -62,6 +62,8 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
     private let canvasContainer = NSView()
     private let canvasView = BlankCanvasView()
     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() {
@@ -94,6 +96,7 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         canvasContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
         canvasContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
         backButton.refreshAppearance()
+        savePDFButton.refreshAppearance()
         printButton.refreshAppearance()
         canvasView.refreshAppearance()
         toolsPanel.refreshAppearance()
@@ -147,9 +150,10 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         toolsPanel.translatesAutoresizingMaskIntoConstraints = false
 
         backButton.translatesAutoresizingMaskIntoConstraints = false
-        printButton.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
@@ -176,6 +180,14 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         toolsPanel.onAddImage = { [weak self] in
             self?.pickImage()
         }
+        canvasView.onRequestAddImage = { [weak self] point in
+            self?.pickImage(at: point)
+        }
+
+        bottomButtonStack.orientation = .horizontal
+        bottomButtonStack.spacing = 12
+        bottomButtonStack.addArrangedSubview(savePDFButton)
+        bottomButtonStack.addArrangedSubview(printButton)
 
         canvasContainer.addSubview(canvasView)
 
@@ -183,7 +195,7 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         addSubview(titleLabel)
         addSubview(canvasContainer)
         addSubview(toolsPanel)
-        addSubview(printButton)
+        addSubview(bottomButtonStack)
 
         NSLayoutConstraint.activate([
             backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
@@ -200,7 +212,7 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
                 constant: -(AppTheme.contentPanelInset + Self.toolsPanelWidth + Self.toolsPanelGap)
             ),
             canvasContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
-            canvasContainer.bottomAnchor.constraint(equalTo: printButton.topAnchor, 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),
@@ -211,13 +223,12 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
             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),
+            bottomButtonStack.centerXAnchor.constraint(equalTo: centerXAnchor),
+            bottomButtonStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28),
         ])
     }
 
-    private func pickImage() {
+    private func pickImage(at placement: NSPoint? = nil) {
         let panel = NSOpenPanel()
         panel.title = "Add Image"
         panel.message = "Select an image to place on the canvas."
@@ -229,17 +240,26 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
 
         guard panel.runModal() == .OK, let url = panel.url,
               let image = NSImage(contentsOf: url) else { return }
-        canvasView.addImage(image)
+        canvasView.addImage(image, at: placement)
     }
 
-    private func printCanvas() {
+    private func canvasSnapshot() -> NSImage? {
         guard canvasView.hasContent else {
             PrintService.showEmptyCanvasAlert()
-            return
+            return nil
         }
-        guard let image = canvasView.renderSnapshot() else { return }
+        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
@@ -249,11 +269,17 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
     override var mouseDownCanMoveWindow: Bool { false }
 
     var activeTool: CanvasTool = .draw {
-        didSet { needsDisplay = true }
+        didSet {
+            if oldValue == .text, activeTool != .text {
+                endEditingText()
+            }
+            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 onRequestAddImage: ((NSPoint) -> Void)?
 
     var hasContent: Bool {
         syncTextFromFields()
@@ -269,6 +295,14 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
 
     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 let undoButton = CanvasHistoryButton(symbolName: "arrow.uturn.backward", accessibilityLabel: "Undo")
     private let redoButton = CanvasHistoryButton(symbolName: "arrow.uturn.forward", accessibilityLabel: "Redo")
@@ -322,6 +356,9 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
                 return hit
             }
         }
+        if activeTool == .text, editingTextID == nil, textIndex(at: point) != nil {
+            return self
+        }
         return super.hitTest(point)
     }
 
@@ -351,10 +388,42 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
             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, let selectedID = selectedTextID,
+           let item = textItems.first(where: { $0.id == selectedID }),
+           item.id != editingTextID {
+            drawTextSelection(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: AppTheme.regularFont(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 = textBounds(for: item).insetBy(dx: -4, dy: -4)
+        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()
     }
 
     private func drawStroke(_ stroke: CanvasStroke, in context: CGContext?) {
@@ -385,26 +454,95 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
             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))
+            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 {
                 addTextItem(at: point)
             }
         case .image:
-            break
+            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) {
-        guard activeTool == .draw, var stroke = currentStroke else { return }
         let point = convert(event.locationInWindow, from: nil)
+
+        if activeTool == .text,
+           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,
+           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 activeTool == .text, draggingTextID != nil {
+            draggingTextID = nil
+            textDragSnapshotPushed = false
+            notifyUndoState()
+            return
+        }
+
+        if activeTool == .image, draggingImageID != nil {
+            draggingImageID = nil
+            imageDragSnapshotPushed = false
+            notifyUndoState()
+            return
+        }
+
         guard activeTool == .draw, let stroke = currentStroke, stroke.points.count > 1 else {
             currentStroke = nil
             return
@@ -417,33 +555,66 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
 
     func addTextItem(at origin: NSPoint? = nil) {
         pushSnapshot()
-        let placement = origin ?? NSPoint(x: 24, y: bounds.height - 48)
+        let defaultSize = textBounds(
+            for: CanvasTextData(
+                id: UUID(),
+                text: "Text",
+                origin: .zero,
+                fontSize: 18,
+                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: placement,
+            origin: clampedOrigin,
             fontSize: 18,
             color: .black,
             alignment: .left
         )
         textItems.append(item)
-        installTextField(for: item)
         selectedTextID = item.id
-        window?.makeFirstResponder(textField(for: item.id))
+        beginEditingText(id: item.id)
+        needsDisplay = true
         notifyUndoState()
     }
 
-    func addImage(_ image: NSImage) {
+    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)
-        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))
+        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()
     }
@@ -455,6 +626,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         if let field = textField(for: id) {
             field.alignment = alignment
         }
+        needsDisplay = true
         notifyUndoState()
     }
 
@@ -464,7 +636,9 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         textItems[index].fontSize = size
         if let field = textField(for: id) {
             field.font = AppTheme.regularFont(size: size)
+            field.frame = textBounds(for: textItems[index])
         }
+        needsDisplay = true
         notifyUndoState()
     }
 
@@ -475,6 +649,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         if let field = textField(for: id) {
             field.textColor = color
         }
+        needsDisplay = true
         notifyUndoState()
     }
 
@@ -488,12 +663,11 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
     }
 
     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
-            }
-        }
+        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() {
@@ -522,10 +696,9 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         strokes = snapshot.strokes
         textItems = snapshot.texts
         imageItems = snapshot.images
-        removeTextFields()
-        for item in textItems {
-            installTextField(for: item)
-        }
+        endEditingText()
+        selectedTextID = nil
+        draggingTextID = nil
         bringUndoButtonsToFront()
         needsDisplay = true
         notifyUndoState()
@@ -538,7 +711,12 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         updateUndoButtons(canUndo: canUndo, canRedo: canRedo)
     }
 
-    private func installTextField(for item: CanvasTextData) {
+    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
@@ -547,19 +725,30 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         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.frame = textBounds(for: item)
         field.target = self
-        field.action = #selector(textFieldEdited(_:))
+        field.action = #selector(textFieldFinishedEditing(_:))
         addSubview(field)
         bringUndoButtonsToFront()
+        window?.makeFirstResponder(field)
+        needsDisplay = true
     }
 
-    @objc private func textFieldEdited(_ sender: NSTextField) {
+    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? {
@@ -568,13 +757,38 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         }
     }
 
-    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() }
     }
+
+    private func textBounds(for item: CanvasTextData) -> NSRect {
+        let attributes: [NSAttributedString.Key: Any] = [
+            .font: AppTheme.regularFont(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
+    }
 }
 
 // MARK: - Tools Panel
@@ -605,6 +819,8 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
         NSColor.purple,
     ]
 
+    private static let fontSizeOptions: [CGFloat] = [12, 14, 16, 18, 20, 22, 24, 28, 32]
+
     init() {
         super.init(frame: .zero)
         setup()
@@ -690,9 +906,7 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
             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(makeFontSizePopup())
             stack.addArrangedSubview(makeSubButton(symbol: "character.textbox", action: { [weak self] in
                 self?.onAddText?()
             }))
@@ -740,6 +954,26 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
         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)
@@ -800,6 +1034,90 @@ private final class DrawPrintToolbarButton: NSControl, AppearanceRefreshable {
     }
 }
 
+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
+
+    init(title: String, symbolName: String) {
+        self.title = title
+        super.init(frame: .zero)
+        wantsLayer = true
+        layer?.cornerRadius = 22
+        layer?.borderWidth = 1.5
+
+        titleLabel.stringValue = title
+        titleLabel.font = AppTheme.semiboldFont(size: 16)
+        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)
+            iconView.image = image.withSymbolConfiguration(config)
+        }
+        iconView.translatesAutoresizingMaskIntoConstraints = false
+
+        addSubview(titleLabel)
+        addSubview(iconView)
+
+        NSLayoutConstraint.activate([
+            heightAnchor.constraint(equalToConstant: 52),
+            widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
+            titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
+            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),
+        ])
+
+        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 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)?
 

+ 34 - 0
smart_printer/PrintService.swift

@@ -24,6 +24,31 @@ enum PrintService {
         printImage(image, title: "Blank Canvas", from: window)
     }
 
+    static func saveCanvasPDF(_ image: NSImage) {
+        guard image.size.width > 0, image.size.height > 0 else {
+            showEmptyCanvasAlert()
+            return
+        }
+        guard let document = pdfDocument(from: image) else {
+            showSaveFailedAlert()
+            return
+        }
+
+        let panel = NSSavePanel()
+        panel.title = "Save as PDF"
+        panel.message = "Choose where to save your canvas."
+        panel.prompt = "Save"
+        panel.nameFieldStringValue = "Blank Canvas.pdf"
+        panel.allowedContentTypes = [.pdf]
+        panel.canCreateDirectories = true
+
+        guard panel.runModal() == .OK, let url = panel.url else { return }
+        guard document.write(to: url) else {
+            showSaveFailedAlert()
+            return
+        }
+    }
+
     static func printText(_ text: String, title: String = "Print Text", from window: NSWindow? = nil) {
         let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !trimmed.isEmpty else {
@@ -290,6 +315,15 @@ enum PrintService {
         alert.runModal()
     }
 
+    private static func showSaveFailedAlert() {
+        let alert = NSAlert()
+        alert.messageText = "Save Failed"
+        alert.informativeText = "The canvas could not be saved as a PDF."
+        alert.alertStyle = .warning
+        alert.addButton(withTitle: "OK")
+        alert.runModal()
+    }
+
     static func showEmptyCanvasAlert() {
         let alert = NSAlert()
         alert.messageText = "Nothing to Print"