import Cocoa import UniformTypeIdentifiers // MARK: - Service enum DrawPrintService { static func present(from window: NSWindow?) { let hostWindow = window ?? NSApp.keyWindow guard PremiumAccess.require(feature: .drawPrint, from: hostWindow) else { return } guard let viewController = hostWindow?.contentViewController as? ViewController else { return } viewController.presentDrawPrint() } } // MARK: - Canvas Model enum CanvasTool { case pointer case text case image case draw static let toolbarTools: [CanvasTool] = [.text, .image, .draw] } struct CanvasStroke { var points: [NSPoint] var color: NSColor var lineWidth: CGFloat } enum CanvasTextFontStyle: String, CaseIterable { case regular = "Regular" case bold = "Bold" case italic = "Italic" case boldItalic = "Bold Italic" case serif = "Serif" case monospace = "Monospace" func font(size: CGFloat) -> NSFont { switch self { case .regular: return AppTheme.regularFont(size: size) case .bold: return .systemFont(ofSize: size, weight: .bold) case .italic: let base = AppTheme.regularFont(size: size) return NSFontManager.shared.convert(base, toHaveTrait: .italicFontMask) ?? base case .boldItalic: let bold = NSFont.systemFont(ofSize: size, weight: .bold) return NSFontManager.shared.convert(bold, toHaveTrait: .italicFontMask) ?? bold case .serif: return NSFont(name: "Times New Roman", size: size) ?? NSFont(name: "Times-Roman", size: size) ?? .systemFont(ofSize: size) case .monospace: return .monospacedSystemFont(ofSize: size, weight: .regular) } } } struct CanvasTextData { var id: UUID var text: String var origin: NSPoint var fontSize: CGFloat var fontStyle: CanvasTextFontStyle var color: NSColor var alignment: NSTextAlignment } struct CanvasImageData { var id: UUID var origin: NSPoint var size: NSSize var image: NSImage } struct CanvasSnapshot { var strokes: [CanvasStroke] var texts: [CanvasTextData] var images: [CanvasImageData] } // MARK: - Overlay final class DrawPrintOverlayView: NSView, AppearanceRefreshable { private static let toolsPanelWidth: CGFloat = 48 private static let toolsPanelGap: CGFloat = 12 var onDismiss: (() -> Void)? override var mouseDownCanMoveWindow: Bool { false } private let backButton = DrawPrintToolbarButton(symbolName: "chevron.left", accessibilityLabel: "Back") private let titleLabel = NSTextField(labelWithString: "Blank Canvas") private let canvasContainer = NSView() private let canvasView = BlankCanvasView() private let addTextPromptView = AddTextPromptView() private var pendingTextPlacement: NSPoint? private var suppressCanvasTextPrompt = false private let toolsPanel = CanvasToolsPanel() private let bottomButtonStack = NSStackView() private let savePDFButton = DrawPrintSecondaryButton(title: "Save PDF", symbolName: "square.and.arrow.down") private let printButton = DrawPrintPrintButton() init() { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false setup() refreshAppearance() NotificationCenter.default.addObserver( self, selector: #selector(appearanceDidChange), name: .appearanceDidChange, object: nil ) } deinit { NotificationCenter.default.removeObserver(self) } @objc private func appearanceDidChange() { refreshAppearance() } @available(*, unavailable) required init?(coder: NSCoder) { nil } func refreshAppearance() { layer?.backgroundColor = AppTheme.background.cgColor titleLabel.textColor = AppTheme.textPrimary canvasContainer.layer?.backgroundColor = AppTheme.cardBackground.cgColor canvasContainer.layer?.borderColor = AppTheme.paywallBorder.cgColor backButton.refreshAppearance() savePDFButton.refreshAppearance() printButton.refreshAppearance() canvasView.refreshAppearance() toolsPanel.refreshAppearance() addTextPromptView.refreshAppearance() } func present(in parent: NSView) { guard superview == nil else { return } parent.addSubview(self) NSLayoutConstraint.activate([ leadingAnchor.constraint(equalTo: parent.leadingAnchor), trailingAnchor.constraint(equalTo: parent.trailingAnchor), topAnchor.constraint(equalTo: parent.topAnchor), bottomAnchor.constraint(equalTo: parent.bottomAnchor), ]) alphaValue = 0 NSAnimationContext.runAnimationGroup { context in context.duration = 0.2 animator().alphaValue = 1 } } func dismiss(animated: Bool = true) { let remove = { [weak self] in self?.removeFromSuperview() self?.onDismiss?() } guard animated else { remove() return } NSAnimationContext.runAnimationGroup({ context in context.duration = 0.15 animator().alphaValue = 0 }, completionHandler: remove) } private func setup() { wantsLayer = true titleLabel.font = AppTheme.semiboldFont(size: 18) titleLabel.alignment = .center titleLabel.translatesAutoresizingMaskIntoConstraints = false canvasContainer.wantsLayer = true canvasContainer.layer?.cornerRadius = AppTheme.contentPanelCornerRadius canvasContainer.layer?.borderWidth = 1.5 canvasContainer.applyCardShadow() canvasContainer.translatesAutoresizingMaskIntoConstraints = false canvasView.translatesAutoresizingMaskIntoConstraints = false toolsPanel.translatesAutoresizingMaskIntoConstraints = false backButton.translatesAutoresizingMaskIntoConstraints = false bottomButtonStack.translatesAutoresizingMaskIntoConstraints = false backButton.onClick = { [weak self] in self?.dismiss() } savePDFButton.onClick = { [weak self] in self?.saveCanvasPDF() } printButton.onClick = { [weak self] in self?.printCanvas() } toolsPanel.onToolChanged = { [weak self] tool in self?.canvasView.activeTool = tool } toolsPanel.onAddText = { [weak self] in self?.pendingTextPlacement = nil self?.presentAddTextDialog() } toolsPanel.onTextAlignment = { [weak self] alignment in self?.canvasView.setSelectedTextAlignment(alignment) } toolsPanel.onTextFontSize = { [weak self] size in self?.canvasView.setSelectedTextFontSize(size) } toolsPanel.onTextColor = { [weak self] color in self?.canvasView.setSelectedTextColor(color) } toolsPanel.onDrawColor = { [weak self] color in self?.canvasView.drawColor = color } toolsPanel.onDrawLineWidth = { [weak self] width in self?.canvasView.drawLineWidth = width } canvasView.onRequestAddImage = { [weak self] point in self?.pickImage(at: point) } canvasView.onRequestAddText = { [weak self] point in guard let self, !self.suppressCanvasTextPrompt else { return } self.pendingTextPlacement = point self.presentAddTextDialog() } bottomButtonStack.orientation = .horizontal bottomButtonStack.spacing = 12 bottomButtonStack.addArrangedSubview(savePDFButton) bottomButtonStack.addArrangedSubview(printButton) canvasContainer.addSubview(canvasView) addSubview(backButton) addSubview(titleLabel) addSubview(canvasContainer) addSubview(toolsPanel) addSubview(bottomButtonStack) addSubview(addTextPromptView) NSLayoutConstraint.activate([ backButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24), backButton.topAnchor.constraint(equalTo: topAnchor, constant: 20), backButton.widthAnchor.constraint(equalToConstant: 40), backButton.heightAnchor.constraint(equalToConstant: 40), titleLabel.centerXAnchor.constraint(equalTo: centerXAnchor), titleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor), canvasContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: AppTheme.contentPanelInset), canvasContainer.trailingAnchor.constraint( equalTo: trailingAnchor, constant: -(AppTheme.contentPanelInset + Self.toolsPanelWidth + Self.toolsPanelGap) ), canvasContainer.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28), canvasContainer.bottomAnchor.constraint(equalTo: bottomButtonStack.topAnchor, constant: -28), toolsPanel.leadingAnchor.constraint(equalTo: canvasContainer.trailingAnchor, constant: Self.toolsPanelGap), toolsPanel.centerYAnchor.constraint(equalTo: canvasContainer.centerYAnchor), toolsPanel.widthAnchor.constraint(equalToConstant: Self.toolsPanelWidth), canvasView.leadingAnchor.constraint(equalTo: canvasContainer.leadingAnchor), canvasView.trailingAnchor.constraint(equalTo: canvasContainer.trailingAnchor), canvasView.topAnchor.constraint(equalTo: canvasContainer.topAnchor), canvasView.bottomAnchor.constraint(equalTo: canvasContainer.bottomAnchor), bottomButtonStack.centerXAnchor.constraint(equalTo: centerXAnchor), bottomButtonStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -28), addTextPromptView.leadingAnchor.constraint(equalTo: leadingAnchor), addTextPromptView.trailingAnchor.constraint(equalTo: trailingAnchor), addTextPromptView.topAnchor.constraint(equalTo: topAnchor), addTextPromptView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) addTextPromptView.isHidden = true addTextPromptView.alphaValue = 0 addTextPromptView.onAdd = { [weak self] result in self?.commitAddedText(result) } addTextPromptView.onCancel = { [weak self] in self?.pendingTextPlacement = nil self?.hideAddTextPrompt() } } private func pickImage(at placement: NSPoint? = nil) { let panel = NSOpenPanel() panel.title = "Add Image" panel.message = "Select an image to place on the canvas." panel.prompt = "Add" panel.canChooseDirectories = false panel.canChooseFiles = true panel.allowsMultipleSelection = false panel.allowedContentTypes = [.image, .png, .jpeg, .gif, .tiff, .bmp, .heic] guard panel.runModal() == .OK, let url = panel.url, let image = NSImage(contentsOf: url) else { return } canvasView.addImage(image, at: placement) toolsPanel.setActiveTool(.pointer) } private func commitAddedText(_ result: AddTextResult) { let placement = pendingTextPlacement pendingTextPlacement = nil suppressCanvasTextPrompt = true canvasView.stopInlineTextEditing() layoutSubtreeIfNeeded() canvasContainer.layoutSubtreeIfNeeded() canvasView.layoutSubtreeIfNeeded() canvasView.addTextItem( text: result.text, fontSize: result.fontSize, fontStyle: result.fontStyle, at: placement ) toolsPanel.setActiveTool(.pointer) DispatchQueue.main.async { [weak self] in self?.hideAddTextPrompt(animated: false) DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { self?.suppressCanvasTextPrompt = false } } } private func presentAddTextDialog() { addSubview(addTextPromptView, positioned: .above, relativeTo: nil) addTextPromptView.prepareForPresentation() addTextPromptView.isHidden = false NSAnimationContext.runAnimationGroup { context in context.duration = 0.18 addTextPromptView.animator().alphaValue = 1 } completionHandler: { [weak self] in self?.addTextPromptView.focusInput() } } private func hideAddTextPrompt(animated: Bool = true) { let hide = { [weak self] in self?.addTextPromptView.alphaValue = 0 self?.addTextPromptView.isHidden = true } guard animated else { hide() return } NSAnimationContext.runAnimationGroup({ context in context.duration = 0.14 addTextPromptView.animator().alphaValue = 0 }, completionHandler: hide) } private func canvasSnapshot() -> NSImage? { guard canvasView.hasContent else { PrintService.showEmptyCanvasAlert() return nil } return canvasView.renderSnapshot() } private func printCanvas() { guard let image = canvasSnapshot() else { return } PrintService.printCanvas(image, from: window) } private func saveCanvasPDF() { guard let image = canvasSnapshot() else { return } PrintService.saveCanvasPDF(image) } } // 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 } var activeTool: CanvasTool = .draw { didSet { if oldValue == .text, activeTool != .text { endEditingText() } updateCursorForActiveTool() needsDisplay = true } } var drawColor: NSColor = NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1) var drawLineWidth: CGFloat = 2 var onUndoStateChanged: ((_ canUndo: Bool, _ canRedo: Bool) -> Void)? var onRequestAddImage: ((NSPoint) -> Void)? var onRequestAddText: ((NSPoint) -> Void)? var hasContent: Bool { syncTextFromFields() let hasText = textItems.contains { !$0.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } return !strokes.isEmpty || !imageItems.isEmpty || hasText } private var strokes: [CanvasStroke] = [] private var textItems: [CanvasTextData] = [] private var imageItems: [CanvasImageData] = [] private var undoStack: [CanvasSnapshot] = [] private var redoStack: [CanvasSnapshot] = [] private var currentStroke: CanvasStroke? 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 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") init() { super.init(frame: .zero) setup() refreshAppearance() notifyUndoState() } @available(*, unavailable) required init?(coder: NSCoder) { nil } func refreshAppearance() { undoButton.refreshAppearance() redoButton.refreshAppearance() } func stopInlineTextEditing() { endEditingText() } private func setup() { wantsLayer = true layerContentsRedrawPolicy = .onSetNeedsDisplay layer?.cornerRadius = AppTheme.contentPanelCornerRadius layer?.masksToBounds = true undoButton.translatesAutoresizingMaskIntoConstraints = false redoButton.translatesAutoresizingMaskIntoConstraints = false undoButton.onClick = { [weak self] in self?.undo() } redoButton.onClick = { [weak self] in self?.redo() } addSubview(undoButton) addSubview(redoButton) NSLayoutConstraint.activate([ undoButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), undoButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12), undoButton.widthAnchor.constraint(equalToConstant: 32), undoButton.heightAnchor.constraint(equalToConstant: 32), redoButton.leadingAnchor.constraint(equalTo: undoButton.trailingAnchor, constant: 8), redoButton.centerYAnchor.constraint(equalTo: undoButton.centerYAnchor), redoButton.widthAnchor.constraint(equalToConstant: 32), redoButton.heightAnchor.constraint(equalToConstant: 32), ]) updateCursorForActiveTool() } private func updateCursorForActiveTool() { window?.invalidateCursorRects(for: self) } override func resetCursorRects() { let cursor: NSCursor switch activeTool { case .pointer: cursor = .arrow case .draw: cursor = .crosshair case .text: cursor = .iBeam case .image: cursor = .pointingHand } addCursorRect(bounds, cursor: cursor) } override func hitTest(_ point: NSPoint) -> NSView? { for button in [redoButton, undoButton] { let localPoint = convert(point, to: button) if let hit = button.hitTest(localPoint) { 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) } private func bringUndoButtonsToFront() { addSubview(redoButton, positioned: .above, relativeTo: nil) addSubview(undoButton, positioned: .below, relativeTo: redoButton) } private func updateUndoButtons(canUndo: Bool, canRedo: Bool) { undoButton.setActionEnabled(canUndo) redoButton.setActionEnabled(canRedo) } override func draw(_ dirtyRect: NSRect) { NSColor.white.setFill() NSBezierPath( roundedRect: bounds, xRadius: AppTheme.contentPanelCornerRadius, yRadius: AppTheme.contentPanelCornerRadius ).fill() let context = NSGraphicsContext.current?.cgContext for stroke in strokes { drawStroke(stroke, in: context) } if let currentStroke { drawStroke(currentStroke, in: context) } for item in textItems where item.id != editingTextID { drawTextItem(item) } for item in imageItems { let rect = NSRect(origin: item.origin, size: item.size) item.image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1) } if (activeTool == .text || activeTool == .pointer), let selectedID = selectedTextID, let item = textItems.first(where: { $0.id == selectedID }), 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) { let rect = textBounds(for: item) let paragraph = NSMutableParagraphStyle() paragraph.alignment = item.alignment let attributes: [NSAttributedString.Key: Any] = [ .font: item.fontStyle.font(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 = 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?) { guard stroke.points.count > 1, let context else { return } context.saveGState() context.setStrokeColor(stroke.color.cgColor) context.setLineWidth(stroke.lineWidth) context.setLineCap(.round) context.setLineJoin(.round) context.beginPath() context.move(to: stroke.points[0]) for point in stroke.points.dropFirst() { context.addLine(to: point) } context.strokePath() context.restoreGState() } override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) if undoButton.frame.contains(point) || redoButton.frame.contains(point) { return } if let resize = resizeHandle(at: point) { beginResize(resize, at: point) return } switch activeTool { case .pointer: if let index = textIndex(at: point) { let id = textItems[index].id selectedTextID = id selectedImageID = nil if event.clickCount >= 2 { beginEditingText(id: id) } else { draggingTextID = id dragStartPoint = point dragStartOrigin = textItems[index].origin textDragSnapshotPushed = false } } else if let index = imageIndex(at: point) { selectedImageID = imageItems[index].id selectedTextID = nil draggingImageID = imageItems[index].id dragStartPoint = point dragStartOrigin = imageItems[index].origin imageDragSnapshotPushed = false } else { selectedTextID = nil selectedImageID = nil if editingTextID != nil { endEditingText() } needsDisplay = true } case .draw: pushSnapshot() currentStroke = CanvasStroke(points: [point], color: drawColor, lineWidth: drawLineWidth) needsDisplay = true case .text: 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 { onRequestAddText?(point) } case .image: 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) { 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 }) { 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 || activeTool == .pointer), 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) currentStroke = stroke needsDisplay = true } 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 notifyUndoState() return } if (activeTool == .image || activeTool == .pointer), draggingImageID != nil { draggingImageID = nil imageDragSnapshotPushed = false notifyUndoState() return } guard activeTool == .draw, let stroke = currentStroke, stroke.points.count > 1 else { currentStroke = nil return } strokes.append(stroke) currentStroke = nil needsDisplay = true notifyUndoState() } func addTextItem( text: String = "Text", fontSize: CGFloat = 18, fontStyle: CanvasTextFontStyle = .regular, at origin: NSPoint? = nil ) { guard bounds.width > 1, bounds.height > 1 else { DispatchQueue.main.async { [weak self] in self?.addTextItem(text: text, fontSize: fontSize, fontStyle: fontStyle, at: origin) } return } pushSnapshot() let defaultSize = textBounds( for: CanvasTextData( id: UUID(), text: text, origin: .zero, fontSize: fontSize, fontStyle: fontStyle, 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( id: UUID(), text: text, origin: clampedOrigin, fontSize: fontSize, fontStyle: fontStyle, color: .black, alignment: .left ) textItems.append(item) selectedTextID = item.id setNeedsDisplay(bounds) displayIfNeeded() notifyUndoState() } func addImage(_ image: NSImage, at placement: NSPoint? = nil) { pushSnapshot() let maxSide: CGFloat = min(bounds.width, bounds.height) * 0.4 let imageSize = image.size let scale = min(maxSide / max(imageSize.width, 1), maxSide / max(imageSize.height, 1)) let size = NSSize(width: imageSize.width * scale, height: imageSize.height * scale) 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 notifyUndoState() } func setSelectedTextAlignment(_ alignment: NSTextAlignment) { guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return } pushSnapshot() textItems[index].alignment = alignment if let field = textField(for: id) { field.alignment = alignment } needsDisplay = true notifyUndoState() } func setSelectedTextFontSize(_ size: CGFloat) { guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return } pushSnapshot() textItems[index].fontSize = size if let field = textField(for: id) { field.font = textItems[index].fontStyle.font(size: size) field.frame = textBounds(for: textItems[index]) } needsDisplay = true notifyUndoState() } func setSelectedTextColor(_ color: NSColor) { guard let id = selectedTextID, let index = textItems.firstIndex(where: { $0.id == id }) else { return } pushSnapshot() textItems[index].color = color if let field = textField(for: id) { field.textColor = color } needsDisplay = true notifyUndoState() } func renderSnapshot() -> NSImage? { syncTextFromFields() guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil } cacheDisplay(in: bounds, to: rep) let image = NSImage(size: bounds.size) image.addRepresentation(rep) return image } private func syncTextFromFields() { 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() { syncTextFromFields() undoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems)) redoStack.removeAll() if undoStack.count > 50 { undoStack.removeFirst() } notifyUndoState() } private func undo() { guard let snapshot = undoStack.popLast() else { return } syncTextFromFields() redoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems)) restore(snapshot) } private func redo() { guard let snapshot = redoStack.popLast() else { return } syncTextFromFields() undoStack.append(CanvasSnapshot(strokes: strokes, texts: textItems, images: imageItems)) restore(snapshot) } private func restore(_ snapshot: CanvasSnapshot) { strokes = snapshot.strokes textItems = snapshot.texts imageItems = snapshot.images endEditingText() selectedTextID = nil selectedImageID = nil draggingTextID = nil draggingImageID = nil resizingTextID = nil resizingImageID = nil activeResizeHandle = nil bringUndoButtonsToFront() needsDisplay = true notifyUndoState() } private func notifyUndoState() { let canUndo = !undoStack.isEmpty let canRedo = !redoStack.isEmpty onUndoStateChanged?(canUndo, canRedo) updateUndoButtons(canUndo: canUndo, canRedo: canRedo) } 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) field.identifier = NSUserInterfaceItemIdentifier(item.id.uuidString) field.isBordered = false field.isBezeled = false field.drawsBackground = false field.font = item.fontStyle.font(size: item.fontSize) field.textColor = item.color field.alignment = item.alignment field.frame = textBounds(for: item) field.target = self field.action = #selector(textFieldFinishedEditing(_:)) addSubview(field) bringUndoButtonsToFront() window?.makeFirstResponder(field) needsDisplay = true } private func endEditingText() { syncTextFromFields() removeTextFields() editingTextID = nil needsDisplay = true } @objc private func textFieldFinishedEditing(_ sender: NSTextField) { guard let idString = sender.identifier?.rawValue, let id = UUID(uuidString: idString), let index = textItems.firstIndex(where: { $0.id == id }) else { return } textItems[index].text = sender.stringValue textItems[index].origin = sender.frame.origin selectedTextID = id endEditingText() } private func textField(for id: UUID) -> NSTextField? { subviews.compactMap { $0 as? NSTextField }.first { $0.identifier?.rawValue == id.uuidString } } private func removeTextFields() { subviews.compactMap { $0 as? NSTextField }.forEach { $0.removeFromSuperview() } } private func textBounds(for item: CanvasTextData) -> NSRect { let attributes: [NSAttributedString.Key: Any] = [ .font: item.fontStyle.font(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 } 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 = textItems[index].fontStyle.font(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 final class CanvasToolsPanel: NSView, AppearanceRefreshable { var onToolChanged: ((CanvasTool) -> Void)? var onAddText: (() -> Void)? var onTextAlignment: ((NSTextAlignment) -> Void)? var onTextFontSize: ((CGFloat) -> Void)? var onTextColor: ((NSColor) -> Void)? var onDrawColor: ((NSColor) -> Void)? var onDrawLineWidth: ((CGFloat) -> Void)? private var activeTool: CanvasTool = .draw private let toolStack = NSStackView() private let subToolbar = NSView() private var toolButtons: [CanvasTool: CanvasToolButton] = [:] private var subToolbarHeight: NSLayoutConstraint? private var subToolbarWidth: NSLayoutConstraint? private let drawColors: [NSColor] = [ NSColor(red: 0.13, green: 0.68, blue: 0.42, alpha: 1), NSColor(red: 0.22, green: 0.47, blue: 0.96, alpha: 1), NSColor.black, NSColor.red, NSColor.orange, NSColor.purple, ] private static let fontSizeOptions: [CGFloat] = [12, 14, 16, 18, 20, 22, 24, 28, 32] init() { super.init(frame: .zero) setup() refreshAppearance() selectTool(.draw) } @available(*, unavailable) required init?(coder: NSCoder) { nil } func setActiveTool(_ tool: CanvasTool) { selectTool(tool) } func refreshAppearance() { layer?.backgroundColor = AppTheme.cardBackground.cgColor layer?.borderColor = AppTheme.paywallBorder.cgColor layer?.borderWidth = 1 subToolbar.layer?.backgroundColor = AppTheme.elevatedBackground.cgColor subToolbar.layer?.borderColor = AppTheme.paywallBorder.cgColor subToolbar.layer?.borderWidth = 1 toolButtons.values.forEach { $0.refreshAppearance() } rebuildSubToolbar() } private func setup() { wantsLayer = true layer?.cornerRadius = 28 toolStack.orientation = .vertical toolStack.spacing = 4 toolStack.translatesAutoresizingMaskIntoConstraints = false subToolbar.wantsLayer = true subToolbar.layer?.cornerRadius = 20 subToolbar.translatesAutoresizingMaskIntoConstraints = false for tool in CanvasTool.toolbarTools { let button = CanvasToolButton(tool: tool) button.onClick = { [weak self] in guard let self else { return } if self.activeTool == tool { self.selectTool(.pointer) } else { self.selectTool(tool) } } toolButtons[tool] = button toolStack.addArrangedSubview(button) } addSubview(subToolbar) addSubview(toolStack) subToolbarHeight = subToolbar.heightAnchor.constraint(equalToConstant: 44) subToolbarWidth = subToolbar.widthAnchor.constraint(equalToConstant: 120) NSLayoutConstraint.activate([ toolStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 6), toolStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), toolStack.topAnchor.constraint(equalTo: topAnchor, constant: 8), toolStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), subToolbar.trailingAnchor.constraint(equalTo: leadingAnchor, constant: -8), subToolbar.centerYAnchor.constraint(equalTo: toolStack.arrangedSubviews.first?.centerYAnchor ?? centerYAnchor), subToolbarHeight!, subToolbarWidth!, ]) } private func selectTool(_ tool: CanvasTool) { activeTool = tool if tool == .pointer { toolButtons.values.forEach { $0.setSelected(false) } } else { toolButtons.forEach { $0.value.setSelected($0.key == tool) } } onToolChanged?(tool) rebuildSubToolbar() } private func rebuildSubToolbar() { subToolbar.subviews.forEach { $0.removeFromSuperview() } let stack = NSStackView() stack.orientation = .horizontal stack.spacing = 6 stack.translatesAutoresizingMaskIntoConstraints = false switch activeTool { case .pointer: break case .text: stack.addArrangedSubview(makeSubButton(symbol: "text.alignleft", action: { [weak self] in self?.onTextAlignment?(.left) })) stack.addArrangedSubview(makeSubButton(symbol: "text.aligncenter", action: { [weak self] in self?.onTextAlignment?(.center) })) stack.addArrangedSubview(makeSubButton(symbol: "text.alignright", action: { [weak self] in self?.onTextAlignment?(.right) })) stack.addArrangedSubview(makeFontSizePopup()) stack.addArrangedSubview(makeSubButton(symbol: "character.textbox", action: { [weak self] in self?.onAddText?() })) stack.addArrangedSubview(makeColorButton(color: .black, action: { [weak self] in self?.pickTextColor() })) case .image: break case .draw: for color in drawColors { stack.addArrangedSubview(makeColorButton(color: color, action: { [weak self] in self?.onDrawColor?(color) })) } stack.addArrangedSubview(makeSubButton(symbol: "lineweight", action: { [weak self] in self?.onDrawLineWidth?(3) })) } subToolbar.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: subToolbar.leadingAnchor, constant: 10), stack.trailingAnchor.constraint(equalTo: subToolbar.trailingAnchor, constant: -10), stack.topAnchor.constraint(equalTo: subToolbar.topAnchor, constant: 6), stack.bottomAnchor.constraint(equalTo: subToolbar.bottomAnchor, constant: -6), ]) subToolbar.isHidden = activeTool == .image || activeTool == .pointer let itemCount = max(stack.arrangedSubviews.count, 1) subToolbarHeight?.constant = 40 let width = CGFloat(itemCount) * 34 + CGFloat(max(itemCount - 1, 0)) * 6 + 18 subToolbarWidth?.constant = width } private func makeSubButton(symbol: String, action: @escaping () -> Void) -> CanvasSubToolButton { let button = CanvasSubToolButton(symbolName: symbol) button.onClick = action return button } private func makeColorButton(color: NSColor, action: @escaping () -> Void) -> CanvasColorButton { let button = CanvasColorButton(color: color) button.onClick = action 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() { let panel = NSColorPanel.shared panel.setTarget(self) panel.setAction(#selector(textColorPanelChanged(_:))) panel.orderFront(nil) } @objc private func textColorPanelChanged(_ sender: NSColorPanel) { onTextColor?(sender.color) } } private final class PlaceholderTextView: NSTextView { var placeholderString = "" var placeholderColor: NSColor = AppTheme.textSecondary { didSet { needsDisplay = true } } override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) guard string.isEmpty, !placeholderString.isEmpty else { return } let attributes: [NSAttributedString.Key: Any] = [ .font: font ?? AppTheme.regularFont(size: 20), .foregroundColor: placeholderColor, ] let rect = firstRect(forCharacterRange: NSRange(location: 0, length: 0), actualRange: nil) (placeholderString as NSString).draw(with: rect, options: .usesLineFragmentOrigin, attributes: attributes) } override func becomeFirstResponder() -> Bool { let becameFirstResponder = super.becomeFirstResponder() if becameFirstResponder { needsDisplay = true } return becameFirstResponder } override func resignFirstResponder() -> Bool { let resignedFirstResponder = super.resignFirstResponder() if resignedFirstResponder { needsDisplay = true } return resignedFirstResponder } } struct AddTextResult { let text: String let fontSize: CGFloat let fontStyle: CanvasTextFontStyle } private final class AddTextOptionPicker: NSControl, AppearanceRefreshable { var onChange: ((String) -> Void)? private let titleLabel = NSTextField() private let chevronView = NSImageView() private var hoverTracker: HoverTracker? private var isHovered = false private let optionMenu = NSMenu() private var options: [String] = [] private var selectedOption = "" init(options: [String], selection: String) { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false wantsLayer = true layer?.cornerRadius = 16 layer?.borderWidth = 1.5 self.options = options selectedOption = selection titleLabel.stringValue = selection titleLabel.font = AppTheme.mediumFont(size: 16) titleLabel.isBordered = false titleLabel.isEditable = false titleLabel.drawsBackground = false titleLabel.lineBreakMode = .byTruncatingTail titleLabel.translatesAutoresizingMaskIntoConstraints = false if let image = NSImage(systemSymbolName: "chevron.up.chevron.down", accessibilityDescription: "Options") { let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold) chevronView.image = image.withSymbolConfiguration(config) } chevronView.translatesAutoresizingMaskIntoConstraints = false addSubview(titleLabel) addSubview(chevronView) NSLayoutConstraint.activate([ heightAnchor.constraint(equalToConstant: 52), titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevronView.leadingAnchor, constant: -10), chevronView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14), chevronView.centerYAnchor.constraint(equalTo: centerYAnchor), chevronView.widthAnchor.constraint(equalToConstant: 16), chevronView.heightAnchor.constraint(equalToConstant: 16), ]) rebuildMenu() hoverTracker = HoverTracker(view: self) { [weak self] hovering in self?.setHovered(hovering) } refreshAppearance() } @available(*, unavailable) required init?(coder: NSCoder) { nil } func setSelection(_ value: String) { guard options.contains(value) else { return } selectedOption = value titleLabel.stringValue = value } func refreshAppearance() { layer?.backgroundColor = isHovered ? AppTheme.elevatedBackground.cgColor : AppTheme.cardBackground.cgColor layer?.borderColor = AppTheme.paywallBorder.cgColor titleLabel.textColor = AppTheme.textPrimary chevronView.contentTintColor = AppTheme.textPrimary } private func setHovered(_ hovering: Bool) { isHovered = hovering animateHover { layer?.backgroundColor = hovering ? AppTheme.elevatedBackground.cgColor : AppTheme.cardBackground.cgColor } } private func rebuildMenu() { optionMenu.removeAllItems() for option in options { let item = NSMenuItem(title: option, action: #selector(menuItemSelected(_:)), keyEquivalent: "") item.target = self item.state = option == selectedOption ? .on : .off optionMenu.addItem(item) } } @objc private func menuItemSelected(_ sender: NSMenuItem) { let value = sender.title guard options.contains(value) else { return } selectedOption = value titleLabel.stringValue = value rebuildMenu() onChange?(value) } override func mouseDown(with event: NSEvent) { guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return } rebuildMenu() optionMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: bounds.height + 4), in: self) } override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) } } private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextViewDelegate { private static let defaultFontSize: CGFloat = 18 private static let fontSizeOptions: [CGFloat] = [12, 14, 16, 18, 20, 22, 24, 28, 32] private static let inputTextInset = NSSize(width: 16, height: 14) private static let inputLineFragmentPadding: CGFloat = 5 var onAdd: ((AddTextResult) -> Void)? var onCancel: (() -> Void)? private var selectedFontSize = defaultFontSize private var selectedFontStyle: CanvasTextFontStyle = .regular private let dimView = NSView() private let cardView = NSView() private let titleLabel = NSTextField(labelWithString: "Add Text") private let optionsStack = NSStackView() private let fontStylePicker: AddTextOptionPicker private let fontSizePicker: AddTextOptionPicker private let inputScrollView = NSScrollView() private let inputTextView = PlaceholderTextView() private let buttonStack = NSStackView() private let addButton = DrawPrintPrintButton(title: "Add", symbolName: "plus", compact: true, firesOnMouseDown: true) private let cancelButton = DrawPrintSecondaryButton(title: "Cancel", symbolName: "xmark", compact: true, firesOnMouseDown: true) override init(frame frameRect: NSRect) { fontStylePicker = AddTextOptionPicker( options: CanvasTextFontStyle.allCases.map(\.rawValue), selection: CanvasTextFontStyle.regular.rawValue ) fontSizePicker = AddTextOptionPicker( options: Self.fontSizeOptions.map { "\(Int($0))" }, selection: "\(Int(Self.defaultFontSize))" ) super.init(frame: frameRect) translatesAutoresizingMaskIntoConstraints = false setup() refreshAppearance() } @available(*, unavailable) required init?(coder: NSCoder) { nil } func refreshAppearance() { dimView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.28).cgColor cardView.layer?.backgroundColor = AppTheme.cardBackground.cgColor cardView.layer?.borderColor = AppTheme.paywallBorder.cgColor titleLabel.textColor = AppTheme.textPrimary inputTextView.backgroundColor = .clear inputTextView.textColor = AppTheme.textPrimary inputTextView.placeholderColor = AppTheme.textSecondary addButton.refreshAppearance() cancelButton.refreshAppearance() fontStylePicker.refreshAppearance() fontSizePicker.refreshAppearance() } func prepareForPresentation() { inputTextView.string = "" selectedFontSize = Self.defaultFontSize selectedFontStyle = .regular fontStylePicker.setSelection(CanvasTextFontStyle.regular.rawValue) fontSizePicker.setSelection("\(Int(Self.defaultFontSize))") applyInputFont() inputTextView.needsDisplay = true } func focusInput() { window?.makeFirstResponder(inputTextView) } private func setup() { wantsLayer = true dimView.wantsLayer = true dimView.translatesAutoresizingMaskIntoConstraints = false cardView.wantsLayer = true cardView.layer?.cornerRadius = 24 cardView.layer?.borderWidth = 1 cardView.applyCardShadow() cardView.translatesAutoresizingMaskIntoConstraints = false titleLabel.font = AppTheme.semiboldFont(size: 22) titleLabel.alignment = .center titleLabel.translatesAutoresizingMaskIntoConstraints = false optionsStack.orientation = .horizontal optionsStack.spacing = 12 optionsStack.distribution = .fillEqually optionsStack.translatesAutoresizingMaskIntoConstraints = false fontStylePicker.onChange = { [weak self] value in self?.fontStyleChanged(value) } fontSizePicker.onChange = { [weak self] value in self?.fontSizeChanged(value) } inputScrollView.translatesAutoresizingMaskIntoConstraints = false inputScrollView.drawsBackground = false inputScrollView.hasVerticalScroller = true inputScrollView.autohidesScrollers = true inputScrollView.borderType = .noBorder inputTextView.isRichText = false inputTextView.font = selectedFontStyle.font(size: selectedFontSize) inputTextView.textContainerInset = Self.inputTextInset inputTextView.textContainer?.lineFragmentPadding = Self.inputLineFragmentPadding inputTextView.placeholderString = "Enter any text here..." inputTextView.isAutomaticQuoteSubstitutionEnabled = true inputTextView.isAutomaticDashSubstitutionEnabled = true inputTextView.isAutomaticTextReplacementEnabled = true inputTextView.delegate = self inputScrollView.documentView = inputTextView buttonStack.orientation = .horizontal buttonStack.spacing = 12 buttonStack.distribution = .fillEqually buttonStack.translatesAutoresizingMaskIntoConstraints = false addButton.onClick = { [weak self] in self?.handleAdd() } cancelButton.onClick = { [weak self] in self?.handleCancel() } addSubview(dimView) addSubview(cardView) cardView.addSubview(titleLabel) cardView.addSubview(optionsStack) optionsStack.addArrangedSubview(fontStylePicker) optionsStack.addArrangedSubview(fontSizePicker) cardView.addSubview(inputScrollView) cardView.addSubview(buttonStack) buttonStack.addArrangedSubview(addButton) buttonStack.addArrangedSubview(cancelButton) NSLayoutConstraint.activate([ dimView.leadingAnchor.constraint(equalTo: leadingAnchor), dimView.trailingAnchor.constraint(equalTo: trailingAnchor), dimView.topAnchor.constraint(equalTo: topAnchor), dimView.bottomAnchor.constraint(equalTo: bottomAnchor), cardView.centerXAnchor.constraint(equalTo: centerXAnchor), cardView.centerYAnchor.constraint(equalTo: centerYAnchor), cardView.widthAnchor.constraint(lessThanOrEqualToConstant: 560), cardView.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.62), cardView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 40), cardView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -40), cardView.heightAnchor.constraint(equalToConstant: 320), titleLabel.topAnchor.constraint(equalTo: cardView.topAnchor, constant: 18), titleLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24), titleLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24), optionsStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24), optionsStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24), optionsStack.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12), inputScrollView.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24), inputScrollView.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24), inputScrollView.topAnchor.constraint(equalTo: optionsStack.bottomAnchor, constant: 12), inputScrollView.bottomAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -16), buttonStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24), buttonStack.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -24), buttonStack.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -20), buttonStack.heightAnchor.constraint(equalToConstant: 44), ]) } private func handleAdd() { let raw = inputTextView.textStorage?.string ?? inputTextView.string let text = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } onAdd?(AddTextResult(text: text, fontSize: selectedFontSize, fontStyle: selectedFontStyle)) } private func fontStyleChanged(_ title: String) { guard let style = CanvasTextFontStyle(rawValue: title) else { return } selectedFontStyle = style applyInputFont() } private func fontSizeChanged(_ title: String) { guard let size = Double(title) else { return } selectedFontSize = CGFloat(size) applyInputFont() } private func applyInputFont() { inputTextView.font = selectedFontStyle.font(size: selectedFontSize) inputTextView.needsDisplay = true } @objc private func handleCancel() { onCancel?() } func textDidChange(_ notification: Notification) { inputTextView.needsDisplay = true } } // MARK: - Controls private final class DrawPrintToolbarButton: NSControl, AppearanceRefreshable { var onClick: (() -> Void)? private let symbolName: String private let iconView = NSImageView() init(symbolName: String, accessibilityLabel: String) { self.symbolName = symbolName super.init(frame: .zero) toolTip = accessibilityLabel wantsLayer = true layer?.cornerRadius = 12 if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) { let config = NSImage.SymbolConfiguration(pointSize: 15, weight: .semibold) iconView.image = image.withSymbolConfiguration(config) } iconView.translatesAutoresizingMaskIntoConstraints = false addSubview(iconView) NSLayoutConstraint.activate([ iconView.centerXAnchor.constraint(equalTo: centerXAnchor), iconView.centerYAnchor.constraint(equalTo: centerYAnchor), ]) refreshAppearance() } @available(*, unavailable) required init?(coder: NSCoder) { nil } func refreshAppearance() { layer?.backgroundColor = AppTheme.elevatedBackground.cgColor layer?.borderColor = AppTheme.paywallBorder.cgColor layer?.borderWidth = 1.5 iconView.contentTintColor = AppTheme.textPrimary } 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 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 private let firesOnMouseDown: Bool init(title: String, symbolName: String, compact: Bool = false, firesOnMouseDown: Bool = false) { self.title = title self.firesOnMouseDown = firesOnMouseDown super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false wantsLayer = true let height: CGFloat = compact ? 44 : 52 let cornerRadius: CGFloat = compact ? 18 : 22 let fontSize: CGFloat = compact ? 14 : 16 let horizontalPadding: CGFloat = compact ? 18 : 24 let minWidth: CGFloat = compact ? 0 : 180 let iconSize: CGFloat = compact ? 14 : 18 let symbolPointSize: CGFloat = compact ? 12 : 14 layer?.cornerRadius = cornerRadius layer?.borderWidth = 1.5 titleLabel.stringValue = title titleLabel.font = AppTheme.semiboldFont(size: fontSize) 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: symbolPointSize, weight: .semibold) iconView.image = image.withSymbolConfiguration(config) } iconView.translatesAutoresizingMaskIntoConstraints = false addSubview(titleLabel) addSubview(iconView) var constraints = [ heightAnchor.constraint(equalToConstant: height), titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalPadding), titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 8), iconView.centerYAnchor.constraint(equalTo: centerYAnchor), iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalPadding), iconView.widthAnchor.constraint(equalToConstant: iconSize), iconView.heightAnchor.constraint(equalToConstant: iconSize), ] if minWidth > 0 { constraints.append(widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth)) } NSLayoutConstraint.activate(constraints) 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 mouseDown(with event: NSEvent) { guard firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return } onClick?() } override func mouseUp(with event: NSEvent) { guard !firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return } onClick?() } override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) } } private final class DrawPrintPrintButton: NSControl, AppearanceRefreshable { var onClick: (() -> Void)? private let titleLabel: NSTextField private let iconView = NSImageView() private var hoverTracker: HoverTracker? private var isHovered = false private let firesOnMouseDown: Bool init( title: String = "Print Canvas", symbolName: String = "printer.fill", compact: Bool = false, firesOnMouseDown: Bool = false ) { titleLabel = NSTextField(labelWithString: title) self.firesOnMouseDown = firesOnMouseDown super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false wantsLayer = true let height: CGFloat = compact ? 44 : 52 let cornerRadius: CGFloat = compact ? 18 : 22 let fontSize: CGFloat = compact ? 14 : 16 let horizontalPadding: CGFloat = compact ? 20 : 28 let iconSize: CGFloat = compact ? 16 : 20 let symbolPointSize: CGFloat = compact ? 12 : 14 layer?.cornerRadius = cornerRadius titleLabel.font = AppTheme.semiboldFont(size: fontSize) titleLabel.textColor = .white titleLabel.translatesAutoresizingMaskIntoConstraints = false if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: title) { let config = NSImage.SymbolConfiguration(pointSize: symbolPointSize, weight: .semibold) iconView.image = image.withSymbolConfiguration(config) } iconView.contentTintColor = .white iconView.translatesAutoresizingMaskIntoConstraints = false addSubview(titleLabel) addSubview(iconView) NSLayoutConstraint.activate([ heightAnchor.constraint(equalToConstant: height), titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: horizontalPadding), titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), iconView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 10), iconView.centerYAnchor.constraint(equalTo: centerYAnchor), iconView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -horizontalPadding), iconView.widthAnchor.constraint(equalToConstant: iconSize), iconView.heightAnchor.constraint(equalToConstant: iconSize), ]) hoverTracker = HoverTracker(view: self) { [weak self] hovering in self?.setHovered(hovering) } refreshAppearance() } @available(*, unavailable) required init?(coder: NSCoder) { nil } func refreshAppearance() { let color = isHovered ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue : AppTheme.blue layer?.backgroundColor = color.cgColor titleLabel.textColor = .white iconView.contentTintColor = .white } private func setHovered(_ hovering: Bool) { isHovered = hovering animateHover { let color = hovering ? AppTheme.blue.blended(withFraction: 0.15, of: .black) ?? AppTheme.blue : AppTheme.blue layer?.backgroundColor = color.cgColor layer?.transform = hovering ? CATransform3DMakeScale(1.03, 1.03, 1) : CATransform3DIdentity } } override func mouseDown(with event: NSEvent) { guard firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return } onClick?() } override func mouseUp(with event: NSEvent) { guard !firesOnMouseDown, bounds.contains(convert(event.locationInWindow, from: nil)) else { return } onClick?() } override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) } } private final class CanvasHistoryButton: NSControl, AppearanceRefreshable { var onClick: (() -> Void)? private var actionEnabled = true { didSet { alphaValue = actionEnabled ? 1 : 0.35 } } private let iconView = NSImageView() init(symbolName: String, accessibilityLabel: String) { super.init(frame: .zero) toolTip = accessibilityLabel wantsLayer = true layer?.cornerRadius = 16 if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityLabel) { let config = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold) iconView.image = image.withSymbolConfiguration(config) } iconView.translatesAutoresizingMaskIntoConstraints = false addSubview(iconView) NSLayoutConstraint.activate([ iconView.centerXAnchor.constraint(equalTo: centerXAnchor), iconView.centerYAnchor.constraint(equalTo: centerYAnchor), ]) refreshAppearance() } @available(*, unavailable) required init?(coder: NSCoder) { nil } func refreshAppearance() { layer?.backgroundColor = NSColor(calibratedWhite: 0.88, alpha: 1).cgColor iconView.contentTintColor = NSColor(calibratedWhite: 0.35, alpha: 1) } func setActionEnabled(_ enabled: Bool) { actionEnabled = enabled } override func mouseDown(with event: NSEvent) {} override func mouseUp(with event: NSEvent) { guard actionEnabled, bounds.contains(convert(event.locationInWindow, from: nil)) else { return } onClick?() } override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) } } private final class CanvasToolButton: NSControl, AppearanceRefreshable { var onClick: (() -> Void)? private let tool: CanvasTool private let iconView = NSImageView() private var selected = false init(tool: CanvasTool) { self.tool = tool super.init(frame: .zero) wantsLayer = true layer?.cornerRadius = 12 let symbol: String switch tool { case .pointer: symbol = "arrow.up.left" case .text: symbol = "character.cursor.ibeam" case .image: symbol = "photo.on.rectangle.angled" case .draw: symbol = "paintbrush.pointed.fill" } if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: toolLabel) { let config = NSImage.SymbolConfiguration(pointSize: 16, weight: .medium) iconView.image = image.withSymbolConfiguration(config) } iconView.translatesAutoresizingMaskIntoConstraints = false addSubview(iconView) NSLayoutConstraint.activate([ widthAnchor.constraint(equalToConstant: 38), heightAnchor.constraint(equalToConstant: 38), iconView.centerXAnchor.constraint(equalTo: centerXAnchor), iconView.centerYAnchor.constraint(equalTo: centerYAnchor), ]) refreshAppearance() } @available(*, unavailable) required init?(coder: NSCoder) { nil } private var toolLabel: String { switch tool { case .pointer: return "Pointer" case .text: return "Text" case .image: return "Image" case .draw: return "Draw" } } func setSelected(_ selected: Bool) { self.selected = selected refreshAppearance() } func refreshAppearance() { layer?.backgroundColor = selected ? AppTheme.homeActiveBackground.cgColor : NSColor.clear.cgColor iconView.contentTintColor = selected ? AppTheme.homeActiveForeground : AppTheme.textPrimary } 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 CanvasSubToolButton: NSControl, AppearanceRefreshable { var onClick: (() -> Void)? private let iconView = NSImageView() init(symbolName: String) { super.init(frame: .zero) wantsLayer = true layer?.cornerRadius = 14 if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) { let config = NSImage.SymbolConfiguration(pointSize: 13, weight: .medium) iconView.image = image.withSymbolConfiguration(config) } iconView.translatesAutoresizingMaskIntoConstraints = false addSubview(iconView) NSLayoutConstraint.activate([ widthAnchor.constraint(equalToConstant: 28), heightAnchor.constraint(equalToConstant: 28), iconView.centerXAnchor.constraint(equalTo: centerXAnchor), iconView.centerYAnchor.constraint(equalTo: centerYAnchor), ]) refreshAppearance() } @available(*, unavailable) required init?(coder: NSCoder) { nil } func refreshAppearance() { layer?.backgroundColor = AppTheme.elevatedBackground.cgColor layer?.borderColor = AppTheme.paywallBorder.cgColor layer?.borderWidth = 1 iconView.contentTintColor = AppTheme.textPrimary } 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 CanvasColorButton: NSControl, AppearanceRefreshable { var onClick: (() -> Void)? private let swatchColor: NSColor init(color: NSColor) { self.swatchColor = color super.init(frame: .zero) wantsLayer = true layer?.cornerRadius = 12 layer?.borderWidth = 2 layer?.borderColor = AppTheme.paywallBorder.cgColor NSLayoutConstraint.activate([ widthAnchor.constraint(equalToConstant: 24), heightAnchor.constraint(equalToConstant: 24), ]) refreshAppearance() } @available(*, unavailable) required init?(coder: NSCoder) { nil } func refreshAppearance() { layer?.backgroundColor = swatchColor.cgColor } override func mouseUp(with event: NSEvent) { guard bounds.contains(convert(event.locationInWindow, from: nil)) else { return } onClick?() } override func resetCursorRects() { addCursorRect(bounds, cursor: .pointingHand) } }