From 87b14e6586b0f7289be17a8114724c47abe69587 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 30 Apr 2025 09:47:00 -0400 Subject: [PATCH 1/5] initial support for links --- Demo/Demo/DemoEditorScreen.swift | 57 ++++++++++++ .../Bridging/RichTextView_AppKit.swift | 37 ++++++++ .../Extensions/RichTextContext+Link.swift | 88 +++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 Sources/RichTextKit/Extensions/RichTextContext+Link.swift diff --git a/Demo/Demo/DemoEditorScreen.swift b/Demo/Demo/DemoEditorScreen.swift index c4d05cb1a..d5d99bf25 100644 --- a/Demo/Demo/DemoEditorScreen.swift +++ b/Demo/Demo/DemoEditorScreen.swift @@ -14,6 +14,8 @@ struct DemoEditorScreen: View { @Binding var document: DemoDocument @State private var isInspectorPresented = false + @State private var isLinkSheetPresented = false + @State private var linkURL = "" @StateObject var context = RichTextContext() @@ -53,6 +55,29 @@ struct DemoEditorScreen: View { .aspectRatio(1, contentMode: .fit) } } + + ToolbarItem(placement: .automatic) { + Button { + if context.hasLink { + context.removeLink() + } else { + isLinkSheetPresented = true + } + } label: { + Image(systemName: "link") + .foregroundColor(context.hasLink ? .accentColor : .primary) + } + .help("Add or remove link") + } + } + .sheet(isPresented: $isLinkSheetPresented) { + LinkSheet(url: $linkURL) { + if !linkURL.isEmpty { + context.setLink(url: linkURL) + linkURL = "" + } + isLinkSheetPresented = false + } } .frame(minWidth: 500) .focusedValue(\.richTextContext, context) @@ -69,6 +94,38 @@ struct DemoEditorScreen: View { } } +private struct LinkSheet: View { + @Binding var url: String + let onDismiss: () -> Void + + var body: some View { + VStack(spacing: 20) { + Text("Add Link") + .font(.headline) + + TextField("URL", text: $url) + .textFieldStyle(.roundedBorder) + .frame(width: 300) + + HStack { + Button("Cancel") { + url = "" + onDismiss() + } + .keyboardShortcut(.cancelAction) + + Button("Add") { + onDismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(url.isEmpty) + } + } + .padding() + .frame(width: 400, height: 150) + } +} + private extension DemoEditorScreen { var isMac: Bool { diff --git a/Sources/RichTextKit/Bridging/RichTextView_AppKit.swift b/Sources/RichTextKit/Bridging/RichTextView_AppKit.swift index 533f568b2..553cd6266 100644 --- a/Sources/RichTextKit/Bridging/RichTextView_AppKit.swift +++ b/Sources/RichTextKit/Bridging/RichTextView_AppKit.swift @@ -98,6 +98,43 @@ 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)) + + if attributes[.link] != nil || 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 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 /** diff --git a/Sources/RichTextKit/Extensions/RichTextContext+Link.swift b/Sources/RichTextKit/Extensions/RichTextContext+Link.swift new file mode 100644 index 000000000..a41b4b868 --- /dev/null +++ b/Sources/RichTextKit/Extensions/RichTextContext+Link.swift @@ -0,0 +1,88 @@ +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 } + let attributes = attributedString.attributes(at: range.location, effectiveRange: nil) + return attributes[.link] != nil + } + + /// 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 +// guard range.length > 0 else { return } + + // 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) + + // Create a mutable copy of the current attributed string + let mutableString = NSMutableAttributedString(attributedString: attributedString) + + // Replace the text first if needed + mutableString.replaceCharacters(in: range, with: linkText) + + // Add the link attribute + mutableString.addAttribute(.link, value: linkURL, range: linkRange) + + // Update the context with the new string + actionPublisher.send(.setAttributedString(mutableString)) + } + + /// 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.. Date: Wed, 30 Apr 2025 11:07:11 -0400 Subject: [PATCH 2/5] Better compatibility with the existing richtextkit system --- .../RichTextKit/Actions/RichTextAction.swift | 5 ++++ .../RichTextViewComponent+Styles.swift | 7 +++++ .../Extensions/RichTextContext+Link.swift | 28 +++++++------------ .../RichTextKit/Images/Image+RichText.swift | 7 ++++- .../RichTextKit/Localization/RTKL10n.swift | 1 + .../RichTextKit/Styles/RichTextStyle.swift | 8 +++++- .../Styles/View+RichTextStyle.swift | 1 + .../_Essential/RichTextContext+Actions.swift | 1 + .../RichTextCoordinator+Actions.swift | 9 ++++++ 9 files changed, 47 insertions(+), 20 deletions(-) diff --git a/Sources/RichTextKit/Actions/RichTextAction.swift b/Sources/RichTextKit/Actions/RichTextAction.swift index 1a60bdabe..b248665f1 100644 --- a/Sources/RichTextKit/Actions/RichTextAction.swift +++ b/Sources/RichTextKit/Actions/RichTextAction.swift @@ -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) @@ -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) @@ -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) diff --git a/Sources/RichTextKit/Bridging/RichTextViewComponent+Styles.swift b/Sources/RichTextKit/Bridging/RichTextViewComponent+Styles.swift index cbf6c716d..c83201b66 100644 --- a/Sources/RichTextKit/Bridging/RichTextViewComponent+Styles.swift +++ b/Sources/RichTextKit/Bridging/RichTextViewComponent+Styles.swift @@ -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 } @@ -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 } } diff --git a/Sources/RichTextKit/Extensions/RichTextContext+Link.swift b/Sources/RichTextKit/Extensions/RichTextContext+Link.swift index a41b4b868..c3bced568 100644 --- a/Sources/RichTextKit/Extensions/RichTextContext+Link.swift +++ b/Sources/RichTextKit/Extensions/RichTextContext+Link.swift @@ -42,17 +42,15 @@ public extension RichTextContext { let linkText = text ?? attributedString.string.substring(with: range) let linkRange = NSRange(location: range.location, length: linkText.count) - // Create a mutable copy of the current attributed string - let mutableString = NSMutableAttributedString(attributedString: attributedString) - - // Replace the text first if needed - mutableString.replaceCharacters(in: range, with: linkText) - - // Add the link attribute - mutableString.addAttribute(.link, value: linkURL, range: linkRange) + // 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)) + } - // Update the context with the new string - actionPublisher.send(.setAttributedString(mutableString)) + // Set the link attribute + actionPublisher.send(.setLinkAttribute(linkURL, linkRange)) } /// Remove link from the current selection @@ -63,14 +61,8 @@ public extension RichTextContext { // 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)) + // Remove the link attribute + actionPublisher.send(.setLinkAttribute(nil, range)) } } diff --git a/Sources/RichTextKit/Images/Image+RichText.swift b/Sources/RichTextKit/Images/Image+RichText.swift index 31214e1ea..b5d067aa7 100644 --- a/Sources/RichTextKit/Images/Image+RichText.swift +++ b/Sources/RichTextKit/Images/Image+RichText.swift @@ -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") diff --git a/Sources/RichTextKit/Localization/RTKL10n.swift b/Sources/RichTextKit/Localization/RTKL10n.swift index 14b2d285a..2e4162623 100644 --- a/Sources/RichTextKit/Localization/RTKL10n.swift +++ b/Sources/RichTextKit/Localization/RTKL10n.swift @@ -79,6 +79,7 @@ public enum RTKL10n: String, CaseIterable, Identifiable { styleItalic, styleStrikethrough, styleUnderlined, + styleLink, superscript, superscriptIncrease, diff --git a/Sources/RichTextKit/Styles/RichTextStyle.swift b/Sources/RichTextKit/Styles/RichTextStyle.swift index 693accbba..697f7686a 100644 --- a/Sources/RichTextKit/Styles/RichTextStyle.swift +++ b/Sources/RichTextKit/Styles/RichTextStyle.swift @@ -22,6 +22,7 @@ public enum RichTextStyle: String, CaseIterable, Identifiable, RichTextLabelValu case italic case underlined case strikethrough + case link } public extension RichTextStyle { @@ -44,9 +45,10 @@ public extension RichTextStyle { var icon: Image { switch self { case .bold: .richTextStyleBold - case .italic: .richTextStyleItalic + case .italic: .richTextItalic case .strikethrough: .richTextStyleStrikethrough case .underlined: .richTextStyleUnderline + case .link: .richTextStyleLink } } @@ -62,6 +64,7 @@ public extension RichTextStyle { case .italic: .styleItalic case .underlined: .styleUnderlined case .strikethrough: .styleStrikethrough + case .link: .styleLink } } @@ -80,6 +83,7 @@ public extension RichTextStyle { var styles = traits?.enabledRichTextStyles ?? [] if attributes?.isStrikethrough == true { styles.append(.strikethrough) } if attributes?.isUnderlined == true { styles.append(.underlined) } + if attributes?[.link] != nil { styles.append(.link) } return styles } } @@ -112,6 +116,7 @@ public extension RichTextStyle { case .italic: .traitItalic case .strikethrough: nil case .underlined: nil + case .link: nil } } } @@ -127,6 +132,7 @@ public extension RichTextStyle { case .italic: .italic case .strikethrough: nil case .underlined: nil + case .link: nil } } } diff --git a/Sources/RichTextKit/Styles/View+RichTextStyle.swift b/Sources/RichTextKit/Styles/View+RichTextStyle.swift index 80ca8942a..8a1479978 100644 --- a/Sources/RichTextKit/Styles/View+RichTextStyle.swift +++ b/Sources/RichTextKit/Styles/View+RichTextStyle.swift @@ -24,6 +24,7 @@ public extension View { case .italic: keyboardShortcut("i", modifiers: .command) case .strikethrough: self case .underlined: keyboardShortcut("u", modifiers: .command) + case .link: keyboardShortcut("k", modifiers: .command) } #else self diff --git a/Sources/RichTextKit/_Essential/RichTextContext+Actions.swift b/Sources/RichTextKit/_Essential/RichTextContext+Actions.swift index 6abcbf466..dcce39af7 100644 --- a/Sources/RichTextKit/_Essential/RichTextContext+Actions.swift +++ b/Sources/RichTextKit/_Essential/RichTextContext+Actions.swift @@ -39,6 +39,7 @@ public extension RichTextContext { case .setHighlightingStyle: true case .setParagraphStyle: true case .setStyle: true + case .setLinkAttribute: true case .stepFontSize: true case .stepIndent: true case .stepLineSpacing: true diff --git a/Sources/RichTextKit/_Foundation/RichTextCoordinator+Actions.swift b/Sources/RichTextKit/_Foundation/RichTextCoordinator+Actions.swift index 705177eae..06d04c29c 100644 --- a/Sources/RichTextKit/_Foundation/RichTextCoordinator+Actions.swift +++ b/Sources/RichTextKit/_Foundation/RichTextCoordinator+Actions.swift @@ -35,6 +35,7 @@ extension RichTextCoordinator { case .setHighlightingStyle(let style): textView.highlightingStyle = style case .setParagraphStyle(let style): textView.setRichTextParagraphStyle(style) case .setStyle(let style, let newValue): setStyle(style, to: newValue) + case .setLinkAttribute(let url, let range): setLinkAttribute(url, range: range) case .stepFontSize(let points): textView.stepRichTextFontSize(points: points) syncContextWithTextView() @@ -159,6 +160,14 @@ extension RichTextCoordinator { if newValue == hasStyle { return } textView.setRichTextStyle(style, to: newValue) } + + func setLinkAttribute(_ url: URL?, range: NSRange) { + if let url = url { + textView.setRichTextAttribute(.link, to: url, at: range) + } else { + textView.setRichTextAttribute(.link, to: NSNull(), at: range) + } + } } extension ColorRepresentable { From 445217c4aea39161127f3aff07c88e06c73b1a12 Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 30 Apr 2025 19:31:41 -0400 Subject: [PATCH 3/5] working minus removing links --- Demo/Demo/DemoEditorScreen.swift | 64 +++--------------- .../Bridging/RichTextView_AppKit.swift | 9 +-- .../Extensions/RichTextContext+Link.swift | 29 ++++++--- .../Format/RichTextFormat+LinkInput.swift | 65 +++++++++++++++++++ .../Styles/RichTextStyle+Toggle.swift | 29 +++++++-- .../_Essential/RichTextContext.swift | 5 +- 6 files changed, 127 insertions(+), 74 deletions(-) create mode 100644 Sources/RichTextKit/Format/RichTextFormat+LinkInput.swift diff --git a/Demo/Demo/DemoEditorScreen.swift b/Demo/Demo/DemoEditorScreen.swift index d5d99bf25..e3329db7d 100644 --- a/Demo/Demo/DemoEditorScreen.swift +++ b/Demo/Demo/DemoEditorScreen.swift @@ -14,8 +14,6 @@ struct DemoEditorScreen: View { @Binding var document: DemoDocument @State private var isInspectorPresented = false - @State private var isLinkSheetPresented = false - @State private var linkURL = "" @StateObject var context = RichTextContext() @@ -56,28 +54,6 @@ struct DemoEditorScreen: View { } } - ToolbarItem(placement: .automatic) { - Button { - if context.hasLink { - context.removeLink() - } else { - isLinkSheetPresented = true - } - } label: { - Image(systemName: "link") - .foregroundColor(context.hasLink ? .accentColor : .primary) - } - .help("Add or remove link") - } - } - .sheet(isPresented: $isLinkSheetPresented) { - LinkSheet(url: $linkURL) { - if !linkURL.isEmpty { - context.setLink(url: linkURL) - linkURL = "" - } - isLinkSheetPresented = false - } } .frame(minWidth: 500) .focusedValue(\.richTextContext, context) @@ -90,39 +66,15 @@ struct DemoEditorScreen: View { ) ) .richTextFormatToolbarConfig(.init(colorPickers: [])) - .viewDebug() - } -} - -private struct LinkSheet: View { - @Binding var url: String - let onDismiss: () -> Void - - var body: some View { - VStack(spacing: 20) { - Text("Add Link") - .font(.headline) - - TextField("URL", text: $url) - .textFieldStyle(.roundedBorder) - .frame(width: 300) - - HStack { - Button("Cancel") { - url = "" - onDismiss() - } - .keyboardShortcut(.cancelAction) - - Button("Add") { - onDismiss() - } - .keyboardShortcut(.defaultAction) - .disabled(url.isEmpty) - } + .sheet(isPresented: $context.isLinkSheetPresented) { + RichTextFormat.LinkInput( + context: context, + isPresented: $context.isLinkSheetPresented + ) + #if macOS + .frame(width: 400) + #endif } - .padding() - .frame(width: 400, height: 150) } } diff --git a/Sources/RichTextKit/Bridging/RichTextView_AppKit.swift b/Sources/RichTextKit/Bridging/RichTextView_AppKit.swift index 553cd6266..aefb0891c 100644 --- a/Sources/RichTextKit/Bridging/RichTextView_AppKit.swift +++ b/Sources/RichTextKit/Bridging/RichTextView_AppKit.swift @@ -111,7 +111,8 @@ open class RichTextView: NSTextView, RichTextViewComponent { if let text = string as? String { let attributes = richTextAttributes(at: NSRange(location: max(0, currentRange.location - 1), length: 1)) - if attributes[.link] != nil || text.rangeOfCharacter(from: .whitespacesAndNewlines) != nil { + // 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) @@ -119,13 +120,13 @@ open class RichTextView: NSTextView, RichTextViewComponent { attrs.removeValue(forKey: .underlineColor) typingAttributes = attrs - // Also remove link attributes from the current position - if let textStorage = self.textStorage { + // 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 diff --git a/Sources/RichTextKit/Extensions/RichTextContext+Link.swift b/Sources/RichTextKit/Extensions/RichTextContext+Link.swift index c3bced568..c6a0932a8 100644 --- a/Sources/RichTextKit/Extensions/RichTextContext+Link.swift +++ b/Sources/RichTextKit/Extensions/RichTextContext+Link.swift @@ -16,8 +16,21 @@ public extension RichTextContext { public var hasLink: Bool { let range = selectedRange guard range.length > 0 else { return false } - let attributes = attributedString.attributes(at: range.location, effectiveRange: nil) - return attributes[.link] != nil + + // 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 @@ -26,7 +39,6 @@ public extension RichTextContext { /// - 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 -// guard range.length > 0 else { return } // Only apply changes if explicitly requested and different from current if hasLink { return } @@ -58,11 +70,12 @@ public extension RichTextContext { let range = selectedRange guard range.length > 0 else { return } - // Only apply changes if explicitly requested and different from current - if !hasLink { return } - - // Remove the link attribute - actionPublisher.send(.setLinkAttribute(nil, range)) + // Remove link from any part of the selection that has one + attributedString.enumerateAttributes(in: range, options: []) { attributes, subrange, _ in + if attributes[.link] != nil { + actionPublisher.send(.setLinkAttribute(nil, subrange)) + } + } } } diff --git a/Sources/RichTextKit/Format/RichTextFormat+LinkInput.swift b/Sources/RichTextKit/Format/RichTextFormat+LinkInput.swift new file mode 100644 index 000000000..32427df23 --- /dev/null +++ b/Sources/RichTextKit/Format/RichTextFormat+LinkInput.swift @@ -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 + ) { + 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) + } + } +} diff --git a/Sources/RichTextKit/Styles/RichTextStyle+Toggle.swift b/Sources/RichTextKit/Styles/RichTextStyle+Toggle.swift index 93647eb41..bcccf6fcd 100644 --- a/Sources/RichTextKit/Styles/RichTextStyle+Toggle.swift +++ b/Sources/RichTextKit/Styles/RichTextStyle+Toggle.swift @@ -35,6 +35,7 @@ public extension RichTextStyle { self.style = style self.value = value self.fillVertically = fillVertically + self.context = nil } /** @@ -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 private let fillVertically: Bool + private let context: RichTextContext? public var body: some View { #if os(tvOS) || os(watchOS) @@ -74,9 +90,12 @@ public extension RichTextStyle { style.icon .frame(maxHeight: fillVertically ? .infinity : nil) } + .toggleStyle(.button) .keyboardShortcut(for: style) .accessibilityLabel(style.title) } + + } } diff --git a/Sources/RichTextKit/_Essential/RichTextContext.swift b/Sources/RichTextKit/_Essential/RichTextContext.swift index f26c594ad..a00ecb968 100644 --- a/Sources/RichTextKit/_Essential/RichTextContext.swift +++ b/Sources/RichTextKit/_Essential/RichTextContext.swift @@ -51,7 +51,10 @@ public class RichTextContext: ObservableObject { /// Whether or not the text is currently being edited. @Published public var isEditingText = false - + + /// Whether or not the link input sheet is presented. + @Published public var isLinkSheetPresented = false + /// The current font name. @Published public var fontName = RichTextFont.PickerFont.all.first?.fontName ?? "" From d41a3d379d95bacf57b6ccda7957ef94b6615fbb Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 30 Apr 2025 19:42:42 -0400 Subject: [PATCH 4/5] Removing links works now --- .../Extensions/RichTextContext+Link.swift | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Sources/RichTextKit/Extensions/RichTextContext+Link.swift b/Sources/RichTextKit/Extensions/RichTextContext+Link.swift index c6a0932a8..4a3be59c4 100644 --- a/Sources/RichTextKit/Extensions/RichTextContext+Link.swift +++ b/Sources/RichTextKit/Extensions/RichTextContext+Link.swift @@ -70,12 +70,17 @@ public extension RichTextContext { let range = selectedRange guard range.length > 0 else { return } - // Remove link from any part of the selection that has one - attributedString.enumerateAttributes(in: range, options: []) { attributes, subrange, _ in - if attributes[.link] != nil { - actionPublisher.send(.setLinkAttribute(nil, subrange)) - } - } + // 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)) } } From 25dba8b2d7aea7ec0f5ef2a191c764d076b2fbef Mon Sep 17 00:00:00 2001 From: Justin Mitchell Date: Wed, 30 Apr 2025 20:09:26 -0400 Subject: [PATCH 5/5] set size of window and inspector for new icon --- Demo/Demo/DemoEditorScreen.swift | 2 +- Sources/RichTextKit/Format/RichTextFormat+Toolbar.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Demo/Demo/DemoEditorScreen.swift b/Demo/Demo/DemoEditorScreen.swift index e3329db7d..146afa2df 100644 --- a/Demo/Demo/DemoEditorScreen.swift +++ b/Demo/Demo/DemoEditorScreen.swift @@ -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 { diff --git a/Sources/RichTextKit/Format/RichTextFormat+Toolbar.swift b/Sources/RichTextKit/Format/RichTextFormat+Toolbar.swift index 699ea097f..a5732a516 100644 --- a/Sources/RichTextKit/Format/RichTextFormat+Toolbar.swift +++ b/Sources/RichTextKit/Format/RichTextFormat+Toolbar.swift @@ -68,7 +68,7 @@ public extension RichTextFormat { .environment(\.sizeCategory, .medium) .background(background) #if macOS - .frame(minWidth: 650) + .frame(minWidth: 750) #endif } }