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