From 382eb14114593906d8965b2a1c14502feb5030a8 Mon Sep 17 00:00:00 2001 From: Divyesh Canopas Date: Fri, 15 Nov 2024 15:05:18 +0530 Subject: [PATCH 1/4] Supported font size customization --- ...hTextAction+KeyboardShortcutModifier.swift | 57 ++++++ .../Actions/RichTextAction.swift | 80 +++++++- .../Actions/RichTextActionButton.swift | 61 ++++++ .../Alignment/RichTextAlignment.swift | 52 +++-- .../RichTextAttributes+RichTextStyle.swift | 1 - .../RichTextCoordinator+Actions.swift | 179 ++++++++--------- .../Colors/RichTextColor.swift | 36 +++- .../Data/Models/RichAttributes.swift | 134 ++++++++++--- .../Fonts/RichTextFont+SizePicker.swift | 84 ++++++++ .../Fonts/RichTextFont+SizePickerConfig.swift | 63 ++++++ .../Fonts/RichTextFontSizePickerStack.swift | 111 ++++++++++ .../Images/Image+RichText.swift | 189 +++++++++--------- .../RichTextKeyboardToolbar+Style.swift | 148 +++++++------- .../Localization/RTEL10n.swift | 155 ++++++++++++++ .../UI/Context/RichEditorState.swift | 6 +- .../UI/Context/RichTextContext+Actions.swift | 40 ++++ .../UI/Context/RichTextContext+Color.swift | 47 +++++ .../UI/Editor/RichEditorState+Spans.swift | 85 ++++---- .../UI/Editor/TextSpanStyle.swift | 98 ++++++++- ...orTool.swift => EditorTextStyleTool.swift} | 6 +- .../UI/EditorToolBar/EditorToolBarView.swift | 69 +++---- .../RichTextAction+ButtonStack.swift | 58 ++++++ .../UI/Extensions/Color+Extension.swift | 95 +++++++++ .../UI/Extensions/Image+Label.swift | 20 ++ .../UI/Extensions/String+Characters.swift | 23 +++ .../View+BackportSupportExtension.swift | 28 +++ .../UI/Views/RichTextLabelValue.swift | 31 +++ .../RichEditorSwiftUITests.swift | 1 + 28 files changed, 1539 insertions(+), 418 deletions(-) create mode 100644 Sources/RichEditorSwiftUI/Actions/RichTextAction+KeyboardShortcutModifier.swift create mode 100644 Sources/RichEditorSwiftUI/Actions/RichTextActionButton.swift create mode 100644 Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift create mode 100644 Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePickerConfig.swift create mode 100644 Sources/RichEditorSwiftUI/Fonts/RichTextFontSizePickerStack.swift create mode 100644 Sources/RichEditorSwiftUI/Localization/RTEL10n.swift create mode 100644 Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Actions.swift create mode 100644 Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift rename Sources/RichEditorSwiftUI/UI/EditorToolBar/{EditorTool.swift => EditorTextStyleTool.swift} (96%) create mode 100644 Sources/RichEditorSwiftUI/UI/EditorToolBar/RichTextAction+ButtonStack.swift create mode 100644 Sources/RichEditorSwiftUI/UI/Extensions/Color+Extension.swift create mode 100644 Sources/RichEditorSwiftUI/UI/Extensions/Image+Label.swift create mode 100644 Sources/RichEditorSwiftUI/UI/Extensions/View+BackportSupportExtension.swift create mode 100644 Sources/RichEditorSwiftUI/UI/Views/RichTextLabelValue.swift diff --git a/Sources/RichEditorSwiftUI/Actions/RichTextAction+KeyboardShortcutModifier.swift b/Sources/RichEditorSwiftUI/Actions/RichTextAction+KeyboardShortcutModifier.swift new file mode 100644 index 0000000..0b439f0 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Actions/RichTextAction+KeyboardShortcutModifier.swift @@ -0,0 +1,57 @@ +// +// RichTextAction+KeyboardShortcutModifier.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 30/10/24. +// + +import SwiftUI + +public extension RichTextAction { + + /** + This view modifier can apply keyboard shortcuts for any + ``RichTextAction`` to any view. + + You can also apply it with the `.keyboardShortcut(for:)` + view modifier. + */ + struct KeyboardShortcutModifier: ViewModifier { + + public init(_ action: RichTextAction) { + self.action = action + } + + private let action: RichTextAction + + public func body(content: Content) -> some View { + content.keyboardShortcut(for: action) + } + } +} + +public extension View { + + /// Apply a ``RichTextAction/KeyboardShortcutModifier``. + @ViewBuilder + func keyboardShortcut(for action: RichTextAction) -> some View { +#if iOS || macOS || os(visionOS) + switch action { + case .copy: keyboardShortcut("c", modifiers: .command) + case .dismissKeyboard: self + case .print: keyboardShortcut("p", modifiers: .command) + case .redoLatestChange: keyboardShortcut("z", modifiers: [.command, .shift]) +// case .setAlignment(let align): keyboardShortcut(for: align) + case .stepFontSize(let points): keyboardShortcut(points < 0 ? "-" : "+", modifiers: .command) + case .stepIndent(let steps): keyboardShortcut(steps < 0 ? "Ö" : "Ä", modifiers: .command) + case .stepSuperscript: self +// case .toggleStyle(let style): keyboardShortcut(for: style) + case .undoLatestChange: keyboardShortcut("z", modifiers: .command) + default: self // TODO: Probably not defined, object to discuss. + } +#else + self +#endif + } +} + diff --git a/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift b/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift index d018ed8..1b1b24b 100644 --- a/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift +++ b/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift @@ -78,7 +78,7 @@ public enum RichTextAction: Identifiable, Equatable { case undoLatestChange /// Set HeaderStyle. - case setHeaderStyle(_ style: RichTextStyle, range: NSRange) + case setHeaderStyle(_ style: RichTextStyle) } public extension RichTextAction { @@ -86,10 +86,84 @@ public extension RichTextAction { typealias Publisher = PassthroughSubject /// The action's unique identifier. - var id: String { UUID().uuidString } + var id: String { title } /// The action's standard icon. - + var icon: Image { + switch self { + case .copy: .richTextCopy + case .dismissKeyboard: .richTextDismissKeyboard +// case .pasteImage: .richTextDocuments +// case .pasteImages: .richTextDocuments +// case .pasteText: .richTextDocuments + case .print: .richTextPrint + case .redoLatestChange: .richTextRedo + case .selectRange: .richTextSelection + case .setAlignment(let val): val.icon + case .setAttributedString: .richTextDocument + case .setColor(let color, _): color.icon + case .setHighlightedRange: .richTextAlignmentCenter + case .setHighlightingStyle: .richTextAlignmentCenter + case .setStyle(let style, _): style.icon + case .stepFontSize(let val): .richTextStepFontSize(val) + case .stepIndent(let val): .richTextStepIndent(val) + case .stepLineSpacing(let val): .richTextStepLineSpacing(val) + case .stepSuperscript(let val): .richTextStepSuperscript(val) + case .toggleStyle(let val): val.icon + case .undoLatestChange: .richTextUndo + case .setHeaderStyle: .richTextIgnoreIt + } + } + + /// The localized label to use for the action. + var label: some View { + icon.label(title) + } + + /// The localized title to use in the main menu. + var menuTitle: String { + menuTitleKey.text + } + + /// The localized title key to use in the main menu. + var menuTitleKey: RTEL10n { + switch self { + case .stepIndent(let points): .menuIndent(points) + default: titleKey + } + } + + /// The localized action title. + var title: String { + titleKey.text + } + + /// The localized action title key. + var titleKey: RTEL10n { + switch self { + case .copy: .actionCopy + case .dismissKeyboard: .actionDismissKeyboard +// case .pasteImage: .pasteImage +// case .pasteImages: .pasteImages +// case .pasteText: .pasteText + case .print: .actionPrint + case .redoLatestChange: .actionRedoLatestChange + case .selectRange: .selectRange + case .setAlignment(let alignment): alignment.titleKey + case .setAttributedString: .setAttributedString + case .setColor(let color, _): color.titleKey + case .setHighlightedRange: .highlightedRange + case .setHighlightingStyle: .highlightingStyle + case .setStyle(let style, _): style.titleKey + case .stepFontSize(let points): .actionStepFontSize(points) + case .stepIndent(let points): .actionStepIndent(points) + case .stepLineSpacing(let points): .actionStepLineSpacing(points) + case .stepSuperscript(let steps): .actionStepSuperscript(steps) + case .toggleStyle(let style): style.titleKey + case .undoLatestChange: .actionUndoLatestChange + case .setHeaderStyle: .ignoreIt + } + } } // MARK: - Aliases diff --git a/Sources/RichEditorSwiftUI/Actions/RichTextActionButton.swift b/Sources/RichEditorSwiftUI/Actions/RichTextActionButton.swift new file mode 100644 index 0000000..2cc50a7 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Actions/RichTextActionButton.swift @@ -0,0 +1,61 @@ +// +// RichTextActionButton.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +import SwiftUI + +public extension RichTextAction { + + /** + This button can be used to trigger a ``RichTextAction``. + + This renders a plain `Button`, which means that you can + use and configure it as a normal button. + */ + struct Button: View { + /** + Create a rich text action button. + + - Parameters: + - action: The action to trigger. + - context: The context to affect. + - fillVertically: WhetherP or not fill up vertical space, by default `false`. + */ + public init( + action: RichTextAction, + context: RichEditorState, + fillVertically: Bool = false + ) { + self.action = action + self._context = ObservedObject(wrappedValue: context) + self.fillVertically = fillVertically + } + + private let action: RichTextAction + private let fillVertically: Bool + + @ObservedObject + private var context: RichEditorState + + public var body: some View { + SwiftUI.Button(action: triggerAction) { + action.label + .labelStyle(.iconOnly) + .frame(maxHeight: fillVertically ? .infinity : nil) + .contentShape(Rectangle()) + } + .keyboardShortcut(for: action) + .disabled(!context.canHandle(action)) + } + } +} + +private extension RichTextAction.Button { + + func triggerAction() { + context.handle(action) + } +} diff --git a/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift b/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift index 7aef541..38e9b20 100644 --- a/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift +++ b/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift @@ -53,38 +53,54 @@ public extension RichTextAlignment { var id: String { rawValue } /// The standard icon to use for the alignment. -// var icon: Image { nativeAlignment.icon } + var icon: Image { nativeAlignment.icon } /// The standard title to use for the alignment. -// var title: String { nativeAlignment.title } + var title: String { nativeAlignment.title } /// The standard title key to use for the alignment. -// var titleKey: RTKL10n { nativeAlignment.titleKey } + var titleKey: RTEL10n { nativeAlignment.titleKey } /// The native alignment of the alignment. var nativeAlignment: NSTextAlignment { switch self { - case .left: .left - case .right: .right - case .center: .center - case .justified: .justified + case .left: .left + case .right: .right + case .center: .center + case .justified: .justified } } } -//extension NSTextAlignment: RichTextLabelValue {} +extension NSTextAlignment: RichTextLabelValue {} public extension NSTextAlignment { -// /// The standard icon to use for the alignment. -// var icon: Image { -// switch self { -// case .left: .richTextAlignmentLeft -// case .right: .richTextAlignmentRight -// case .center: .richTextAlignmentCenter -// case .justified: .richTextAlignmentJustified -// default: .richTextAlignmentLeft -// } -// } + /// The standard icon to use for the alignment. + var icon: Image { + switch self { + case .left: .richTextAlignmentLeft + case .right: .richTextAlignmentRight + case .center: .richTextAlignmentCenter + case .justified: .richTextAlignmentJustified + default: .richTextAlignmentLeft + } + } + + /// The standard title to use for the alignment. + var title: String { + titleKey.text + } + /// The standard title key to use for the alignment. + var titleKey: RTEL10n { + switch self { + case .left: .textAlignmentLeft + case .right: .textAlignmentRight + case .center: .textAlignmentCentered + case .justified: .textAlignmentJustified + default: .textAlignmentLeft + } + } } + diff --git a/Sources/RichEditorSwiftUI/Attributes/RichTextAttributes+RichTextStyle.swift b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributes+RichTextStyle.swift index c5c9367..90b6df6 100644 --- a/Sources/RichEditorSwiftUI/Attributes/RichTextAttributes+RichTextStyle.swift +++ b/Sources/RichEditorSwiftUI/Attributes/RichTextAttributes+RichTextStyle.swift @@ -8,7 +8,6 @@ import Foundation public extension RichTextAttributes { - /** Whether or not the attributes has a strikethrough style. */ diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift index 4774a9b..9d55266 100644 --- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift +++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift @@ -15,102 +15,103 @@ extension RichTextCoordinator { func handle(_ action: RichTextAction?) { guard let action else { return } switch action { - case .copy: textView.copySelection() - case .dismissKeyboard: - textView.resignFirstResponder() -// case .pasteImage(let image): -// pasteImage(image) -// case .pasteImages(let images): -// pasteImages(images) -// case .pasteText(let text): -// pasteText(text) - case .print: - break - case .redoLatestChange: - textView.redoLatestChange() - syncContextWithTextView() - case .selectRange(let range): - setSelectedRange(to: range) - case .setAlignment(let alignment): -//// textView.setRichTextAlignment(alignment) - return - case .setAttributedString(let string): - setAttributedString(to: string) - case .setColor(let color, let newValue): - setColor(color, to: newValue) - case .setHighlightedRange(let range): - setHighlightedRange(to: range) - case .setHighlightingStyle(let style): - textView.highlightingStyle = style - case .setStyle(let style, let newValue): - setStyle(style, to: newValue) - return - case .stepFontSize(let points): - textView.stepRichTextFontSize(points: points) - syncContextWithTextView() - return - case .stepIndent(let points): -// textView.stepRichTextIndent(points: points) - return - case .stepLineSpacing(let points): -// textView.stepRichTextLineSpacing(points: points) - return - case .stepSuperscript(let points): -// textView.stepRichTextSuperscriptLevel(points: points) - return - case .toggleStyle(let style): -// textView.toggleRichTextStyle(style) - return - case .undoLatestChange: - textView.undoLatestChange() - syncContextWithTextView() - case .setHeaderStyle(let style, let range): - let size = style.fontSizeMultiplier * .standardRichTextFontSize - var font = textView.richTextFont(at: range) - font = font?.withSize(size) - textView - .setRichTextFont(font ?? .standardRichTextFont, at: range) + case .copy: textView.copySelection() + case .dismissKeyboard: + textView.resignFirstResponder() + // case .pasteImage(let image): + // pasteImage(image) + // case .pasteImages(let images): + // pasteImages(images) + // case .pasteText(let text): + // pasteText(text) + case .print: + break + case .redoLatestChange: + textView.redoLatestChange() + syncContextWithTextView() + case .selectRange(let range): + setSelectedRange(to: range) + case .setAlignment(let alignment): + //// textView.setRichTextAlignment(alignment) + return + case .setAttributedString(let string): + setAttributedString(to: string) + case .setColor(let color, let newValue): + setColor(color, to: newValue) + case .setHighlightedRange(let range): + setHighlightedRange(to: range) + case .setHighlightingStyle(let style): + textView.highlightingStyle = style + case .setStyle(let style, let newValue): + setStyle(style, to: newValue) + return + case .stepFontSize(let points): + textView.stepRichTextFontSize(points: points) + syncContextWithTextView() + return + case .stepIndent(let points): + // textView.stepRichTextIndent(points: points) + return + case .stepLineSpacing(let points): + // textView.stepRichTextLineSpacing(points: points) + return + case .stepSuperscript(let points): + // textView.stepRichTextSuperscriptLevel(points: points) + return + case .toggleStyle(let style): + // textView.toggleRichTextStyle(style) + return + case .undoLatestChange: + textView.undoLatestChange() + syncContextWithTextView() + case .setHeaderStyle(let style): + let size = style.fontSizeMultiplier * .standardRichTextFontSize + let range = textView.text.getHeaderRangeFor(textView.selectedRange) + var font = textView.richTextFont(at: range) + font = font?.withSize(size) + textView + .setRichTextFont(font ?? .standardRichTextFont, at: range) } } } extension RichTextCoordinator { -// func paste(_ data: RichTextInsertion) { -// if let data = data as? RichTextInsertion { -// pasteImage(data) -// } else if let data = data as? RichTextInsertion<[ImageRepresentable]> { -// pasteImages(data) -// } else if let data = data as? RichTextInsertion { -// pasteText(data) -// } else { -// print("Unsupported media type") -// } -// } -// -// func pasteImage(_ data: RichTextInsertion) { -// textView.pasteImage( -// data.content, -// at: data.index, -// moveCursorToPastedContent: data.moveCursor -// ) -// } -// -// func pasteImages(_ data: RichTextInsertion<[ImageRepresentable]>) { -// textView.pasteImages( -// data.content, -// at: data.index, -// moveCursorToPastedContent: data.moveCursor -// ) -// } + // func paste(_ data: RichTextInsertion) { + // if let data = data as? RichTextInsertion { + // pasteImage(data) + // } else if let data = data as? RichTextInsertion<[ImageRepresentable]> { + // pasteImages(data) + // } else if let data = data as? RichTextInsertion { + // pasteText(data) + // } else { + // print("Unsupported media type") + // } + // } + // + // func pasteImage(_ data: RichTextInsertion) { + // textView.pasteImage( + // data.content, + // at: data.index, + // moveCursorToPastedContent: data.moveCursor + // ) + // } + // + // func pasteImages(_ data: RichTextInsertion<[ImageRepresentable]>) { + // textView.pasteImages( + // data.content, + // at: data.index, + // moveCursorToPastedContent: data.moveCursor + // ) + // } -// func pasteText(_ data: RichTextInsertion) { -// textView.pasteText( -// data.content, -// at: data.index, -// moveCursorToPastedContent: data.moveCursor -// ) -// } + // func pasteText(_ data: RichTextInsertion) { + // textView.pasteText( + // data.content, + // at: data.index, + // moveCursorToPastedContent: data.moveCursor + // ) + // } func setAttributedString(to newValue: NSAttributedString?) { guard let newValue else { return } diff --git a/Sources/RichEditorSwiftUI/Colors/RichTextColor.swift b/Sources/RichEditorSwiftUI/Colors/RichTextColor.swift index e532cda..50b4d84 100644 --- a/Sources/RichEditorSwiftUI/Colors/RichTextColor.swift +++ b/Sources/RichEditorSwiftUI/Colors/RichTextColor.swift @@ -38,14 +38,35 @@ public extension RichTextColor { /// The corresponding rich text attribute, if any. var attribute: NSAttributedString.Key? { switch self { - case .foreground: .foregroundColor - case .background: .backgroundColor - case .strikethrough: .strikethroughColor - case .stroke: .strokeColor - case .underline: .underlineColor + case .foreground: .foregroundColor + case .background: .backgroundColor + case .strikethrough: .strikethroughColor + case .stroke: .strokeColor + case .underline: .underlineColor } } + /// The standard icon to use for the color. + var icon: Image { + switch self { + case .foreground: .richTextColorForeground + case .background: .richTextColorBackground + case .strikethrough: .richTextColorStrikethrough + case .stroke: .richTextColorStroke + case .underline: .richTextColorUnderline + } + } + + /// The localized color title key. + var titleKey: RTEL10n { + switch self { + case .foreground: .foregroundColor + case .background: .backgroundColor + case .strikethrough: .strikethroughColor + case .stroke: .strokeColor + case .underline: .underlineColor + } + } /// Adjust a `color` for a certain `colorScheme`. func adjust( @@ -53,8 +74,8 @@ public extension RichTextColor { for scheme: ColorScheme ) -> Color { switch self { - case .background: color ?? .clear - default: color ?? .primary + case .background: color ?? .clear + default: color ?? .primary } } } @@ -63,3 +84,4 @@ public extension Collection where Element == RichTextColor { static var allCases: [RichTextColor] { Element.allCases } } + diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift index 38dfb14..37a4afe 100644 --- a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift +++ b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift @@ -17,6 +17,10 @@ public struct RichAttributes: Codable { public let header: HeaderType? public let list: ListType? public let indent: Int? + public let size: Int? + public let font: String? + public let color: String? + public let background: String? public init( // id: String = UUID().uuidString, @@ -26,7 +30,11 @@ public struct RichAttributes: Codable { strike: Bool? = nil, header: HeaderType? = nil, list: ListType? = nil, - indent: Int? = nil + indent: Int? = nil, + size: Int? = nil, + font: String? = nil, + color: String? = nil, + background: String? = nil ) { // self.id = id self.bold = bold @@ -36,6 +44,10 @@ public struct RichAttributes: Codable { self.header = header self.list = list self.indent = indent + self.size = size + self.font = font + self.color = color + self.background = background } enum CodingKeys: String, CodingKey { @@ -46,6 +58,10 @@ public struct RichAttributes: Codable { case header = "header" case list = "list" case indent = "indent" + case size = "size" + case font = "font" + case color = "color" + case background = "background" } public init(from decoder: Decoder) throws { @@ -58,6 +74,10 @@ public struct RichAttributes: Codable { self.header = try values.decodeIfPresent(HeaderType.self, forKey: .header) self.list = try values.decodeIfPresent(ListType.self, forKey: .list) self.indent = try values.decodeIfPresent(Int.self, forKey: .indent) + self.size = try values.decodeIfPresent(Int.self, forKey: .size) + self.font = try values.decodeIfPresent(String.self, forKey: .font) + self.color = try values.decodeIfPresent(String.self, forKey: .color) + self.background = try values.decodeIfPresent(String.self, forKey: .background) } } @@ -71,6 +91,10 @@ extension RichAttributes: Hashable { hasher.combine(header) hasher.combine(list) hasher.combine(indent) + hasher.combine(size) + hasher.combine(font) + hasher.combine(color) + hasher.combine(background) } } @@ -86,6 +110,10 @@ extension RichAttributes: Equatable { && lhs.header == rhs.header && lhs.list == rhs.list && lhs.indent == rhs.indent + && lhs.size == rhs.size + && lhs.font == rhs.font + && lhs.color == rhs.color + && lhs.background == rhs.background ) } } @@ -97,7 +125,11 @@ extension RichAttributes { underline: Bool? = nil, strike: Bool? = nil, list: ListType? = nil, - indent: Int? = nil + indent: Int? = nil, + size: Int? = nil, + font: String? = nil, + color: String? = nil, + background: String? = nil ) -> RichAttributes { return RichAttributes( bold: (bold != nil ? bold! : self.bold), @@ -106,7 +138,11 @@ extension RichAttributes { strike: (strike != nil ? strike! : self.strike), header: (header != nil ? header! : self.header), list: (list != nil ? list! : self.list), - indent: (indent != nil ? indent! : self.indent) + indent: (indent != nil ? indent! : self.indent), + size: (size != nil ? size! : self.size), + font: (font != nil ? font! : self.font), + color: (color != nil ? color! : self.color), + background: (background != nil ? background! : self.background) ) } @@ -123,7 +159,11 @@ extension RichAttributes { strike: (att.strike != nil ? (byAdding ? att.strike! : nil) : self.strike), header: (att.header != nil ? (byAdding ? att.header! : nil) : self.header), list: (att.list != nil ? (byAdding ? att.list! : nil) : self.list), - indent: (att.indent != nil ? (byAdding ? att.indent! : nil) : self.indent) + indent: (att.indent != nil ? (byAdding ? att.indent! : nil) : self.indent), + size: (att.size != nil ? (byAdding ? att.size! : nil) : self.size), + font: (att.font != nil ? (byAdding ? att.font! : nil) : self.font), + color: (att.color != nil ? (byAdding ? att.color! : nil) : self.color), + background: (att.background != nil ? (byAdding ? att.background! : nil) : self.background) ) } } @@ -149,6 +189,18 @@ extension RichAttributes { if let list = list { styles.append(list.getTextSpanStyle()) } + if let size = size { + styles.append(.size(size)) + } + if let font = font { + styles.append(.font(font)) + } + if let color = color { + styles.append(.color(.init(hex: color))) + } + if let background = background { + styles.append(.background(.init(hex: background))) + } return styles } @@ -179,30 +231,38 @@ extension RichAttributes { extension RichAttributes { public func hasStyle(style: RichTextStyle) -> Bool { switch style { - case .default: - return true - case .bold: - return bold ?? false - case .italic: - return italic ?? false - case .underline: - return underline ?? false - case .strikethrough: - return strike ?? false - case .h1: - return header == .h1 - case .h2: - return header == .h2 - case .h3: - return header == .h3 - case .h4: - return header == .h4 - case .h5: - return header == .h5 - case .h6: - return header == .h6 - case .bullet: - return list == .bullet(indent) + case .default: + return true + case .bold: + return bold ?? false + case .italic: + return italic ?? false + case .underline: + return underline ?? false + case .strikethrough: + return strike ?? false + case .h1: + return header == .h1 + case .h2: + return header == .h2 + case .h3: + return header == .h3 + case .h4: + return header == .h4 + case .h5: + return header == .h5 + case .h6: + return header == .h6 + case .bullet: + return list == .bullet(indent) + case .size(let size): + return size == size + case .font(let name): + return font == name + case .color(let colorItem): + return color == colorItem?.hexString + case .background(let color): + return background == color?.hexString } } } @@ -220,6 +280,10 @@ internal func getRichAttributesFor(styles: [RichTextStyle]) -> RichAttributes { var header: HeaderType? = nil var list: ListType? = nil var indent: Int? = nil + var size: Int? = nil + var font: String? = nil + var color: String? = nil + var background: String? = nil for style in styles { switch style { @@ -248,6 +312,14 @@ internal func getRichAttributesFor(styles: [RichTextStyle]) -> RichAttributes { indent = indentIndex case .default: header = .default + case .size(let fontSize): + size = fontSize + case .font(let name): + font = name + case .color(let textColor): + color = textColor?.hexString + case .background(let backgroundColor): + background = backgroundColor?.hexString } } return RichAttributes(bold: bold, @@ -256,6 +328,10 @@ internal func getRichAttributesFor(styles: [RichTextStyle]) -> RichAttributes { strike: strike, header: header, list: list, - indent: indent + indent: indent, + size: size, + font: font, + color: color, + background: background ) } diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift new file mode 100644 index 0000000..aaa46c0 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift @@ -0,0 +1,84 @@ +// +// RichTextFont+SizePicker.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +import SwiftUI + +public extension RichTextFont { + + /** + This picker can be used to pick a font size. + + The view returns a plain SwiftUI `Picker` view that can + be styled and configured with plain SwiftUI. + + You can configure this picker by applying a config view + modifier to your view hierarchy: + + ```swift + VStack { + RichTextFont.SizePicker(...) + ... + } + .richTextFontSizePickerConfig(...) + ``` + */ + struct SizePicker: View { + + /** + Create a font size picker. + + - Parameters: + - selection: The selected font size. + */ + public init( + selection: Binding + ) { + self._selection = selection + } + + @Binding + private var selection: CGFloat + + @Environment(\.richTextFontSizePickerConfig) + private var config + + public var body: some View { + Picker("", selection: $selection) { + ForEach(values( + for: config.values, + selection: selection + ), id: \.self) { + text(for: $0) + .tag($0) + } + } + } + } +} + +public extension RichTextFont.SizePicker { + + /// Get a list of values for a certain selection. + func values( + for values: [CGFloat], + selection: CGFloat + ) -> [CGFloat] { + let values = values + [selection] + return Array(Set(values)).sorted() + } +} + +private extension RichTextFont.SizePicker { + + func text( + for fontSize: CGFloat + ) -> some View { + Text("\(Int(fontSize))") + .fixedSize(horizontal: true, vertical: false) + } +} + diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePickerConfig.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePickerConfig.swift new file mode 100644 index 0000000..311d40b --- /dev/null +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePickerConfig.swift @@ -0,0 +1,63 @@ +// +// RichTextFont+SizePickerConfig.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +import SwiftUI + +public extension RichTextFont { + + /// This type can configure a ``RichTextFont/SizePicker``. + struct SizePickerConfig { + + /// Create a custom font size picker config. + /// + /// - Parameters: + /// - values: The values to display in the list, by default a standard list. + public init( + values: [CGFloat] = [10, 12, 14, 16, 18, 20, 22, 24, 28, 36, 48, 64, 72, 96, 144] + ) { + self.values = values + } + + /// The values to display in the list. + public var values: [CGFloat] + } +} + +public extension RichTextFont.SizePickerConfig { + + /// The standard font size picker configuration. + /// + /// You can set a new value to change the global default. + static var standard = Self() +} + +public extension View { + + /// Apply a ``RichTextFont`` size picker configuration. + func richTextFontSizePickerConfig( + _ config: RichTextFont.SizePickerConfig + ) -> some View { + self.environment(\.richTextFontSizePickerConfig, config) + } +} + +private extension RichTextFont.SizePickerConfig { + + struct Key: EnvironmentKey { + + public static var defaultValue: RichTextFont.SizePickerConfig = .standard + } +} + +public extension EnvironmentValues { + + /// This value can bind to a font size picker config. + var richTextFontSizePickerConfig: RichTextFont.SizePickerConfig { + get { self [RichTextFont.SizePickerConfig.Key.self] } + set { self [RichTextFont.SizePickerConfig.Key.self] = newValue } + } +} diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFontSizePickerStack.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFontSizePickerStack.swift new file mode 100644 index 0000000..3a2af5d --- /dev/null +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFontSizePickerStack.swift @@ -0,0 +1,111 @@ +// +// RichTextFontSizePickerStack.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +#if iOS || macOS || os(visionOS) +import SwiftUI + +public extension RichTextFont { + + /** + This view uses a ``RichTextFont/SizePicker`` and button + steppers to increment and a decrement the font size. + + You can configure this picker by applying a config view + modifier to your view hierarchy: + + ```swift + VStack { + RichTextFont.SizePickerStack(...) + ... + } + .richTextFontSizePickerConfig(...) + ``` + */ + struct SizePickerStack: View { + + /** + Create a rich text font size picker stack. + + - Parameters: + - context: The context to affect. + */ + public init( + context: RichEditorState + ) { + self._context = ObservedObject(wrappedValue: context) + } + + private let step = 1 + + @ObservedObject + private var context: RichEditorState + + public var body: some View { +#if iOS || os(visionOS) + stack + .fixedSize(horizontal: false, vertical: true) +#else + HStack(spacing: 3) { + picker + stepper + } + .overlay(macShortcutOverlay) +#endif + } + } +} + +private extension RichTextFont.SizePickerStack { + + var macShortcutOverlay: some View { + stack + .opacity(0) + .allowsHitTesting(false) + } + + var stack: some View { + HStack(spacing: 2) { + stepButton(-step) + picker + stepButton(step) + } + } + + func stepButton(_ points: Int) -> some View { + RichTextAction.Button( + action: .stepFontSize(points: points), + context: context, + fillVertically: true + ) + } + + var picker: some View { + RichTextFont.SizePicker( + selection: $context.fontSize + ) + .onChangeBackPort(of: context.fontSize) { newValue in + context.updateStyle(style: .size(Int(context.fontSize))) + } + } + + var stepper: some View { + Stepper( + "", + onIncrement: increment, + onDecrement: decrement + ) + } + + func decrement() { + context.fontSize -= CGFloat(step) + } + + func increment() { + context.fontSize += CGFloat(step) + } +} +#endif diff --git a/Sources/RichEditorSwiftUI/Images/Image+RichText.swift b/Sources/RichEditorSwiftUI/Images/Image+RichText.swift index 543acd2..cc51aa4 100644 --- a/Sources/RichEditorSwiftUI/Images/Image+RichText.swift +++ b/Sources/RichEditorSwiftUI/Images/Image+RichText.swift @@ -7,97 +7,98 @@ import SwiftUI -//public extension Image { -// -// static let richTextCopy = symbol("doc.on.clipboard") -// static let richTextDismissKeyboard = symbol("keyboard.chevron.compact.down") -// static let richTextEdit = symbol("square.and.pencil") -// static let richTextExport = symbol("square.and.arrow.up.on.square") -// static let richTextPrint = symbol("printer") -// static let richTextRedo = symbol("arrow.uturn.forward") -// static let richTextShare = symbol("square.and.arrow.up") -// static let richTextUndo = symbol("arrow.uturn.backward") -// -// static let richTextAlignmentCenter = symbol("text.aligncenter") -// static let richTextAlignmentJustified = symbol("text.justify") -// static let richTextAlignmentLeft = symbol("text.alignleft") -// static let richTextAlignmentRight = symbol("text.alignright") -// -// static let richTextColorBackground = symbol("highlighter") -// static let richTextColorForeground = symbol("character") -// static let richTextColorReset = symbol("circle.slash") -// static let richTextColorStroke = symbol("a.square") -// static let richTextColorStrikethrough = symbol("strikethrough") -// static let richTextColorUnderline = symbol("underline") -// static let richTextColorUndefined = symbol("questionmark.app") -// -// static let richTextDocument = symbol("doc.text") -// static let richTextDocuments = symbol("doc.on.doc") -// -// static let richTextFont = symbol("textformat") -// static let richTextFontSizeDecrease = symbol("minus") -// static let richTextFontSizeIncrease = symbol("plus") -// -// static let richTextFormat = symbol("textformat") -// static let richTextFormatBrush = symbol("paintbrush") -// -// static let richTextIndentDecrease = symbol("decrease.indent") -// static let richTextIndentIncrease = symbol("increase.indent") -// -// static let richTextLineSpacing = symbol("arrow.up.and.down.text.horizontal") -// static let richTextLineSpacingDecrease = symbol("minus") -// static let richTextLineSpacingIncrease = symbol("plus") -// -// static let richTextSelection = symbol("123.rectangle.fill") -// -// static let richTextStyleBold = symbol("bold") -// static let richTextStyleItalic = symbol("italic") -// static let richTextStyleStrikethrough = symbol("strikethrough") -// static let richTextStyleUnderline = symbol("underline") -// -// static let richTextSuperscriptDecrease = symbol("textformat.subscript") -// static let richTextSuperscriptIncrease = symbol("textformat.superscript") -//} -// -//public extension Image { -// -// static func richTextStepFontSize( -// _ points: Int -// ) -> Image { -// points < 0 ? -// .richTextFontSizeDecrease : -// .richTextFontSizeIncrease -// } -// -// static func richTextStepIndent( -// _ points: Double -// ) -> Image { -// points < 0 ? -// .richTextIndentDecrease : -// .richTextIndentIncrease -// } -// -// static func richTextStepLineSpacing( -// _ points: Double -// ) -> Image { -// points < 0 ? -// .richTextLineSpacingDecrease : -// .richTextLineSpacingIncrease -// } -// -// static func richTextStepSuperscript( -// _ steps: Int -// ) -> Image { -// steps < 0 ? -// .richTextSuperscriptDecrease : -// .richTextSuperscriptIncrease -// } -//} -// -//extension Image { -// -// static func symbol(_ name: String) -> Image { -// .init(systemName: name) -// } -//} -// +public extension Image { + + static let richTextCopy = symbol("doc.on.clipboard") + static let richTextDismissKeyboard = symbol("keyboard.chevron.compact.down") + static let richTextEdit = symbol("square.and.pencil") + static let richTextExport = symbol("square.and.arrow.up.on.square") + static let richTextPrint = symbol("printer") + static let richTextRedo = symbol("arrow.uturn.forward") + static let richTextShare = symbol("square.and.arrow.up") + static let richTextUndo = symbol("arrow.uturn.backward") + + static let richTextAlignmentCenter = symbol("text.aligncenter") + static let richTextAlignmentJustified = symbol("text.justify") + static let richTextAlignmentLeft = symbol("text.alignleft") + static let richTextAlignmentRight = symbol("text.alignright") + + static let richTextColorBackground = symbol("highlighter") + static let richTextColorForeground = symbol("character") + static let richTextColorReset = symbol("circle.slash") + static let richTextColorStroke = symbol("a.square") + static let richTextColorStrikethrough = symbol("strikethrough") + static let richTextColorUnderline = symbol("underline") + static let richTextColorUndefined = symbol("questionmark.app") + + static let richTextDocument = symbol("doc.text") + static let richTextDocuments = symbol("doc.on.doc") + + static let richTextFont = symbol("textformat") + static let richTextFontSizeDecrease = symbol("minus") + static let richTextFontSizeIncrease = symbol("plus") + + static let richTextFormat = symbol("textformat") + static let richTextFormatBrush = symbol("paintbrush") + + static let richTextIndentDecrease = symbol("decrease.indent") + static let richTextIndentIncrease = symbol("increase.indent") + + static let richTextLineSpacing = symbol("arrow.up.and.down.text.horizontal") + static let richTextLineSpacingDecrease = symbol("minus") + static let richTextLineSpacingIncrease = symbol("plus") + + static let richTextSelection = symbol("123.rectangle.fill") + + static let richTextStyleBold = symbol("bold") + static let richTextStyleItalic = symbol("italic") + static let richTextStyleStrikethrough = symbol("strikethrough") + static let richTextStyleUnderline = symbol("underline") + + static let richTextSuperscriptDecrease = symbol("textformat.subscript") + static let richTextSuperscriptIncrease = symbol("textformat.superscript") + static let richTextIgnoreIt = symbol("") +} + +public extension Image { + + static func richTextStepFontSize( + _ points: Int + ) -> Image { + points < 0 ? + .richTextFontSizeDecrease : + .richTextFontSizeIncrease + } + + static func richTextStepIndent( + _ points: Double + ) -> Image { + points < 0 ? + .richTextIndentDecrease : + .richTextIndentIncrease + } + + static func richTextStepLineSpacing( + _ points: Double + ) -> Image { + points < 0 ? + .richTextLineSpacingDecrease : + .richTextLineSpacingIncrease + } + + static func richTextStepSuperscript( + _ steps: Int + ) -> Image { + steps < 0 ? + .richTextSuperscriptDecrease : + .richTextSuperscriptIncrease + } +} + +extension Image { + + static func symbol(_ name: String) -> Image { + .init(systemName: name) + } +} + diff --git a/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar+Style.swift b/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar+Style.swift index 6c540da..88833b7 100644 --- a/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar+Style.swift +++ b/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar+Style.swift @@ -5,77 +5,77 @@ // Created by Divyesh Vekariya on 22/10/24. // -//#if iOS || macOS || os(visionOS) -//import SwiftUI -// -///// This struct can style a ``RichTextKeyboardToolbar``. -//public struct RichTextKeyboardToolbarStyle { -// -// /// Create a custom toolbar style -// /// -// /// - Parameters: -// /// - toolbarHeight: The height of the toolbar, by default `50`. -// /// - itemSpacing: The spacing between toolbar items, by default `15`. -// /// - shadowColor: The toolbar's shadow color, by default transparent black. -// /// - shadowRadius: The toolbar's shadow radius, by default `3`. -// public init( -// toolbarHeight: Double = 50, -// itemSpacing: Double = 15, -// shadowColor: Color = .black.opacity(0.1), -// shadowRadius: Double = 3 -// ) { -// self.toolbarHeight = toolbarHeight -// self.itemSpacing = itemSpacing -// self.shadowColor = shadowColor -// self.shadowRadius = shadowRadius -// } -// -// /// The height of the toolbar. -// public var toolbarHeight: Double -// -// /// The spacing between toolbar items. -// public var itemSpacing: Double -// -// /// The toolbar's shadow color. -// public var shadowColor: Color -// -// /// The toolbar's shadow radius. -// public var shadowRadius: Double -//} -// -//public extension RichTextKeyboardToolbarStyle { -// -// /// The standard rich text keyboard toolbar style. -// /// -// /// You can set a new value to change the global default. -// static var standard = Self() -//} -// -//public extension View { -// -// /// Apply a ``RichTextKeyboardToolbar`` style. -// func richTextKeyboardToolbarStyle( -// _ style: RichTextKeyboardToolbarStyle -// ) -> some View { -// self.environment(\.richTextKeyboardToolbarStyle, style) -// } -//} -// -//private extension RichTextKeyboardToolbarStyle { -// -// struct Key: EnvironmentKey { -// -// static var defaultValue: RichTextKeyboardToolbarStyle = .standard -// } -//} -// -//public extension EnvironmentValues { -// -// /// This value can bind to a keyboard toolbar style. -// var richTextKeyboardToolbarStyle: RichTextKeyboardToolbarStyle { -// get { self [RichTextKeyboardToolbarStyle.Key.self] } -// set { self [RichTextKeyboardToolbarStyle.Key.self] = newValue } -// } -//} -// -//#endif +#if iOS || macOS || os(visionOS) +import SwiftUI + +/// This struct can style a ``RichTextKeyboardToolbar``. +public struct RichTextKeyboardToolbarStyle { + + /// Create a custom toolbar style + /// + /// - Parameters: + /// - toolbarHeight: The height of the toolbar, by default `50`. + /// - itemSpacing: The spacing between toolbar items, by default `15`. + /// - shadowColor: The toolbar's shadow color, by default transparent black. + /// - shadowRadius: The toolbar's shadow radius, by default `3`. + public init( + toolbarHeight: Double = 50, + itemSpacing: Double = 15, + shadowColor: Color = .black.opacity(0.1), + shadowRadius: Double = 3 + ) { + self.toolbarHeight = toolbarHeight + self.itemSpacing = itemSpacing + self.shadowColor = shadowColor + self.shadowRadius = shadowRadius + } + + /// The height of the toolbar. + public var toolbarHeight: Double + + /// The spacing between toolbar items. + public var itemSpacing: Double + + /// The toolbar's shadow color. + public var shadowColor: Color + + /// The toolbar's shadow radius. + public var shadowRadius: Double +} + +public extension RichTextKeyboardToolbarStyle { + + /// The standard rich text keyboard toolbar style. + /// + /// You can set a new value to change the global default. + static var standard = Self() +} + +public extension View { + + /// Apply a ``RichTextKeyboardToolbar`` style. + func richTextKeyboardToolbarStyle( + _ style: RichTextKeyboardToolbarStyle + ) -> some View { + self.environment(\.richTextKeyboardToolbarStyle, style) + } +} + +private extension RichTextKeyboardToolbarStyle { + + struct Key: EnvironmentKey { + + static var defaultValue: RichTextKeyboardToolbarStyle = .standard + } +} + +public extension EnvironmentValues { + + /// This value can bind to a keyboard toolbar style. + var richTextKeyboardToolbarStyle: RichTextKeyboardToolbarStyle { + get { self [RichTextKeyboardToolbarStyle.Key.self] } + set { self [RichTextKeyboardToolbarStyle.Key.self] = newValue } + } +} + +#endif diff --git a/Sources/RichEditorSwiftUI/Localization/RTEL10n.swift b/Sources/RichEditorSwiftUI/Localization/RTEL10n.swift new file mode 100644 index 0000000..0b1d23c --- /dev/null +++ b/Sources/RichEditorSwiftUI/Localization/RTEL10n.swift @@ -0,0 +1,155 @@ +// +// RTEL10n.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +import SwiftUI + +/// This enum defines RichTextKit-specific, localized texts. +public enum RTEL10n: String, CaseIterable, Identifiable { + + case + done, + more, + + font, + fontSize, + fontSizeIncrease, + fontSizeIncreaseDescription, + fontSizeDecrease, + fontSizeDecreaseDescription, + + setHeaderStyle, + + color, + foregroundColor, + backgroundColor, + underlineColor, + strikethroughColor, + strokeColor, + + actionCopy, + actionDismissKeyboard, + actionPrint, + actionRedoLatestChange, + actionUndoLatestChange, + + fileFormatRtk, + fileFormatPdf, + fileFormatRtf, + fileFormatTxt, + + indent, + indentIncrease, + indentIncreaseDescription, + indentDecrease, + indentDecreaseDescription, + + lineSpacing, + lineSpacingIncrease, + lineSpacingIncreaseDescription, + lineSpacingDecrease, + lineSpacingDecreaseDescription, + + menuExport, + menuExportAs, + menuFormat, + menuPrint, + menuSave, + menuSaveAs, + menuShare, + menuShareAs, + menuText, + + highlightedRange, + highlightingStyle, + + pasteImage, + pasteImages, + pasteText, + selectRange, + + setAttributedString, + + styleBold, + styleItalic, + styleStrikethrough, + styleUnderlined, + + superscript, + superscriptIncrease, + superscriptIncreaseDescription, + superscriptDecrease, + superscriptDecreaseDescription, + + textAlignment, + textAlignmentLeft, + textAlignmentRight, + textAlignmentCentered, + textAlignmentJustified, + + ignoreIt +} + +public extension RTEL10n { + + static func actionStepFontSize( + _ points: Int + ) -> RTEL10n { + points < 0 ? + .fontSizeDecreaseDescription : + .fontSizeIncreaseDescription + } + + static func actionStepIndent( + _ points: Double + ) -> RTEL10n { + points < 0 ? + .indentDecreaseDescription : + .indentIncreaseDescription + } + + static func actionStepLineSpacing( + _ points: CGFloat + ) -> RTEL10n { + points < 0 ? + .lineSpacingDecreaseDescription : + .lineSpacingIncreaseDescription + } + + static func actionStepSuperscript( + _ steps: Int + ) -> RTEL10n { + steps < 0 ? + .superscriptDecreaseDescription : + .superscriptIncreaseDescription + } + + static func menuIndent(_ points: Double) -> RTEL10n { + points < 0 ? + .indentDecrease : + .indentIncrease + } +} + +public extension RTEL10n { + + /// The item's unique identifier. + var id: String { rawValue } + + /// The item's localization key. + var key: String { rawValue } + + /// The item's localized text. + var text: String { + rawValue + } + + /// Get the localized text for a certain `Locale`. +// func text(for locale: Locale) -> String { +// guard let bundle = Bundle.module.bundle(for: locale) else { return "" } +// return NSLocalizedString(key, bundle: bundle, comment: "") +// } +} diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift index 052cf0d..a5bd8d7 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift @@ -183,7 +183,11 @@ public class RichEditorState: ObservableObject { let adapter = DefaultAdapter() self.adapter = adapter - self.attributedString = NSMutableAttributedString(string: input) + + let str = NSMutableAttributedString(string: input) + + str.addAttributes([.font: currentFont], range: str.richTextRange) + self.attributedString = str self.internalSpans = [.init(from: 0, to: input.utf16Length > 0 ? input.utf16Length - 1 : 0, attributes: RichAttributes())] diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Actions.swift b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Actions.swift new file mode 100644 index 0000000..93ff750 --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Actions.swift @@ -0,0 +1,40 @@ +// +// RichTextContext+Actions.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +import SwiftUI + +public extension RichEditorState { + + /// Handle a certain rich text action. + func handle(_ action: RichTextAction) { + switch action { +// case .stepFontSize(let size): +// fontSize += CGFloat(size) +// updateStyle(style: .size(Int(fontSize))) + default: actionPublisher.send(action) + } + } + + /// Check if the context can handle a certain action. + func canHandle(_ action: RichTextAction) -> Bool { + switch action { + case .copy: canCopy + // case .pasteImage: true + // case .pasteImages: true + // case .pasteText: true + case .print: false + case .redoLatestChange: canRedoLatestChange + case .undoLatestChange: canUndoLatestChange + default: true + } + } + + /// Trigger a certain rich text action. + func trigger(_ action: RichTextAction) { + handle(action) + } +} diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift new file mode 100644 index 0000000..edc686a --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift @@ -0,0 +1,47 @@ +// +// RichTextContext+Color.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +import SwiftUI + +public extension RichEditorState { + + /// Get a binding for a certain color. + func binding(for color: RichTextColor) -> Binding { + Binding( + get: { Color(self.color(for: color) ?? .clear) }, + set: { self.setColor(color, to: .init($0)) } + ) + } + + /// Get the value for a certain color. + func color(for color: RichTextColor) -> ColorRepresentable? { + colors[color] + } + + /// Set the value for a certain color. + func setColor( + _ color: RichTextColor, + to val: ColorRepresentable + ) { + guard self.color(for: color) != val else { return } + actionPublisher.send(.setColor(color, val)) + setColorInternal(color, to: val) + } +} + +extension RichEditorState { + + /// Set the value for a certain color, or remove it. + func setColorInternal( + _ color: RichTextColor, + to val: ColorRepresentable? + ) { + guard let val else { return colors[color] = nil } + colors[color] = val + } +} + diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift index ecaa94c..02ad6cc 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift @@ -42,7 +42,6 @@ extension RichEditorState { - style: is of type TextSpanStyle */ public func toggleStyle(style: TextSpanStyle) { -// toggleStyle(style) if activeStyles.contains(style) { setInternalStyles(style: style, add: false) removeStyle(style) @@ -58,8 +57,8 @@ extension RichEditorState { - style: is of type TextSpanStyle */ public func updateStyle(style: TextSpanStyle) { - setStyle(style) setInternalStyles(style: style) + setStyle(style) } /** @@ -81,13 +80,13 @@ extension RichEditorState { internal func onTextViewEvent(_ event: TextViewEvents) { switch event { case .didChangeSelection(let range, let text): - selectedRange = range - guard rawText.count == text.string.count && selectedRange.isCollapsed else { - return - } + selectedRange = range + guard rawText.count == text.string.count && selectedRange.isCollapsed else { + return + } onSelectionDidChanged() case .didBeginEditing(let range, _): - selectedRange = range + selectedRange = range case .didChange: onTextFieldValueChange(newText: attributedString, selection: selectedRange) case .didEndEditing: @@ -138,7 +137,12 @@ extension RichEditorState { if style.isHeaderStyle || style.isDefault || style.isList { handleAddOrRemoveHeaderOrListStyle(in: selectedRange, style: style, byAdding: !style.isDefault) } else if !selectedRange.isCollapsed { - processSpansFor(new: style, in: selectedRange) + var addStyle = true + if case .size(let size) = style, let size, CGFloat(size) == CGFloat.standardRichTextFontSize { + addStyle = false + } + + processSpansFor(new: style, in: selectedRange, addStyle: addStyle) } updateCurrentSpanStyle() @@ -166,20 +170,6 @@ extension RichEditorState { activeAttributes = attributes } - - /** - This will take style in argument and Toggle it - - Parameters: - - style: which is of type TextSpanStyle - It will add style if not in activeStyle or remove is it is. - */ -// private func toggleStyle(_ style: TextSpanStyle) { -// if activeStyles.contains(style) { -// removeStyle(style) -// } else { -// addStyle(style) -// } -// } } //MARK: - Add styles @@ -485,30 +475,10 @@ extension RichEditorState { private func handleAddOrRemoveHeaderOrListStyle(in range: NSRange, style: TextSpanStyle, byAdding: Bool = true) { guard !rawText.isEmpty else { return } - let range = style.isList ? getListRangeFor(range, in: rawText) : getHeaderRangeFor(range, in: rawText) + let range = style.isList ? getListRangeFor(range, in: rawText) : rawText.getHeaderRangeFor(range) processSpansFor(new: style, in: range, addStyle: byAdding) } - private func getHeaderRangeFor(_ range: NSRange, in text: String) -> NSRange { - guard !text.isEmpty else { return range } - - let fromIndex = range.lowerBound - let toIndex = range.isCollapsed ? fromIndex : range.upperBound - - let newLineStartIndex = text.utf16.prefix(fromIndex).map({ $0 }).lastIndex(of: "\n".utf16.last) ?? 0 - let newLineEndIndex = text.utf16.suffix(from: text.utf16.index(text.utf16.startIndex, offsetBy: max(0, toIndex - 1))).map({ $0 }).firstIndex(of: "\n".utf16.last) - - let startIndex = max(0, newLineStartIndex) - var endIndex = (toIndex) + (newLineEndIndex ?? 0) - - if newLineEndIndex == nil { - endIndex = (text.utf16Length) - } - - let range = startIndex...endIndex - return range.nsRange - } - /** This will remove header style form selected range of text - Parameters: @@ -779,13 +749,28 @@ extension RichEditorState { extension RichEditorState { func setInternalStyles(style: RichTextStyle, add: Bool = true) { switch style { - case .bold, .italic, .underline, .strikethrough: - setStyle(style, to: add) - case .h1, .h2, .h3, .h4, .h5, .h6, .default: - let range = getHeaderRangeFor(selectedRange, in: attributedString.string) - actionPublisher.send(.setHeaderStyle(style, range: range)) - case .bullet(_): - return + case .bold, .italic, .underline, .strikethrough: + setStyle(style, to: add) + case .h1, .h2, .h3, .h4, .h5, .h6, .default: + actionPublisher.send(.setHeaderStyle(style)) + case .bullet(_): + return + case .size(let size): + if let size, fontSize != CGFloat(size) { + self.fontSize = CGFloat(size) + } + case .font(let fontName): + if let fontName { + self.fontName = fontName + } + case .color(let color): + if let color { + setColor(.foreground, to: .init(color)) + } + case .background(let color): + if let color { + setColor(.background, to: .init(color)) + } } } } diff --git a/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift b/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift index 4b4d421..e9f6273 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift @@ -9,7 +9,7 @@ import SwiftUI public typealias RichTextStyle = TextSpanStyle -public enum TextSpanStyle: Equatable, Codable, CaseIterable, Hashable { +public enum TextSpanStyle: Equatable, CaseIterable, Hashable { public static var allCases: [TextSpanStyle] = [ .default, @@ -23,7 +23,11 @@ public enum TextSpanStyle: Equatable, Codable, CaseIterable, Hashable { .h4, .h5, .h6, - .bullet() + .bullet(), + .size(), + .font(), + .color(), + .background() ] public func hash(into hasher: inout Hasher) { @@ -47,6 +51,10 @@ public enum TextSpanStyle: Equatable, Codable, CaseIterable, Hashable { case h6 case bullet(_ indent: Int? = nil) // case ordered(_ indent: Int? = nil) + case size(Int? = nil) + case font(String? = nil) + case color(Color? = nil) + case background(Color? = nil) var key: String { switch self { @@ -76,6 +84,14 @@ public enum TextSpanStyle: Equatable, Codable, CaseIterable, Hashable { return "bullet" // case .ordered: // return "ordered" + case .size: + return "size" + case .font: + return "font" + case .color: + return "color" + case .background: + return "background" } } @@ -90,6 +106,26 @@ public enum TextSpanStyle: Equatable, Codable, CaseIterable, Hashable { return getListStyleAttributeValue(listType ?? .bullet(), indent: indent) case .strikethrough: return NSUnderlineStyle.single.rawValue + case .size(let size): + if let fontSize = size { + return getFontWithUpdating(font: font) + .withSize(CGFloat(fontSize)) + } else { + return getFontWithUpdating(font: font) + } + case .font(let name): + if let name { + return FontRepresentable( + name: name, + size: .standardRichTextFontSize + ) ?? font + } else { + return RichTextView.Theme.standard.font + } + case .color: + return RichTextView.Theme.standard.fontColor + case .background: + return ColorRepresentable.white } } @@ -97,12 +133,16 @@ public enum TextSpanStyle: Equatable, Codable, CaseIterable, Hashable { switch self { case .underline: return .underlineStyle - case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6: + case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6, .size, .font: return .font case .bullet: return .paragraphStyle case .strikethrough: return .strikethroughStyle + case .color: + return .foregroundColor + case .background: + return .backgroundColor } } @@ -110,7 +150,29 @@ public enum TextSpanStyle: Equatable, Codable, CaseIterable, Hashable { return lhs.key == rhs.key } - var editorTools: EditorTool? { + /// The standard icon to use for the trait. + var icon: Image { + switch self { + case .bold: .richTextStyleBold + case .italic: .richTextStyleItalic + case .strikethrough: .richTextStyleStrikethrough + case .underline: .richTextStyleUnderline + default: .richTextPrint + } + } + + /// The localized style title key. + var titleKey: RTEL10n { + switch self { + case .bold: .styleBold + case .italic: .styleItalic + case .underline: .styleUnderlined + case .strikethrough: .styleStrikethrough + default: .done + } + } + + var editorTools: EditorTextStyleTool? { switch self { case .default: return .none @@ -137,6 +199,8 @@ public enum TextSpanStyle: Equatable, Codable, CaseIterable, Hashable { return .header(.h5) case .h6: return .header(.h6) + default: + return .none } } @@ -182,7 +246,7 @@ public enum TextSpanStyle: Equatable, Codable, CaseIterable, Hashable { return font case .bold,.italic: return font.addFontStyle(self) - case .underline, .bullet, .strikethrough: + case .underline, .bullet, .strikethrough, .color, .background: return font case .h1: return font.updateFontSize(multiple: 1.5) @@ -196,6 +260,18 @@ public enum TextSpanStyle: Equatable, Codable, CaseIterable, Hashable { return font.updateFontSize(multiple: 1.1) case .h6: return font.updateFontSize(multiple: 1) + case .size(let size): + if let size { + return font.updateFontSize(size: CGFloat(size)) + } else { + return font + } + case .font(let name): + if let name { + return FontRepresentable(name: name, size: font.pointSize) ?? font + } else { + return font + } } } @@ -220,9 +296,9 @@ public enum TextSpanStyle: Equatable, Codable, CaseIterable, Hashable { switch self { case .bold, .italic, .bullet: return font.removeFontStyle(self) - case .underline, .strikethrough: + case .underline, .strikethrough, .color, .background: return font - case .default, .h1, .h2, .h3, .h4, .h5, .h6: + case .default, .h1, .h2, .h3, .h4, .h5, .h6, .size, .font: return font.updateFontSize(size: .standardRichTextFontSize) } } @@ -292,6 +368,14 @@ extension TextSpanStyle { return RichAttributes(header: .h5) case .h6: return RichAttributes(header: .h6) + case .size(let size): + return RichAttributes(size: size) + case .font(let font): + return RichAttributes(font: font) + case .color(let color): + return RichAttributes(color: color?.hexString) + case .background(let background): + return RichAttributes(background: background?.hexString) } } } diff --git a/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorTool.swift b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorTextStyleTool.swift similarity index 96% rename from Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorTool.swift rename to Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorTextStyleTool.swift index 3e6d4e6..dceff0e 100644 --- a/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorTool.swift +++ b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorTextStyleTool.swift @@ -1,5 +1,5 @@ // -// EditorTool.swift +// EditorTextStyleTool.swift // // // Created by Divyesh Vekariya on 19/12/23. @@ -7,7 +7,7 @@ import SwiftUI -enum EditorTool: CaseIterable, Hashable { +enum EditorTextStyleTool: CaseIterable, Hashable { func hash(into hasher: inout Hasher) { hasher.combine(key) @@ -17,7 +17,7 @@ enum EditorTool: CaseIterable, Hashable { // } } - static var allCases: [EditorTool] { + static var allCases: [EditorTextStyleTool] { return [ .header(), .bold, diff --git a/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift index 6cccd93..6caab90 100644 --- a/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift +++ b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift @@ -9,6 +9,7 @@ import SwiftUI public struct EditorToolBarView: View { @Environment(\.colorScheme) var colorScheme + @Environment(\.richTextKeyboardToolbarStyle) private var style @ObservedObject var state: RichEditorState @@ -22,15 +23,21 @@ public struct EditorToolBarView: View { public var body: some View { LazyHStack(spacing: 5, content: { - ForEach(EditorTool.allCases, id: \.self) { tool in - if tool.isContainManu { - TitleStyleButton(tool: tool, appliedTools: state.activeStyles, setStyle: state.updateStyle(style:)) - } else { -// if tool != .list() { + Section { + ForEach(EditorTextStyleTool.allCases, id: \.self) { tool in + if tool.isContainManu { + TitleStyleButton(tool: tool, appliedTools: state.activeStyles, setStyle: state.updateStyle(style:)) + } else { + // if tool != .list() { ToggleStyleButton(tool: tool, appliedTools: state.activeStyles, onToolSelect: state.toggleStyle(style:)) -// } + // } + } } } + Divider() + Section { + RichTextFont.SizePickerStack(context: state) + } }) .frame(height: 50) .padding(.horizontal, 15) @@ -43,7 +50,7 @@ public struct EditorToolBarView: View { private struct ToggleStyleButton: View { @Environment(\.colorScheme) var colorScheme - let tool: EditorTool + let tool: EditorTextStyleTool let appliedTools: Set let onToolSelect: (TextSpanStyle) -> Void @@ -79,7 +86,7 @@ private struct ToggleStyleButton: View { struct TitleStyleButton: View { @Environment(\.colorScheme) var colorScheme - let tool: EditorTool + let tool: EditorTextStyleTool let appliedTools: Set let setStyle: (TextSpanStyle) -> Void @@ -87,48 +94,26 @@ struct TitleStyleButton: View { tool.isSelected(appliedTools) } - @State var isExpanded: Bool = false - var normalDarkColor: Color { colorScheme == .dark ? .white : .black } - var body: some View { + @State var selection: HeaderType = .default - Menu(content: { + var body: some View { + Picker("", selection: $selection) { ForEach(HeaderType.allCases, id: \.self) { header in - Button(action: { - isExpanded = false - setStyle(EditorTool.header(header).getTextSpanStyle()) - }, label: { - if hasStyle(header.getTextSpanStyle()) { - Label(header.title, systemImage:"checkmark") - .foregroundColor(normalDarkColor) - } else { - Text(header.title) - } - }) + if hasStyle(header.getTextSpanStyle()) { + Label(header.title, systemImage:"checkmark") + .foregroundColor(normalDarkColor) + } else { + Text(header.title) + } } - }, label: { - HStack(alignment: .center, spacing: 4, content: { - Image(systemName: tool.systemImageName) - .font(.title) - - Image(systemName: "chevron.down") - .font(.subheadline) - }) - .foregroundColor(isSelected ? .blue : normalDarkColor) - .frame(width: 50, height: 40, alignment: .center) - .padding(.horizontal, 3) - .background(isSelected ? Color.gray.opacity(0.1) : Color.clear) - .cornerRadius(5) - .padding(.vertical, 5) - }) -#if !os(tvOS) - .onTapGesture { - isExpanded.toggle() } -#endif + .onChangeBackPort(of: selection) { newValue in + setStyle(EditorTextStyleTool.header(selection).getTextSpanStyle()) + } } diff --git a/Sources/RichEditorSwiftUI/UI/EditorToolBar/RichTextAction+ButtonStack.swift b/Sources/RichEditorSwiftUI/UI/EditorToolBar/RichTextAction+ButtonStack.swift new file mode 100644 index 0000000..60529a8 --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/EditorToolBar/RichTextAction+ButtonStack.swift @@ -0,0 +1,58 @@ +// +// RichTextAction+ButtonStack.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +import SwiftUI + +public extension RichTextAction { + + /** + This view lists ``RichTextAction`` buttons in a stack. + + Since this view uses multiple values, it binds directly + to a ``RichTextContext`` instead of individual values. + */ + struct ButtonStack: View { + + /** + Create a rich text action button stack. + + - Parameters: + - context: The context to affect. + - actions: The actions to list, by default all non-size actions. + - spacing: The spacing to apply to stack items, by default `5`. + */ + public init( + context: RichEditorState, + actions: [RichTextAction], + spacing: Double = 5 + ) { + self._context = ObservedObject(wrappedValue: context) + self.actions = actions + self.spacing = spacing + } + + private let actions: [RichTextAction] + private let spacing: Double + + @ObservedObject + private var context: RichEditorState + + public var body: some View { + HStack(spacing: spacing) { + ForEach(actions) { + RichTextAction.Button( + action: $0, + context: context, + fillVertically: true + ) + .frame(maxHeight: .infinity) + } + } + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/Color+Extension.swift b/Sources/RichEditorSwiftUI/UI/Extensions/Color+Extension.swift new file mode 100644 index 0000000..a6dc79b --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Extensions/Color+Extension.swift @@ -0,0 +1,95 @@ +// +// Color+Extension.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +import SwiftUI + +extension Color { + var hexString: String? { + // Convert to ColorRepresentable and get components + guard let components = ColorRepresentable(self).cgColor.components else { return nil } + + let r = components[0] + let g = components.count >= 3 ? components[1] : r + let b = components.count >= 3 ? components[2] : r + let a = components.count == 4 ? components[3] : 1.0 + + // Format the hex string with alpha if necessary + if a < 1.0 { + return String(format: "#%02lX%02lX%02lX%02lX", + lround(Double(r * 255)), + lround(Double(g * 255)), + lround(Double(b * 255)), + lround(Double(a * 255))) + } else { + return String(format: "#%02lX%02lX%02lX", + lround(Double(r * 255)), + lround(Double(g * 255)), + lround(Double(b * 255))) + } + } +} + + +public extension Color { + init(hex string: String) { + var string: String = string.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + if string.hasPrefix("#") { + _ = string.removeFirst() + } + // Double the last value if incomplete hex + if !string.count.isMultiple(of: 2), let last = string.last { + string.append(last) + } + // Fix invalid values + if string.count > 8 { + string = String(string.prefix(8)) + } + // Scanner creation + let scanner = Scanner(string: string) + + var color: UInt64 = 0 + scanner.scanHexInt64(&color) + + if string.count == 2 { + let mask = 0xFF + let g = Int(color) & mask + let gray = Double(g) / 255.0 + self.init(.sRGB, red: gray, green: gray, blue: gray, opacity: 1) + } else if string.count == 4 { + let mask = 0x00FF + let g = Int(color >> 8) & mask + let a = Int(color) & mask + let gray = Double(g) / 255.0 + let alpha = Double(a) / 255.0 + self.init(.sRGB, red: gray, green: gray, blue: gray, opacity: alpha) + } else if string.count == 6 { + let mask = 0x0000FF + let r = Int(color >> 16) & mask + let g = Int(color >> 8) & mask + let b = Int(color) & mask + + let red = Double(r) / 255.0 + let green = Double(g) / 255.0 + let blue = Double(b) / 255.0 + self.init(.sRGB, red: red, green: green, blue: blue, opacity: 1) + } else if string.count == 8 { + let mask = 0x000000FF + let r = Int(color >> 24) & mask + let g = Int(color >> 16) & mask + let b = Int(color >> 8) & mask + let a = Int(color) & mask + let red = Double(r) / 255.0 + let green = Double(g) / 255.0 + let blue = Double(b) / 255.0 + let alpha = Double(a) / 255.0 + self.init(.sRGB, red: red, green: green, blue: blue, opacity: alpha) + } else { + self.init(.sRGB, red: 1, green: 1, blue: 1, opacity: 1) + } + } +} + diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/Image+Label.swift b/Sources/RichEditorSwiftUI/UI/Extensions/Image+Label.swift new file mode 100644 index 0000000..c48d887 --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Extensions/Image+Label.swift @@ -0,0 +1,20 @@ +// +// Image+Label.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +import SwiftUI + +extension Image { + + /// Create a label from the icon. + func label(_ title: String) -> some View { + Label { + Text(title) + } icon: { + self + } + } +} diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/String+Characters.swift b/Sources/RichEditorSwiftUI/UI/Extensions/String+Characters.swift index b556e46..a7d7c59 100644 --- a/Sources/RichEditorSwiftUI/UI/Extensions/String+Characters.swift +++ b/Sources/RichEditorSwiftUI/UI/Extensions/String+Characters.swift @@ -37,3 +37,26 @@ internal extension String { /// Get the string for a ` ` space. static let space = String(.space) } + +internal extension String { + func getHeaderRangeFor(_ range: NSRange) -> NSRange { + let text = self + guard !text.isEmpty else { return range } + + let fromIndex = range.lowerBound + let toIndex = range.isCollapsed ? fromIndex : range.upperBound + + let newLineStartIndex = text.utf16.prefix(fromIndex).map({ $0 }).lastIndex(of: "\n".utf16.last) ?? 0 + let newLineEndIndex = text.utf16.suffix(from: text.utf16.index(text.utf16.startIndex, offsetBy: max(0, toIndex - 1))).map({ $0 }).firstIndex(of: "\n".utf16.last) + + let startIndex = max(0, newLineStartIndex) + var endIndex = (toIndex) + (newLineEndIndex ?? 0) + + if newLineEndIndex == nil { + endIndex = (text.utf16Length) + } + + let range = startIndex...endIndex + return range.nsRange + } +} diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/View+BackportSupportExtension.swift b/Sources/RichEditorSwiftUI/UI/Extensions/View+BackportSupportExtension.swift new file mode 100644 index 0000000..c63b361 --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Extensions/View+BackportSupportExtension.swift @@ -0,0 +1,28 @@ +// +// View+BackportSupportExtension.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 30/10/24. +// + +import SwiftUI + +extension View { + nonisolated public func onChangeBackPort(of value: V, _ action: @escaping (_ newValue: V) -> Void) -> some View where V : Equatable { + Group { + if #available(iOS 17.0, *) { + self + //iOS17~ + .onChange(of: value) { oldValue, newValue in + action(newValue) + } + } else { + //up to iOS16 + self + .onChange(of: value) { newValue in + action(newValue) + } + } + } + } +} diff --git a/Sources/RichEditorSwiftUI/UI/Views/RichTextLabelValue.swift b/Sources/RichEditorSwiftUI/UI/Views/RichTextLabelValue.swift new file mode 100644 index 0000000..7d2aed4 --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Views/RichTextLabelValue.swift @@ -0,0 +1,31 @@ +// +// RichTextLabelValue.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 30/10/24. +// + +import SwiftUI + +/// This protocol can be implemented by any rich text values +/// that can be represented as a label. +public protocol RichTextLabelValue: Hashable { + + /// The value icon. + var icon: Image { get } + + /// The value display title. + var title: String { get } +} + +public extension RichTextLabelValue { + + /// The standard label to use for the value. + var label: some View { + Label( + title: { Text(title) }, + icon: { icon } + ) + .tag(self) + } +} diff --git a/Tests/RichEditorSwiftUITests/RichEditorSwiftUITests.swift b/Tests/RichEditorSwiftUITests/RichEditorSwiftUITests.swift index fdccc25..2f4c621 100644 --- a/Tests/RichEditorSwiftUITests/RichEditorSwiftUITests.swift +++ b/Tests/RichEditorSwiftUITests/RichEditorSwiftUITests.swift @@ -10,3 +10,4 @@ final class RichEditorSwiftUITests: XCTestCase { // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods } } +// \ No newline at end of file From 9a13aa35bd0c622fd345610c63fd4800c7532e15 Mon Sep 17 00:00:00 2001 From: Divyesh Canopas Date: Fri, 15 Nov 2024 15:35:04 +0530 Subject: [PATCH 2/4] updated gitlab work flow --- .github/workflows/build.yaml | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index aa54d40..7d26224 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -21,7 +21,15 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: '16.0' - - name: Build all platforms - run: bash scripts/build.sh ${{ github.event.repository.name }} + - name: Build iOS + run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=iOS'; + - name: Build macOS + run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=OS X'; + - name: Build tvOS + run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=tvOS'; + - name: Build watchOS + run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=watchOS'; + - name: Build visionOS + run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=xrOS'; - name: Test iOS - run: bash scripts/test.sh ${{ github.event.repository.name }} + run: xcodebuild test -scheme $SCHEME -derivedDataPath .build -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.0' -enableCodeCoverage YES; From 2dc098898f49aed3ce13c059fb77bb8844624dfd Mon Sep 17 00:00:00 2001 From: Divyesh Canopas Date: Mon, 18 Nov 2024 10:51:35 +0530 Subject: [PATCH 3/4] updated tool bar and workflow --- .github/workflows/build.yaml | 12 ++--- .../RichEditorDemo.xcodeproj/project.pbxproj | 8 +-- .../RichEditorDemo/ContentView.swift | 2 + .../RichTextCoordinator+Actions.swift | 4 +- .../UI/EditorToolBar/EditorToolBarView.swift | 49 ++++++++++++++----- .../View+BackportSupportExtension.swift | 2 +- .../UI/TextViewUI/RichTextView_AppKit.swift | 6 +++ .../UI/TextViewUI/RichTextView_UIKit.swift | 6 +++ 8 files changed, 65 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 7d26224..255e579 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -22,14 +22,14 @@ jobs: with: xcode-version: '16.0' - name: Build iOS - run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=iOS'; + run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=iOS' | xcpretty --color; - name: Build macOS - run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=OS X'; + run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=OS X' | xcpretty --color; - name: Build tvOS - run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=tvOS'; + run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=tvOS' | xcpretty --color; - name: Build watchOS - run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=watchOS'; + run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=watchOS' | xcpretty --color; - name: Build visionOS - run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=xrOS'; + run: xcodebuild -scheme $SCHEME -derivedDataPath .build -destination 'generic/platform=xrOS' | xcpretty --color; - name: Test iOS - run: xcodebuild test -scheme $SCHEME -derivedDataPath .build -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.0' -enableCodeCoverage YES; + run: xcodebuild test -scheme $SCHEME -derivedDataPath .build -destination 'platform=iOS Simulator,name=iPhone 16 Pro,OS=18.0' -enableCodeCoverage YES | xcpretty --color; diff --git a/RichEditorDemo/RichEditorDemo.xcodeproj/project.pbxproj b/RichEditorDemo/RichEditorDemo.xcodeproj/project.pbxproj index 875ff8c..21eeb44 100644 --- a/RichEditorDemo/RichEditorDemo.xcodeproj/project.pbxproj +++ b/RichEditorDemo/RichEditorDemo.xcodeproj/project.pbxproj @@ -314,11 +314,11 @@ PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = NO; SDKROOT = auto; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,3"; + TARGETED_DEVICE_FAMILY = "1,2,3,7"; TVOS_DEPLOYMENT_TARGET = 17.0; }; name = Debug; @@ -353,11 +353,11 @@ PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = NO; SDKROOT = auto; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,3"; + TARGETED_DEVICE_FAMILY = "1,2,3,7"; TVOS_DEPLOYMENT_TARGET = 17.0; }; name = Release; diff --git a/RichEditorDemo/RichEditorDemo/ContentView.swift b/RichEditorDemo/RichEditorDemo/ContentView.swift index 2c78d10..7819e6c 100644 --- a/RichEditorDemo/RichEditorDemo/ContentView.swift +++ b/RichEditorDemo/RichEditorDemo/ContentView.swift @@ -29,7 +29,9 @@ struct ContentView: View { var body: some View { NavigationStack { VStack { +#if os(iOS) || os(macOS) || os(visionOS) EditorToolBarView(state: state) +#endif RichTextEditor( context: _state, diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift index 9d55266..63a5064 100644 --- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift +++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift @@ -66,7 +66,9 @@ extension RichTextCoordinator { syncContextWithTextView() case .setHeaderStyle(let style): let size = style.fontSizeMultiplier * .standardRichTextFontSize - let range = textView.text.getHeaderRangeFor(textView.selectedRange) + let range = textView.textString.getHeaderRangeFor( + textView.selectedRange + ) var font = textView.richTextFont(at: range) font = font?.withSize(size) textView diff --git a/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift index 6caab90..b40aa79 100644 --- a/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift +++ b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift @@ -7,6 +7,7 @@ import SwiftUI +#if iOS || macOS || os(visionOS) public struct EditorToolBarView: View { @Environment(\.colorScheme) var colorScheme @Environment(\.richTextKeyboardToolbarStyle) private var style @@ -25,25 +26,34 @@ public struct EditorToolBarView: View { LazyHStack(spacing: 5, content: { Section { ForEach(EditorTextStyleTool.allCases, id: \.self) { tool in - if tool.isContainManu { - TitleStyleButton(tool: tool, appliedTools: state.activeStyles, setStyle: state.updateStyle(style:)) - } else { - // if tool != .list() { - ToggleStyleButton(tool: tool, appliedTools: state.activeStyles, onToolSelect: state.toggleStyle(style:)) - // } + Group { + if tool.isContainManu { + TitleStyleButton(tool: tool, appliedTools: state.activeStyles, setStyle: state.updateStyle(style:)) + RTEVDivider() + } else { + // if tool != .list() { + ToggleStyleButton(tool: tool, appliedTools: state.activeStyles, onToolSelect: state.toggleStyle(style:)) + // } + } } } } - Divider() + + RTEVDivider() + Section { RichTextFont.SizePickerStack(context: state) } }) - .frame(height: 50) - .padding(.horizontal, 15) + #if os(iOS) + .frame(height: 40) + #else + .frame(height: 40) + #endif + .padding(.horizontal) .frame(maxWidth: .infinity, alignment: .leading) .background(selectedColor) - .clipShape(.capsule) + .cornerRadius(6) } } @@ -72,13 +82,18 @@ private struct ToggleStyleButton: View { }, label: { HStack(alignment: .center, spacing: 4, content: { Image(systemName: tool.systemImageName) - .font(.title) + #if os(iOS) + .frame(width: 25, height: 25) + #else + .frame(width: 20, height: 20) + #endif }) .foregroundColor(isSelected ? .blue : normalDarkColor) +#if os(iOS) .frame(width: 40, height: 40, alignment: .center) + #endif .background(isSelected ? selectedColor : Color.clear) .cornerRadius(5) - .padding(.vertical, 5) }) } } @@ -121,3 +136,13 @@ struct TitleStyleButton: View { return appliedTools.contains(where: { $0.key == style.key }) } } + +struct RTEVDivider: View { + var body: some View { + Rectangle() + .frame(width: 1) + .foregroundColor(.secondary) + .padding(.vertical) + } +} +#endif diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/View+BackportSupportExtension.swift b/Sources/RichEditorSwiftUI/UI/Extensions/View+BackportSupportExtension.swift index c63b361..3567c9c 100644 --- a/Sources/RichEditorSwiftUI/UI/Extensions/View+BackportSupportExtension.swift +++ b/Sources/RichEditorSwiftUI/UI/Extensions/View+BackportSupportExtension.swift @@ -10,7 +10,7 @@ import SwiftUI extension View { nonisolated public func onChangeBackPort(of value: V, _ action: @escaping (_ newValue: V) -> Void) -> some View where V : Equatable { Group { - if #available(iOS 17.0, *) { + if #available(iOS 17.0, macOS 14.0, *) { self //iOS17~ .onChange(of: value) { oldValue, newValue in diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift index 8404bcf..cf61ea0 100644 --- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift +++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_AppKit.swift @@ -222,4 +222,10 @@ public extension RichTextView { } } +extension RichTextView { + var textString: String { + return self.string + } +} + #endif diff --git a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift index 4b103b6..bf2df81 100644 --- a/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift +++ b/Sources/RichEditorSwiftUI/UI/TextViewUI/RichTextView_UIKit.swift @@ -394,4 +394,10 @@ public extension RichTextView { textStorage } } + +extension RichTextView { + var textString: String { + return self.text ?? "" + } +} #endif From e6fedd3bc9126dd0445fa0067c75849957a5f07a Mon Sep 17 00:00:00 2001 From: Divyesh Canopas Date: Mon, 18 Nov 2024 10:55:21 +0530 Subject: [PATCH 4/4] Fixed warnings --- .../BaseFoundation/RichTextCoordinator+Actions.swift | 10 +++++----- .../RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift index 63a5064..3fe6919 100644 --- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift +++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift @@ -31,7 +31,7 @@ extension RichTextCoordinator { syncContextWithTextView() case .selectRange(let range): setSelectedRange(to: range) - case .setAlignment(let alignment): + case .setAlignment(_): //// textView.setRichTextAlignment(alignment) return case .setAttributedString(let string): @@ -49,16 +49,16 @@ extension RichTextCoordinator { textView.stepRichTextFontSize(points: points) syncContextWithTextView() return - case .stepIndent(let points): + case .stepIndent(_): // textView.stepRichTextIndent(points: points) return - case .stepLineSpacing(let points): + case .stepLineSpacing(_): // textView.stepRichTextLineSpacing(points: points) return - case .stepSuperscript(let points): + case .stepSuperscript(_): // textView.stepRichTextSuperscriptLevel(points: points) return - case .toggleStyle(let style): + case .toggleStyle(_): // textView.toggleRichTextStyle(style) return case .undoLatestChange: diff --git a/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift b/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift index e9f6273..3e18da8 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift @@ -184,7 +184,7 @@ public enum TextSpanStyle: Equatable, CaseIterable, Hashable { return .underline case .strikethrough: return .strikethrough - case .bullet(let indent): + case .bullet(_): // return .list(.bullet(indent)) return nil case .h1: