소스 검색

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