ソースを参照

Add corner resize handles for canvas text and images.

Lets users stretch or shrink placed content by dragging selection handles instead of relying only on toolbar font size controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 ヶ月 前
コミット
3726628c39
1 ファイル変更264 行追加1 行削除
  1. 264 1
      smart_printer/DrawPrintView.swift

+ 264 - 1
smart_printer/DrawPrintView.swift

@@ -338,7 +338,24 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
 
 // MARK: - Canvas View
 
+private enum CanvasResizeHandle {
+    case topLeft
+    case topRight
+    case bottomLeft
+    case bottomRight
+}
+
+private enum CanvasResizeTarget {
+    case text(UUID)
+    case image(UUID)
+}
+
 final class BlankCanvasView: NSView, AppearanceRefreshable {
+    private static let resizeHandleSize: CGFloat = 8
+    private static let resizeHandleHitPadding: CGFloat = 6
+    private static let minImageDimension: CGFloat = 24
+    private static let minTextFontSize: CGFloat = 8
+    private static let maxTextFontSize: CGFloat = 120
     override var isOpaque: Bool { true }
     override var mouseDownCanMoveWindow: Bool { false }
 
@@ -379,6 +396,13 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
     private var dragStartOrigin = NSPoint.zero
     private var textDragSnapshotPushed = false
     private var imageDragSnapshotPushed = false
+    private var resizingTextID: UUID?
+    private var resizingImageID: UUID?
+    private var activeResizeHandle: CanvasResizeHandle?
+    private var resizeStartPoint = NSPoint.zero
+    private var resizeStartRect = NSRect.zero
+    private var resizeStartFontSize: CGFloat = 18
+    private var resizeFixedCorner = NSPoint.zero
 
     private let undoButton = CanvasHistoryButton(symbolName: "arrow.uturn.backward", accessibilityLabel: "Undo")
     private let redoButton = CanvasHistoryButton(symbolName: "arrow.uturn.forward", accessibilityLabel: "Redo")
@@ -458,10 +482,16 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
                 return hit
             }
         }
+        if resizeHandle(at: point) != nil {
+            return self
+        }
         if (activeTool == .text || activeTool == .pointer),
            editingTextID == nil, textIndex(at: point) != nil {
             return self
         }
+        if (activeTool == .image || activeTool == .pointer), imageIndex(at: point) != nil {
+            return self
+        }
         return super.hitTest(point)
     }
 
@@ -506,6 +536,12 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
            item.id != editingTextID {
             drawTextSelection(for: item)
         }
+
+        if (activeTool == .image || activeTool == .pointer),
+           let selectedID = selectedImageID,
+           let item = imageItems.first(where: { $0.id == selectedID }) {
+            drawImageSelection(for: item)
+        }
     }
 
     private func drawTextItem(_ item: CanvasTextData) {
@@ -521,13 +557,38 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
     }
 
     private func drawTextSelection(for item: CanvasTextData) {
-        let rect = textBounds(for: item).insetBy(dx: -4, dy: -4)
+        let rect = selectionRect(for: textBounds(for: item))
+        drawSelectionChrome(in: rect)
+    }
+
+    private func drawImageSelection(for item: CanvasImageData) {
+        let rect = selectionRect(for: NSRect(origin: item.origin, size: item.size))
+        drawSelectionChrome(in: rect)
+    }
+
+    private func selectionRect(for contentRect: NSRect) -> NSRect {
+        contentRect.insetBy(dx: -4, dy: -4)
+    }
+
+    private func drawSelectionChrome(in rect: NSRect) {
         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()
+        drawResizeHandles(in: rect)
+    }
+
+    private func drawResizeHandles(in rect: NSRect) {
+        for handleRect in resizeHandleRects(for: rect).values {
+            NSColor.white.setFill()
+            NSBezierPath(roundedRect: handleRect, xRadius: 2, yRadius: 2).fill()
+            NSColor(calibratedWhite: 0.55, alpha: 1).setStroke()
+            let border = NSBezierPath(roundedRect: handleRect, xRadius: 2, yRadius: 2)
+            border.lineWidth = 1
+            border.stroke()
+        }
     }
 
     private func drawStroke(_ stroke: CanvasStroke, in context: CGContext?) {
@@ -552,6 +613,11 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
             return
         }
 
+        if let resize = resizeHandle(at: point) {
+            beginResize(resize, at: point)
+            return
+        }
+
         switch activeTool {
         case .pointer:
             if let index = textIndex(at: point) {
@@ -618,6 +684,21 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
     override func mouseDragged(with event: NSEvent) {
         let point = convert(event.locationInWindow, from: nil)
 
+        if let handle = activeResizeHandle {
+            if let id = resizingTextID,
+               let index = textItems.firstIndex(where: { $0.id == id }) {
+                applyTextResize(at: point, handle: handle, index: index)
+                needsDisplay = true
+                return
+            }
+            if let id = resizingImageID,
+               let index = imageItems.firstIndex(where: { $0.id == id }) {
+                applyImageResize(at: point, handle: handle, index: index)
+                needsDisplay = true
+                return
+            }
+        }
+
         if (activeTool == .text || activeTool == .pointer),
            let id = draggingTextID,
            let index = textItems.firstIndex(where: { $0.id == id }) {
@@ -661,6 +742,14 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
     }
 
     override func mouseUp(with event: NSEvent) {
+        if resizingTextID != nil || resizingImageID != nil {
+            resizingTextID = nil
+            resizingImageID = nil
+            activeResizeHandle = nil
+            notifyUndoState()
+            return
+        }
+
         if (activeTool == .text || activeTool == .pointer), draggingTextID != nil {
             draggingTextID = nil
             textDragSnapshotPushed = false
@@ -837,7 +926,12 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         imageItems = snapshot.images
         endEditingText()
         selectedTextID = nil
+        selectedImageID = nil
         draggingTextID = nil
+        draggingImageID = nil
+        resizingTextID = nil
+        resizingImageID = nil
+        activeResizeHandle = nil
         bringUndoButtonsToFront()
         needsDisplay = true
         notifyUndoState()
@@ -928,6 +1022,175 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
         }
         return nil
     }
+
+    private func resizeHandleRects(for rect: NSRect) -> [CanvasResizeHandle: NSRect] {
+        let half = Self.resizeHandleSize / 2
+        return [
+            .topLeft: NSRect(x: rect.minX - half, y: rect.maxY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
+            .topRight: NSRect(x: rect.maxX - half, y: rect.maxY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
+            .bottomLeft: NSRect(x: rect.minX - half, y: rect.minY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
+            .bottomRight: NSRect(x: rect.maxX - half, y: rect.minY - half, width: Self.resizeHandleSize, height: Self.resizeHandleSize),
+        ]
+    }
+
+    private func resizeHandle(at point: NSPoint) -> (CanvasResizeTarget, CanvasResizeHandle)? {
+        if (activeTool == .image || activeTool == .pointer),
+           let id = selectedImageID,
+           let item = imageItems.first(where: { $0.id == id }) {
+            let rect = selectionRect(for: NSRect(origin: item.origin, size: item.size))
+            if let handle = hitResizeHandle(at: point, in: rect) {
+                return (.image(id), handle)
+            }
+        }
+
+        if (activeTool == .text || activeTool == .pointer),
+           let id = selectedTextID,
+           id != editingTextID,
+           let item = textItems.first(where: { $0.id == id }) {
+            let rect = selectionRect(for: textBounds(for: item))
+            if let handle = hitResizeHandle(at: point, in: rect) {
+                return (.text(id), handle)
+            }
+        }
+
+        return nil
+    }
+
+    private func hitResizeHandle(at point: NSPoint, in rect: NSRect) -> CanvasResizeHandle? {
+        for (handle, handleRect) in resizeHandleRects(for: rect) {
+            let hitRect = handleRect.insetBy(dx: -Self.resizeHandleHitPadding, dy: -Self.resizeHandleHitPadding)
+            if hitRect.contains(point) {
+                return handle
+            }
+        }
+        return nil
+    }
+
+    private func fixedCorner(for rect: NSRect, handle: CanvasResizeHandle) -> NSPoint {
+        switch handle {
+        case .topLeft:
+            return NSPoint(x: rect.maxX, y: rect.minY)
+        case .topRight:
+            return NSPoint(x: rect.minX, y: rect.minY)
+        case .bottomLeft:
+            return NSPoint(x: rect.maxX, y: rect.maxY)
+        case .bottomRight:
+            return NSPoint(x: rect.minX, y: rect.maxY)
+        }
+    }
+
+    private func beginResize(_ target: (CanvasResizeTarget, CanvasResizeHandle), at point: NSPoint) {
+        let (resizeTarget, handle) = target
+        pushSnapshot()
+        activeResizeHandle = handle
+        resizeStartPoint = point
+
+        switch resizeTarget {
+        case .text(let id):
+            guard let item = textItems.first(where: { $0.id == id }) else { return }
+            resizingTextID = id
+            resizeStartRect = textBounds(for: item)
+            resizeStartFontSize = item.fontSize
+            resizeFixedCorner = fixedCorner(for: resizeStartRect, handle: handle)
+        case .image(let id):
+            guard let item = imageItems.first(where: { $0.id == id }) else { return }
+            resizingImageID = id
+            resizeStartRect = NSRect(origin: item.origin, size: item.size)
+            resizeFixedCorner = fixedCorner(for: resizeStartRect, handle: handle)
+        }
+    }
+
+    private func applyTextResize(at point: NSPoint, handle: CanvasResizeHandle, index: Int) {
+        let startDistance = hypot(resizeStartPoint.x - resizeFixedCorner.x, resizeStartPoint.y - resizeFixedCorner.y)
+        let newDistance = hypot(point.x - resizeFixedCorner.x, point.y - resizeFixedCorner.y)
+        guard startDistance > 1 else { return }
+
+        let scale = max(0.2, min(5, newDistance / startDistance))
+        let newFontSize = min(Self.maxTextFontSize, max(Self.minTextFontSize, resizeStartFontSize * scale))
+        textItems[index].fontSize = newFontSize
+
+        let bounds = textBounds(for: textItems[index])
+        var origin = NSPoint.zero
+        switch handle {
+        case .topLeft:
+            origin = NSPoint(x: resizeFixedCorner.x - bounds.width, y: resizeFixedCorner.y)
+        case .topRight:
+            origin = NSPoint(x: resizeFixedCorner.x, y: resizeFixedCorner.y)
+        case .bottomLeft:
+            origin = NSPoint(x: resizeFixedCorner.x - bounds.width, y: resizeFixedCorner.y - bounds.height)
+        case .bottomRight:
+            origin = NSPoint(x: resizeFixedCorner.x, y: resizeFixedCorner.y - bounds.height)
+        }
+        textItems[index].origin = clampedOrigin(origin, size: bounds.size)
+
+        if let field = textField(for: textItems[index].id) {
+            field.font = AppTheme.regularFont(size: newFontSize)
+            field.frame = textBounds(for: textItems[index])
+        }
+    }
+
+    private func applyImageResize(at point: NSPoint, handle: CanvasResizeHandle, index: Int) {
+        let dx = point.x - resizeStartPoint.x
+        let dy = point.y - resizeStartPoint.y
+        var rect = resizeStartRect
+
+        switch handle {
+        case .topLeft:
+            rect.origin.x += dx
+            rect.size.width -= dx
+            rect.size.height -= dy
+            rect.origin.y += dy
+        case .topRight:
+            rect.size.width += dx
+            rect.size.height -= dy
+            rect.origin.y += dy
+        case .bottomLeft:
+            rect.origin.x += dx
+            rect.size.width -= dx
+            rect.size.height += dy
+        case .bottomRight:
+            rect.size.width += dx
+            rect.size.height += dy
+        }
+
+        rect.size.width = max(Self.minImageDimension, rect.size.width)
+        rect.size.height = max(Self.minImageDimension, rect.size.height)
+
+        switch handle {
+        case .topLeft:
+            rect.origin.x = resizeFixedCorner.x - rect.size.width
+            rect.origin.y = resizeFixedCorner.y
+        case .topRight:
+            rect.origin.x = resizeFixedCorner.x
+            rect.origin.y = resizeFixedCorner.y
+        case .bottomLeft:
+            rect.origin.x = resizeFixedCorner.x - rect.size.width
+            rect.origin.y = resizeFixedCorner.y - rect.size.height
+        case .bottomRight:
+            rect.origin.x = resizeFixedCorner.x
+            rect.origin.y = resizeFixedCorner.y - rect.size.height
+        }
+
+        let clamped = clampedRect(rect)
+        imageItems[index].origin = clamped.origin
+        imageItems[index].size = clamped.size
+    }
+
+    private func clampedOrigin(_ origin: NSPoint, size: NSSize) -> NSPoint {
+        var clamped = origin
+        clamped.x = max(0, min(clamped.x, bounds.width - size.width))
+        clamped.y = max(0, min(clamped.y, bounds.height - size.height))
+        return clamped
+    }
+
+    private func clampedRect(_ rect: NSRect) -> NSRect {
+        var clamped = rect
+        clamped.size.width = max(Self.minImageDimension, min(clamped.size.width, bounds.width))
+        clamped.size.height = max(Self.minImageDimension, min(clamped.size.height, bounds.height))
+        clamped.origin.x = max(0, min(clamped.origin.x, bounds.width - clamped.size.width))
+        clamped.origin.y = max(0, min(clamped.origin.y, bounds.height - clamped.size.height))
+        return clamped
+    }
 }
 
 // MARK: - Tools Panel