diff --git a/Demo/Demo/DemoEditorScreen.swift b/Demo/Demo/DemoEditorScreen.swift index c4d05cb1a..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 { @@ -53,6 +53,7 @@ struct DemoEditorScreen: View { .aspectRatio(1, contentMode: .fit) } } + } .frame(minWidth: 500) .focusedValue(\.richTextContext, context) @@ -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 + } } } 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/Bridging/RichTextView_AppKit.swift b/Sources/RichTextKit/Bridging/RichTextView_AppKit.swift index 533f568b2..aefb0891c 100644 --- a/Sources/RichTextKit/Bridging/RichTextView_AppKit.swift +++ b/Sources/RichTextKit/Bridging/RichTextView_AppKit.swift @@ -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 /** diff --git a/Sources/RichTextKit/Extensions/RichTextContext+Link.swift b/Sources/RichTextKit/Extensions/RichTextContext+Link.swift new file mode 100644 index 000000000..4a3be59c4 --- /dev/null +++ b/Sources/RichTextKit/Extensions/RichTextContext+Link.swift @@ -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.. + ) { + 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/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 } } 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+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/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/_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 ?? "" 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 {