소스 검색

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 개월 전
부모
커밋
c8efdf80a5
2개의 변경된 파일400개의 추가작업 그리고 48개의 파일을 삭제
  1. 366 48
      smart_printer/DrawPrintView.swift
  2. 34 0
      smart_printer/PrintService.swift

+ 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"