Skip to content

Add basic link support to AppKit/macOS formatting bar #241

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions Demo/Demo/DemoEditorScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ struct DemoEditorScreen: View {
.inspector(isPresented: $isInspectorPresented) {
RichTextFormat.Sidebar(context: context)
#if os(macOS)
.inspectorColumnWidth(min: 200, ideal: 200, max: 315)
.inspectorColumnWidth(min: 280, ideal: 350, max: 400)
#endif
}
.toolbar {
Expand All @@ -53,6 +53,7 @@ struct DemoEditorScreen: View {
.aspectRatio(1, contentMode: .fit)
}
}

}
.frame(minWidth: 500)
.focusedValue(\.richTextContext, context)
Expand All @@ -65,7 +66,15 @@ struct DemoEditorScreen: View {
)
)
.richTextFormatToolbarConfig(.init(colorPickers: []))
.viewDebug()
.sheet(isPresented: $context.isLinkSheetPresented) {
RichTextFormat.LinkInput(
context: context,
isPresented: $context.isLinkSheetPresented
)
#if macOS
.frame(width: 400)
#endif
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions Sources/RichTextKit/Actions/RichTextAction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ public enum RichTextAction: Identifiable, Equatable, RichTextLabelValue {

/// Set a certain ``RichTextStyle``.
case setStyle(RichTextStyle, Bool)

/// Set a link attribute for a range of text.
case setLinkAttribute(URL?, NSRange)

/// Step the font size.
case stepFontSize(points: Int)
Expand Down Expand Up @@ -121,6 +124,7 @@ public extension RichTextAction {
case .setHighlightingStyle: .richTextAlignmentCenter
case .setParagraphStyle: .richTextAlignmentLeft
case .setStyle(let style, _): style.icon
case .setLinkAttribute: .richTextStyleLink
case .stepFontSize(let val): .richTextStepFontSize(val)
case .stepIndent(let val): .richTextStepIndent(val)
case .stepLineSpacing(let val): .richTextStepLineSpacing(val)
Expand Down Expand Up @@ -175,6 +179,7 @@ public extension RichTextAction {
case .setHighlightingStyle: .highlightingStyle
case .setParagraphStyle: .textAlignmentLeft
case .setStyle(let style, _): style.titleKey
case .setLinkAttribute: .styleLink
case .stepFontSize(let points): .actionStepFontSize(points)
case .stepIndent(let points): .actionStepIndent(points)
case .stepLineSpacing(let points): .actionStepLineSpacing(points)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public extension RichTextViewComponent {
var styles = traits?.enabledRichTextStyles ?? []
if attributes.isStrikethrough { styles.append(.strikethrough) }
if attributes.isUnderlined { styles.append(.underlined) }
if attributes[.link] != nil { styles.append(.link) }
return styles
}

Expand All @@ -42,6 +43,12 @@ public extension RichTextViewComponent {
setRichTextAttribute(.underlineStyle, to: value)
case .strikethrough:
setRichTextAttribute(.strikethroughStyle, to: value)
case .link:
if !newValue {
// When disabling link, remove the link attribute
setRichTextAttribute(.link, to: NSNull())
}
// When enabling link, do nothing - this will be handled by the context
}
}

Expand Down
38 changes: 38 additions & 0 deletions Sources/RichTextKit/Bridging/RichTextView_AppKit.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,44 @@ open class RichTextView: NSTextView, RichTextViewComponent {
.scrollWheel(with: event)
}

// Add text input handling
open override func insertText(_ string: Any, replacementRange: NSRange) {
// Store current selection for undo
let currentRange = selectedRange
let currentAttributes = typingAttributes

// Begin undo grouping
undoManager?.beginUndoGrouping()

// If we're typing after a link or inserting whitespace, remove link attributes
if let text = string as? String {
let attributes = richTextAttributes(at: NSRange(location: max(0, currentRange.location - 1), length: 1))

// Only remove link attributes when typing whitespace
if text.rangeOfCharacter(from: .whitespacesAndNewlines) != nil {
// Remove link-related attributes from typing attributes
var attrs = typingAttributes
attrs.removeValue(forKey: .link)
attrs.removeValue(forKey: .underlineStyle)
attrs.removeValue(forKey: .underlineColor)
typingAttributes = attrs

// Also remove link attributes from the current position if we're after a link
if attributes[.link] != nil, let textStorage = self.textStorage {
textStorage.removeAttribute(.link, range: NSRange(location: currentRange.location, length: 0))
textStorage.removeAttribute(.underlineStyle, range: NSRange(location: currentRange.location, length: 0))
textStorage.removeAttribute(.underlineColor, range: NSRange(location: currentRange.location, length: 0))
}
}
}

// Perform the text insertion
super.insertText(string, replacementRange: replacementRange)

// End undo grouping
undoManager?.endUndoGrouping()
}

// MARK: - Setup

/**
Expand Down
98 changes: 98 additions & 0 deletions Sources/RichTextKit/Extensions/RichTextContext+Link.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Foundation
#if canImport(AppKit)
import AppKit
#endif

public extension RichTextContext {

/// Get the currently selected text, if any
public var selectedText: String? {
let range = selectedRange
guard range.length > 0 else { return nil }
return attributedString.string.substring(with: range)
}

/// Check if the current selection has a link
public var hasLink: Bool {
let range = selectedRange
guard range.length > 0 else { return false }

// Check if any part of the selection has a link
var hasAnyLink = false
var hasNoLink = false

attributedString.enumerateAttributes(in: range, options: []) { attributes, _, _ in
if attributes[.link] != nil {
hasAnyLink = true
} else {
hasNoLink = true
}
}

// Only return true if the entire selection has a link
return hasAnyLink && !hasNoLink
}

/// Add a link to the currently selected text
/// - Parameters:
/// - urlString: The URL string to link to
/// - text: Optional text to replace the selection with. If nil, uses existing selection
public func setLink(url urlString: String, text: String? = nil) {
let range = selectedRange

// Only apply changes if explicitly requested and different from current
if hasLink { return }

// Process URL string
var finalURLString = urlString
if !urlString.lowercased().hasPrefix("http://") && !urlString.lowercased().hasPrefix("https://") {
finalURLString = "https://" + urlString
}

guard let linkURL = URL(string: finalURLString) else { return }

let linkText = text ?? attributedString.string.substring(with: range)
let linkRange = NSRange(location: range.location, length: linkText.count)

// If there's replacement text, replace it first
if text != nil {
let mutableString = NSMutableAttributedString(attributedString: attributedString)
mutableString.replaceCharacters(in: range, with: linkText)
actionPublisher.send(.setAttributedString(mutableString))
}

// Set the link attribute
actionPublisher.send(.setLinkAttribute(linkURL, linkRange))
}

/// Remove link from the current selection
public func removeLink() {
let range = selectedRange
guard range.length > 0 else { return }

// Only apply changes if explicitly requested and different from current
if !hasLink { return }

// Create a mutable copy of the current attributed string
let mutableString = NSMutableAttributedString(attributedString: attributedString)

// Remove only the link attribute
mutableString.removeAttribute(.link, range: range)

// Update the context with the new string
actionPublisher.send(.setAttributedString(mutableString))
}
}

private extension String {
func substring(with range: NSRange) -> String {
guard range.location >= 0,
range.length >= 0,
range.location + range.length <= self.count else {
return ""
}
let start = index(startIndex, offsetBy: range.location)
let end = index(start, offsetBy: range.length)
return String(self[start..<end])
}
}
65 changes: 65 additions & 0 deletions Sources/RichTextKit/Format/RichTextFormat+LinkInput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// RichTextFormat+LinkInput.swift
// RichTextKit
//
// Created by Daniel Saidi on 2024-04-30.
// Copyright © 2024 Daniel Saidi. All rights reserved.
//

import Foundation
import SwiftUI

public extension RichTextFormat {

@available(iOS 15.0, macOS 12.0, *)
struct LinkInput: View {

public init(
context: RichTextContext,
isPresented: Binding<Bool>
) {
self.context = context
self._isPresented = isPresented
self._urlString = State(initialValue: "")
self._text = State(initialValue: context.selectedText ?? "")
}

private let context: RichTextContext
@Binding private var isPresented: Bool
@State private var urlString: String
@State private var text: String

public var body: some View {
VStack(spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("URL")
.foregroundStyle(.secondary)
TextField("", text: $urlString)
.textFieldStyle(.roundedBorder)
}

VStack(alignment: .leading, spacing: 8) {
Text("Text")
.foregroundStyle(.secondary)
TextField("", text: $text)
.textFieldStyle(.roundedBorder)
}

HStack(spacing: 12) {
Spacer()
Button("Cancel") {
isPresented = false
}
Button("OK") {
context.setLink(url: urlString, text: text)
isPresented = false
}
.buttonStyle(.borderedProminent)
.disabled(urlString.isEmpty)
}
}
.padding()
.frame(width: 400)
}
}
}
2 changes: 1 addition & 1 deletion Sources/RichTextKit/Format/RichTextFormat+Toolbar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ public extension RichTextFormat {
.environment(\.sizeCategory, .medium)
.background(background)
#if macOS
.frame(minWidth: 650)
.frame(minWidth: 750)
#endif
}
}
Expand Down
7 changes: 6 additions & 1 deletion Sources/RichTextKit/Images/Image+RichText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,14 @@ public extension Image {
static let richTextSelection = symbol("123.rectangle.fill")

static let richTextStyleBold = symbol("bold")
static let richTextStyleItalic = symbol("italic")
static var richTextItalic = symbol("italic")

/// The rich text link image.
static var richTextLink = ("link")

static let richTextStyleStrikethrough = symbol("strikethrough")
static let richTextStyleUnderline = symbol("underline")
static let richTextStyleLink = symbol("link")

static let richTextSuperscriptDecrease = symbol("textformat.subscript")
static let richTextSuperscriptIncrease = symbol("textformat.superscript")
Expand Down
1 change: 1 addition & 0 deletions Sources/RichTextKit/Localization/RTKL10n.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ public enum RTKL10n: String, CaseIterable, Identifiable {
styleItalic,
styleStrikethrough,
styleUnderlined,
styleLink,

superscript,
superscriptIncrease,
Expand Down
29 changes: 24 additions & 5 deletions Sources/RichTextKit/Styles/RichTextStyle+Toggle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public extension RichTextStyle {
self.style = style
self.value = value
self.fillVertically = fillVertically
self.context = nil
}

/**
Expand All @@ -50,16 +51,31 @@ public extension RichTextStyle {
context: RichTextContext,
fillVertically: Bool = false
) {
self.init(
style: style,
value: context.binding(for: style),
fillVertically: fillVertically
)
self.style = style
self.fillVertically = fillVertically
self.context = context

if style == .link {
self.value = Binding(
get: { context.hasLink },
set: { _ in
guard context.hasSelectedRange else { return }
if context.hasLink {
context.removeLink()
} else {
context.isLinkSheetPresented = true
}
}
)
} else {
self.value = context.binding(for: style)
}
}

private let style: RichTextStyle
private let value: Binding<Bool>
private let fillVertically: Bool
private let context: RichTextContext?

public var body: some View {
#if os(tvOS) || os(watchOS)
Expand All @@ -74,9 +90,12 @@ public extension RichTextStyle {
style.icon
.frame(maxHeight: fillVertically ? .infinity : nil)
}
.toggleStyle(.button)
.keyboardShortcut(for: style)
.accessibilityLabel(style.title)
}


}
}

Expand Down
Loading