|
|
@@ -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
|