Explorar el Código

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 hace 1 mes
padre
commit
c8efdf80a5
Se han modificado 2 ficheros con 400 adiciones y 48 borrados
  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 canvasContainer = NSView()
     private let canvasView = BlankCanvasView()
     private let canvasView = BlankCanvasView()
     private let toolsPanel = CanvasToolsPanel()
     private let toolsPanel = CanvasToolsPanel()
+    private let bottomButtonStack = NSStackView()
+    private let savePDFButton = DrawPrintSecondaryButton(title: "Save PDF", symbolName: "square.and.arrow.down")
     private let printButton = DrawPrintPrintButton()
     private let printButton = DrawPrintPrintButton()
 
 
     init() {
     init() {
@@ -94,6 +96,7 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         canvasContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
         canvasContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor
         canvasContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
         canvasContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor
         backButton.refreshAppearance()
         backButton.refreshAppearance()
+        savePDFButton.refreshAppearance()
         printButton.refreshAppearance()
         printButton.refreshAppearance()
         canvasView.refreshAppearance()
         canvasView.refreshAppearance()
         toolsPanel.refreshAppearance()
         toolsPanel.refreshAppearance()
@@ -147,9 +150,10 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         toolsPanel.translatesAutoresizingMaskIntoConstraints = false
         toolsPanel.translatesAutoresizingMaskIntoConstraints = false
 
 
         backButton.translatesAutoresizingMaskIntoConstraints = false
         backButton.translatesAutoresizingMaskIntoConstraints = false
-        printButton.translatesAutoresizingMaskIntoConstraints = false
+        bottomButtonStack.translatesAutoresizingMaskIntoConstraints = false
 
 
         backButton.onClick = { [weak self] in self?.dismiss() }
         backButton.onClick = { [weak self] in self?.dismiss() }
+        savePDFButton.onClick = { [weak self] in self?.saveCanvasPDF() }
         printButton.onClick = { [weak self] in self?.printCanvas() }
         printButton.onClick = { [weak self] in self?.printCanvas() }
 
 
         toolsPanel.onToolChanged = { [weak self] tool in
         toolsPanel.onToolChanged = { [weak self] tool in
@@ -176,6 +180,14 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         toolsPanel.onAddImage = { [weak self] in
         toolsPanel.onAddImage = { [weak self] in
             self?.pickImage()
             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)
         canvasContainer.addSubview(canvasView)
 
 
@@ -183,7 +195,7 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
         addSubview(titleLabel)
         addSubview(titleLabel)
         addSubview(canvasContainer)
         addSubview(canvasContainer)
         addSubview(toolsPanel)
         addSubview(toolsPanel)
-        addSubview(printButton)
+        addSubview(bottomButtonStack)
 
 
         NSLayoutConstraint.activate([
         NSLayoutConstraint.activate([
             backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
             backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
@@ -200,7 +212,7 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
                 constant: -(AppTheme.contentPanelInset + Self.toolsPanelWidth + Self.toolsPanelGap)
                 constant: -(AppTheme.contentPanelInset + Self.toolsPanelWidth + Self.toolsPanelGap)
             ),
             ),
             canvasContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28),
             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.leadingAnchor.constraint(equalTo: canvasContainer.trailingAnchor, constant: Self.toolsPanelGap),
             toolsPanel.centerYAnchor.constraint(equalTo: canvasContainer.centerYAnchor),
             toolsPanel.centerYAnchor.constraint(equalTo: canvasContainer.centerYAnchor),
@@ -211,13 +223,12 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
             canvasView.topAnchor.constraint(equalTo: canvasContainer.topAnchor),
             canvasView.topAnchor.constraint(equalTo: canvasContainer.topAnchor),
             canvasView.bottomAnchor.constraint(equalTo: canvasContainer.bottomAnchor),
             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()
         let panel = NSOpenPanel()
         panel.title = "Add Image"
         panel.title = "Add Image"
         panel.message = "Select an image to place on the canvas."
         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,
         guard panel.runModal() == .OK, let url = panel.url,
               let image = NSImage(contentsOf: url) else { return }
               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 {
         guard canvasView.hasContent else {
             PrintService.showEmptyCanvasAlert()
             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)
         PrintService.printCanvas(image, from: window)
     }
     }
+
+    private func saveCanvasPDF() {
+        guard let image = canvasSnapshot() else { return }
+        PrintService.saveCanvasPDF(image)
+    }
 }
 }
 
 
 // MARK: - Canvas View
 // MARK: - Canvas View
@@ -249,11 +269,17 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
     override var mouseDownCanMoveWindow: Bool { false }
     override var mouseDownCanMoveWindow: Bool { false }
 
 
     var activeTool: CanvasTool = .draw {
     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 drawColor: NSColor = NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1)
     var drawLineWidth: CGFloat = 3
     var drawLineWidth: CGFloat = 3
     var onUndoStateChanged: ((_ canUndo: Bool, _ canRedo: Bool) -> Void)?
     var onUndoStateChanged: ((_ canUndo: Bool, _ canRedo: Bool) -> Void)?
+    var onRequestAddImage: ((NSPoint) -> Void)?
 
 
     var hasContent: Bool {
     var hasContent: Bool {
         syncTextFromFields()
         syncTextFromFields()
@@ -269,6 +295,14 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
 
 
     private var currentStroke: CanvasStroke?
     private var currentStroke: CanvasStroke?
     private var selectedTextID: UUID?
     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 undoButton = CanvasHistoryButton(symbolName: "arrow.uturn.backward", accessibilityLabel: "Undo")
     private let redoButton = CanvasHistoryButton(symbolName: "arrow.uturn.forward", accessibilityLabel: "Redo")
     private let redoButton = CanvasHistoryButton(symbolName: "arrow.uturn.forward", accessibilityLabel: "Redo")
@@ -322,6 +356,9 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
                 return hit
                 return hit
             }
             }
         }
         }
+        if activeTool == .text, editingTextID == nil, textIndex(at: point) != nil {
+            return self
+        }
         return super.hitTest(point)
         return super.hitTest(point)
     }
     }
 
 
@@ -351,10 +388,42 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
             drawStroke(currentStroke, in: context)
             drawStroke(currentStroke, in: context)
         }
         }
 
 
+        for item in textItems where item.id != editingTextID {
+            drawTextItem(item)
+        }
+
         for item in imageItems {
         for item in imageItems {
             let rect = NSRect(origin: item.origin, size: item.size)
             let rect = NSRect(origin: item.origin, size: item.size)
             item.image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
             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?) {
     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)
             currentStroke = CanvasStroke(points: [point], color: drawColor, lineWidth: drawLineWidth)
             needsDisplay = true
             needsDisplay = true
         case .text:
         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 {
             } else {
                 addTextItem(at: point)
                 addTextItem(at: point)
             }
             }
         case .image:
         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) {
     override func mouseDragged(with event: NSEvent) {
-        guard activeTool == .draw, var stroke = currentStroke else { return }
         let point = convert(event.locationInWindow, from: nil)
         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)
         stroke.points.append(point)
         currentStroke = stroke
         currentStroke = stroke
         needsDisplay = true
         needsDisplay = true
     }
     }
 
 
     override func mouseUp(with event: NSEvent) {
     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 {
         guard activeTool == .draw, let stroke = currentStroke, stroke.points.count > 1 else {
             currentStroke = nil
             currentStroke = nil
             return
             return
@@ -417,33 +555,66 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
 
 
     func addTextItem(at origin: NSPoint? = nil) {
     func addTextItem(at origin: NSPoint? = nil) {
         pushSnapshot()
         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(
         let item = CanvasTextData(
             id: UUID(),
             id: UUID(),
             text: "Text",
             text: "Text",
-            origin: placement,
+            origin: clampedOrigin,
             fontSize: 18,
             fontSize: 18,
             color: .black,
             color: .black,
             alignment: .left
             alignment: .left
         )
         )
         textItems.append(item)
         textItems.append(item)
-        installTextField(for: item)
         selectedTextID = item.id
         selectedTextID = item.id
-        window?.makeFirstResponder(textField(for: item.id))
+        beginEditingText(id: item.id)
+        needsDisplay = true
         notifyUndoState()
         notifyUndoState()
     }
     }
 
 
-    func addImage(_ image: NSImage) {
+    func addImage(_ image: NSImage, at placement: NSPoint? = nil) {
         pushSnapshot()
         pushSnapshot()
         let maxSide: CGFloat = min(bounds.width, bounds.height) * 0.4
         let maxSide: CGFloat = min(bounds.width, bounds.height) * 0.4
         let imageSize = image.size
         let imageSize = image.size
         let scale = min(maxSide / max(imageSize.width, 1), maxSide / max(imageSize.height, 1))
         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 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
         needsDisplay = true
         notifyUndoState()
         notifyUndoState()
     }
     }
@@ -455,6 +626,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         if let field = textField(for: id) {
         if let field = textField(for: id) {
             field.alignment = alignment
             field.alignment = alignment
         }
         }
+        needsDisplay = true
         notifyUndoState()
         notifyUndoState()
     }
     }
 
 
@@ -464,7 +636,9 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         textItems[index].fontSize = size
         textItems[index].fontSize = size
         if let field = textField(for: id) {
         if let field = textField(for: id) {
             field.font = AppTheme.regularFont(size: size)
             field.font = AppTheme.regularFont(size: size)
+            field.frame = textBounds(for: textItems[index])
         }
         }
+        needsDisplay = true
         notifyUndoState()
         notifyUndoState()
     }
     }
 
 
@@ -475,6 +649,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         if let field = textField(for: id) {
         if let field = textField(for: id) {
             field.textColor = color
             field.textColor = color
         }
         }
+        needsDisplay = true
         notifyUndoState()
         notifyUndoState()
     }
     }
 
 
@@ -488,12 +663,11 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
     }
     }
 
 
     private func syncTextFromFields() {
     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() {
     private func pushSnapshot() {
@@ -522,10 +696,9 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         strokes = snapshot.strokes
         strokes = snapshot.strokes
         textItems = snapshot.texts
         textItems = snapshot.texts
         imageItems = snapshot.images
         imageItems = snapshot.images
-        removeTextFields()
-        for item in textItems {
-            installTextField(for: item)
-        }
+        endEditingText()
+        selectedTextID = nil
+        draggingTextID = nil
         bringUndoButtonsToFront()
         bringUndoButtonsToFront()
         needsDisplay = true
         needsDisplay = true
         notifyUndoState()
         notifyUndoState()
@@ -538,7 +711,12 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         updateUndoButtons(canUndo: canUndo, canRedo: canRedo)
         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)
         let field = NSTextField(string: item.text)
         field.identifier = NSUserInterfaceItemIdentifier(item.id.uuidString)
         field.identifier = NSUserInterfaceItemIdentifier(item.id.uuidString)
         field.isBordered = false
         field.isBordered = false
@@ -547,19 +725,30 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         field.font = AppTheme.regularFont(size: item.fontSize)
         field.font = AppTheme.regularFont(size: item.fontSize)
         field.textColor = item.color
         field.textColor = item.color
         field.alignment = item.alignment
         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.target = self
-        field.action = #selector(textFieldEdited(_:))
+        field.action = #selector(textFieldFinishedEditing(_:))
         addSubview(field)
         addSubview(field)
         bringUndoButtonsToFront()
         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,
         guard let idString = sender.identifier?.rawValue,
               let id = UUID(uuidString: idString),
               let id = UUID(uuidString: idString),
               let index = textItems.firstIndex(where: { $0.id == id }) else { return }
               let index = textItems.firstIndex(where: { $0.id == id }) else { return }
         textItems[index].text = sender.stringValue
         textItems[index].text = sender.stringValue
         textItems[index].origin = sender.frame.origin
         textItems[index].origin = sender.frame.origin
+        selectedTextID = id
+        endEditingText()
     }
     }
 
 
     private func textField(for id: UUID) -> NSTextField? {
     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() {
     private func removeTextFields() {
         subviews.compactMap { $0 as? NSTextField }.forEach { $0.removeFromSuperview() }
         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
 // MARK: - Tools Panel
@@ -605,6 +819,8 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
         NSColor.purple,
         NSColor.purple,
     ]
     ]
 
 
+    private static let fontSizeOptions: [CGFloat] = [12, 14, 16, 18, 20, 22, 24, 28, 32]
+
     init() {
     init() {
         super.init(frame: .zero)
         super.init(frame: .zero)
         setup()
         setup()
@@ -690,9 +906,7 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
             stack.addArrangedSubview(makeSubButton(symbol: "text.alignright", action: { [weak self] in
             stack.addArrangedSubview(makeSubButton(symbol: "text.alignright", action: { [weak self] in
                 self?.onTextAlignment?(.right)
                 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
             stack.addArrangedSubview(makeSubButton(symbol: "character.textbox", action: { [weak self] in
                 self?.onAddText?()
                 self?.onAddText?()
             }))
             }))
@@ -740,6 +954,26 @@ final class CanvasToolsPanel: NSView, AppearanceRefreshable {
         return button
         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() {
     private func pickTextColor() {
         let panel = NSColorPanel.shared
         let panel = NSColorPanel.shared
         panel.setTarget(self)
         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 {
 private final class DrawPrintPrintButton: NSControl, AppearanceRefreshable {
     var onClick: (() -> Void)?
     var onClick: (() -> Void)?
 
 

+ 34 - 0
smart_printer/PrintService.swift

@@ -24,6 +24,31 @@ enum PrintService {
         printImage(image, title: "Blank Canvas", from: window)
         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) {
     static func printText(_ text: String, title: String = "Print Text", from window: NSWindow? = nil) {
         let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
         let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
         guard !trimmed.isEmpty else {
         guard !trimmed.isEmpty else {
@@ -290,6 +315,15 @@ enum PrintService {
         alert.runModal()
         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() {
     static func showEmptyCanvasAlert() {
         let alert = NSAlert()
         let alert = NSAlert()
         alert.messageText = "Nothing to Print"
         alert.messageText = "Nothing to Print"