|
|
@@ -28,11 +28,42 @@ struct CanvasStroke {
|
|
|
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
|
|
|
}
|
|
|
@@ -245,8 +276,8 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
|
|
|
|
|
|
addTextPromptView.isHidden = true
|
|
|
addTextPromptView.alphaValue = 0
|
|
|
- addTextPromptView.onAdd = { [weak self] text in
|
|
|
- self?.commitAddedText(text)
|
|
|
+ addTextPromptView.onAdd = { [weak self] result in
|
|
|
+ self?.commitAddedText(result)
|
|
|
}
|
|
|
addTextPromptView.onCancel = { [weak self] in
|
|
|
self?.pendingTextPlacement = nil
|
|
|
@@ -270,7 +301,7 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
|
|
|
toolsPanel.setActiveTool(.pointer)
|
|
|
}
|
|
|
|
|
|
- private func commitAddedText(_ text: String) {
|
|
|
+ private func commitAddedText(_ result: AddTextResult) {
|
|
|
let placement = pendingTextPlacement
|
|
|
pendingTextPlacement = nil
|
|
|
suppressCanvasTextPrompt = true
|
|
|
@@ -279,7 +310,12 @@ final class DrawPrintOverlayView: NSView, AppearanceRefreshable {
|
|
|
layoutSubtreeIfNeeded()
|
|
|
canvasContainer.layoutSubtreeIfNeeded()
|
|
|
canvasView.layoutSubtreeIfNeeded()
|
|
|
- canvasView.addTextItem(text: text, at: placement)
|
|
|
+ canvasView.addTextItem(
|
|
|
+ text: result.text,
|
|
|
+ fontSize: result.fontSize,
|
|
|
+ fontStyle: result.fontStyle,
|
|
|
+ at: placement
|
|
|
+ )
|
|
|
toolsPanel.setActiveTool(.pointer)
|
|
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
|
@@ -549,7 +585,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
|
|
|
let paragraph = NSMutableParagraphStyle()
|
|
|
paragraph.alignment = item.alignment
|
|
|
let attributes: [NSAttributedString.Key: Any] = [
|
|
|
- .font: AppTheme.regularFont(size: item.fontSize),
|
|
|
+ .font: item.fontStyle.font(size: item.fontSize),
|
|
|
.foregroundColor: item.color,
|
|
|
.paragraphStyle: paragraph,
|
|
|
]
|
|
|
@@ -774,10 +810,15 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
|
|
|
notifyUndoState()
|
|
|
}
|
|
|
|
|
|
- func addTextItem(text: String = "Text", at origin: NSPoint? = nil) {
|
|
|
+ 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, at: origin)
|
|
|
+ self?.addTextItem(text: text, fontSize: fontSize, fontStyle: fontStyle, at: origin)
|
|
|
}
|
|
|
return
|
|
|
}
|
|
|
@@ -788,7 +829,8 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
|
|
|
id: UUID(),
|
|
|
text: text,
|
|
|
origin: .zero,
|
|
|
- fontSize: 18,
|
|
|
+ fontSize: fontSize,
|
|
|
+ fontStyle: fontStyle,
|
|
|
color: .black,
|
|
|
alignment: .left
|
|
|
)
|
|
|
@@ -812,7 +854,8 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
|
|
|
id: UUID(),
|
|
|
text: text,
|
|
|
origin: clampedOrigin,
|
|
|
- fontSize: 18,
|
|
|
+ fontSize: fontSize,
|
|
|
+ fontStyle: fontStyle,
|
|
|
color: .black,
|
|
|
alignment: .left
|
|
|
)
|
|
|
@@ -863,7 +906,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
|
|
|
pushSnapshot()
|
|
|
textItems[index].fontSize = size
|
|
|
if let field = textField(for: id) {
|
|
|
- field.font = AppTheme.regularFont(size: size)
|
|
|
+ field.font = textItems[index].fontStyle.font(size: size)
|
|
|
field.frame = textBounds(for: textItems[index])
|
|
|
}
|
|
|
needsDisplay = true
|
|
|
@@ -955,7 +998,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
|
|
|
field.isBordered = false
|
|
|
field.isBezeled = false
|
|
|
field.drawsBackground = false
|
|
|
- field.font = AppTheme.regularFont(size: item.fontSize)
|
|
|
+ field.font = item.fontStyle.font(size: item.fontSize)
|
|
|
field.textColor = item.color
|
|
|
field.alignment = item.alignment
|
|
|
field.frame = textBounds(for: item)
|
|
|
@@ -996,7 +1039,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
|
|
|
|
|
|
private func textBounds(for item: CanvasTextData) -> NSRect {
|
|
|
let attributes: [NSAttributedString.Key: Any] = [
|
|
|
- .font: AppTheme.regularFont(size: item.fontSize),
|
|
|
+ .font: item.fontStyle.font(size: item.fontSize),
|
|
|
]
|
|
|
let textSize = (item.text as NSString).size(withAttributes: attributes)
|
|
|
let width = max(textSize.width + 8, 80)
|
|
|
@@ -1124,7 +1167,7 @@ final class BlankCanvasView: NSView, AppearanceRefreshable {
|
|
|
textItems[index].origin = clampedOrigin(origin, size: bounds.size)
|
|
|
|
|
|
if let field = textField(for: textItems[index].id) {
|
|
|
- field.font = AppTheme.regularFont(size: newFontSize)
|
|
|
+ field.font = textItems[index].fontStyle.font(size: newFontSize)
|
|
|
field.frame = textBounds(for: textItems[index])
|
|
|
}
|
|
|
}
|
|
|
@@ -1436,17 +1479,143 @@ private final class PlaceholderTextView: NSTextView {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+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 inputFontSize: CGFloat = 20
|
|
|
+ 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: ((String) -> Void)?
|
|
|
+ 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()
|
|
|
@@ -1454,6 +1623,14 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
|
|
|
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()
|
|
|
@@ -1473,10 +1650,17 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
|
|
|
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
|
|
|
}
|
|
|
|
|
|
@@ -1500,6 +1684,18 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
|
|
|
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
|
|
|
@@ -1507,7 +1703,7 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
|
|
|
inputScrollView.borderType = .noBorder
|
|
|
|
|
|
inputTextView.isRichText = false
|
|
|
- inputTextView.font = AppTheme.regularFont(size: Self.inputFontSize)
|
|
|
+ inputTextView.font = selectedFontStyle.font(size: selectedFontSize)
|
|
|
inputTextView.textContainerInset = Self.inputTextInset
|
|
|
inputTextView.textContainer?.lineFragmentPadding = Self.inputLineFragmentPadding
|
|
|
inputTextView.placeholderString = "Enter any text here..."
|
|
|
@@ -1532,6 +1728,9 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
|
|
|
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)
|
|
|
@@ -1549,15 +1748,19 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
|
|
|
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: 280),
|
|
|
+ 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: titleLabel.bottomAnchor, constant: 14),
|
|
|
+ inputScrollView.topAnchor.constraint(equalTo: optionsStack.bottomAnchor, constant: 12),
|
|
|
inputScrollView.bottomAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -16),
|
|
|
|
|
|
buttonStack.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 24),
|
|
|
@@ -1571,7 +1774,24 @@ private final class AddTextPromptView: NSView, AppearanceRefreshable, NSTextView
|
|
|
let raw = inputTextView.textStorage?.string ?? inputTextView.string
|
|
|
let text = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
guard !text.isEmpty else { return }
|
|
|
- onAdd?(text)
|
|
|
+ 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() {
|