From 00a402cb993d809b08b57eb203c090e1202459ca Mon Sep 17 00:00:00 2001 From: Divyesh Canopas Date: Wed, 8 Jan 2025 15:46:18 +0530 Subject: [PATCH 1/6] Fixed unwanted view update on selection change --- .../Fonts/RichTextFont+ListPicker.swift | 64 ++-- .../Fonts/RichTextFont+Picker.swift | 4 +- .../Fonts/RichTextFont+SizePicker.swift | 76 ++-- .../Fonts/RichTextFontSizePickerStack.swift | 151 ++++---- .../Format/RichTextFormat+Sheet.swift | 186 +++++----- .../Format/RichTextFormat+Sidebar.swift | 226 ++++++----- .../Format/RichTextFormat+Toolbar.swift | 5 +- .../Format/RichTextFormatToolbarBase.swift | 350 +++++++++--------- .../Keyboard/RichTextKeyboardToolbar.swift | 2 +- .../UI/Context/RichTextContext+Font.swift | 32 ++ .../UI/Context/RichTextContext+FontSize.swift | 32 ++ 11 files changed, 590 insertions(+), 538 deletions(-) create mode 100644 Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Font.swift create mode 100644 Sources/RichEditorSwiftUI/UI/Context/RichTextContext+FontSize.swift diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+ListPicker.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+ListPicker.swift index 3699aae..df0ff9d 100644 --- a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+ListPicker.swift +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+ListPicker.swift @@ -9,7 +9,7 @@ import SwiftUI extension RichTextFont { - /** + /** This view uses a `List` to list a set of fonts of which one can be selected. @@ -28,47 +28,47 @@ extension RichTextFont { .richTextFontPickerConfig(...) ``` */ - public struct ListPicker: View { + public struct ListPicker: View { - /** + /** Create a font list picker. - Parameters: - selection: The selected font name. */ - public init( - selection: Binding - ) { - self._selection = selection - } + public init( + context: RichEditorState + ) { + self._selection = context.bindingForFontName() + } - public typealias Config = RichTextFont.PickerConfig - public typealias Font = Config.Font - public typealias FontName = Config.FontName + public typealias Config = RichTextFont.PickerConfig + public typealias Font = Config.Font + public typealias FontName = Config.FontName - @Binding - private var selection: FontName + @Binding + private var selection: FontName - @Environment(\.richTextFontPickerConfig) - private var config + @Environment(\.richTextFontPickerConfig) + private var config - public var body: some View { - let font = Binding( - get: { Font(fontName: selection) }, - set: { selection = $0.fontName } - ) + public var body: some View { + let font = Binding( + get: { Font(fontName: selection) }, + set: { selection = $0.fontName } + ) - RichEditorSwiftUI.ListPicker( - items: config.fontsToList(for: selection), - selection: font, - dismissAfterPick: config.dismissAfterPick - ) { font, isSelected in - RichTextFont.PickerItem( - font: font, - fontSize: config.fontSize, - isSelected: isSelected - ) - } - } + RichEditorSwiftUI.ListPicker( + items: config.fontsToList(for: selection), + selection: font, + dismissAfterPick: config.dismissAfterPick + ) { font, isSelected in + RichTextFont.PickerItem( + font: font, + fontSize: config.fontSize, + isSelected: isSelected + ) + } } + } } diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift index 6686f1d..d6e4c75 100644 --- a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift @@ -40,9 +40,9 @@ extension RichTextFont { - selection: The selected font name. */ public init( - selection: Binding + context: RichEditorState ) { - self._selection = selection + self._selection = context.bindingForFontName() self.selectedFont = Config.Font.all.first } diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift index b36f70d..2ac95ff 100644 --- a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift @@ -9,7 +9,7 @@ import SwiftUI extension RichTextFont { - /** + /** This picker can be used to pick a font size. The view returns a plain SwiftUI `Picker` view that can @@ -26,60 +26,60 @@ extension RichTextFont { .richTextFontSizePickerConfig(...) ``` */ - public struct SizePicker: View { + public struct SizePicker: View { - /** + /** Create a font size picker. - Parameters: - selection: The selected font size. */ - public init( - selection: Binding - ) { - self._selection = selection - } + public init( + context: RichEditorState + ) { + self._selection = context.bindingForFontSize() + } - @Binding - private var selection: CGFloat + @Binding + private var selection: CGFloat - @Environment(\.richTextFontSizePickerConfig) - private var config + @Environment(\.richTextFontSizePickerConfig) + private var config - public var body: some View { - SwiftUI.Picker("", selection: $selection) { - ForEach( - values( - for: config.values, - selection: selection - ), id: \.self - ) { - text(for: $0) - .tag($0) - } - } + public var body: some View { + SwiftUI.Picker("", selection: $selection) { + ForEach( + values( + for: config.values, + selection: selection + ), id: \.self + ) { + text(for: $0) + .tag($0) } + } } + } } extension RichTextFont.SizePicker { - /// Get a list of values for a certain selection. - public func values( - for values: [CGFloat], - selection: CGFloat - ) -> [CGFloat] { - let values = values + [selection] - return Array(Set(values)).sorted() - } + /// Get a list of values for a certain selection. + public func values( + for values: [CGFloat], + selection: CGFloat + ) -> [CGFloat] { + let values = values + [selection] + return Array(Set(values)).sorted() + } } extension RichTextFont.SizePicker { - fileprivate func text( - for fontSize: CGFloat - ) -> some View { - Text("\(Int(fontSize))") - .fixedSize(horizontal: true, vertical: false) - } + fileprivate func text( + for fontSize: CGFloat + ) -> some View { + Text("\(Int(fontSize))") + .fixedSize(horizontal: true, vertical: false) + } } diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFontSizePickerStack.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFontSizePickerStack.swift index a68800f..9f44403 100644 --- a/Sources/RichEditorSwiftUI/Fonts/RichTextFontSizePickerStack.swift +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFontSizePickerStack.swift @@ -6,11 +6,11 @@ // #if os(iOS) || os(macOS) || os(visionOS) - import SwiftUI + import SwiftUI - extension RichTextFont { + extension RichTextFont { - /** + /** This view uses a ``RichTextFont/SizePicker`` and button steppers to increment and a decrement the font size. @@ -25,87 +25,84 @@ .richTextFontSizePickerConfig(...) ``` */ - public struct SizePickerStack: View { + public 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 os(iOS) || os(visionOS) - stack - .fixedSize(horizontal: false, vertical: true) - #else - HStack(spacing: 3) { - picker - stepper - } - .overlay(macShortcutOverlay) - #endif - } - } + public init( + context: RichEditorState + ) { + self._context = ObservedObject(wrappedValue: context) + } + + private let step = 1 + + @ObservedObject + private var context: RichEditorState + + public var body: some View { + #if os(iOS) || os(visionOS) + stack + .fixedSize(horizontal: false, vertical: true) + #else + HStack(spacing: 3) { + picker + stepper + } + .overlay(macShortcutOverlay) + #endif + } } + } - extension RichTextFont.SizePickerStack { - - fileprivate var macShortcutOverlay: some View { - stack - .opacity(0) - .allowsHitTesting(false) - } - - fileprivate var stack: some View { - HStack(spacing: 2) { - stepButton(-step) - picker - stepButton(step) - } - } - - fileprivate func stepButton(_ points: Int) -> some View { - RichTextAction.Button( - action: .stepFontSize(points: points), - context: context, - fillVertically: true - ) - } - - fileprivate var picker: some View { - RichTextFont.SizePicker( - selection: $context.fontSize - ) - .onChangeBackPort(of: context.fontSize) { newValue in - context.updateStyle(style: .size(Int(context.fontSize))) - } - } - - fileprivate var stepper: some View { - Stepper( - "", - onIncrement: increment, - onDecrement: decrement - ) - } - - fileprivate func decrement() { - context.fontSize -= CGFloat(step) - } - - fileprivate func increment() { - context.fontSize += CGFloat(step) - } + extension RichTextFont.SizePickerStack { + + fileprivate var macShortcutOverlay: some View { + stack + .opacity(0) + .allowsHitTesting(false) + } + + fileprivate var stack: some View { + HStack(spacing: 2) { + stepButton(-step) + picker + stepButton(step) + } + } + + fileprivate func stepButton(_ points: Int) -> some View { + RichTextAction.Button( + action: .stepFontSize(points: points), + context: context, + fillVertically: true + ) + } + + fileprivate var picker: some View { + RichTextFont.SizePicker( + context: context + ) + } + + fileprivate var stepper: some View { + Stepper( + "", + onIncrement: increment, + onDecrement: decrement + ) + } + + fileprivate func decrement() { + context.fontSize -= CGFloat(step) + } + + fileprivate func increment() { + context.fontSize += CGFloat(step) } + } #endif diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sheet.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sheet.swift index b9d44cc..8d86bc0 100644 --- a/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sheet.swift +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sheet.swift @@ -6,11 +6,11 @@ // #if os(iOS) || os(macOS) || os(visionOS) - import SwiftUI + import SwiftUI - extension RichTextFormat { + extension RichTextFormat { - /** + /** This sheet contains a font picker and a bottom toolbar. You can configure and style the view by applying config @@ -24,119 +24,119 @@ .richTextFormatSheetConfig(...) ``` */ - public struct Sheet: RichTextFormatToolbarBase { + public struct Sheet: RichTextFormatToolbarBase { - /** + /** Create a rich text format sheet. - Parameters: - context: The context to apply changes to. */ - public init( - context: RichEditorState - ) { - self._context = ObservedObject(wrappedValue: context) - } - - public typealias Config = RichTextFormat.ToolbarConfig - public typealias Style = RichTextFormat.ToolbarStyle - - @ObservedObject - private var context: RichEditorState - - @Environment(\.richTextFormatSheetConfig) - var config - - @Environment(\.richTextFormatSheetStyle) - var style - - @Environment(\.dismiss) - private var dismiss - - @Environment(\.horizontalSizeClass) - private var horizontalSizeClass - - public var body: some View { - NavigationView { - VStack(spacing: 0) { - RichTextFont.ListPicker( - selection: $context.fontName - ) - Divider() - RichTextFormat.Toolbar( - context: context - ) - .richTextFormatToolbarConfig(config) - } - .padding(.top, -35) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(RTEL10n.done.text) { - dismiss() - } - } - } - .navigationTitle("") - #if iOS - .navigationBarTitleDisplayMode(.inline) - #endif - } - #if iOS - .navigationViewStyle(.stack) - #endif + public init( + context: RichEditorState + ) { + self._context = ObservedObject(wrappedValue: context) + } + + public typealias Config = RichTextFormat.ToolbarConfig + public typealias Style = RichTextFormat.ToolbarStyle + + @ObservedObject + private var context: RichEditorState + + @Environment(\.richTextFormatSheetConfig) + var config + + @Environment(\.richTextFormatSheetStyle) + var style + + @Environment(\.dismiss) + private var dismiss + + @Environment(\.horizontalSizeClass) + private var horizontalSizeClass + + public var body: some View { + NavigationView { + VStack(spacing: 0) { + RichTextFont.ListPicker( + context: context + ) + Divider() + RichTextFormat.Toolbar( + context: context + ) + .richTextFormatToolbarConfig(config) + } + .padding(.top, -35) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(RTEL10n.done.text) { + dismiss() + } } + } + .navigationTitle("") + #if iOS + .navigationBarTitleDisplayMode(.inline) + #endif } + #if iOS + .navigationViewStyle(.stack) + #endif + } } + } - extension View { + extension View { - /// Apply a rich text format sheet config. - public func richTextFormatSheetConfig( - _ value: RichTextFormat.Sheet.Config - ) -> some View { - self.environment(\.richTextFormatSheetConfig, value) - } + /// Apply a rich text format sheet config. + public func richTextFormatSheetConfig( + _ value: RichTextFormat.Sheet.Config + ) -> some View { + self.environment(\.richTextFormatSheetConfig, value) + } - /// Apply a rich text format sheet style. - public func richTextFormatSheetStyle( - _ value: RichTextFormat.Sheet.Style - ) -> some View { - self.environment(\.richTextFormatSheetStyle, value) - } + /// Apply a rich text format sheet style. + public func richTextFormatSheetStyle( + _ value: RichTextFormat.Sheet.Style + ) -> some View { + self.environment(\.richTextFormatSheetStyle, value) } + } - extension RichTextFormat.Sheet.Config { + extension RichTextFormat.Sheet.Config { - fileprivate struct Key: EnvironmentKey { + fileprivate struct Key: EnvironmentKey { - static var defaultValue: RichTextFormat.Sheet.Config { - .standard - } - } + static var defaultValue: RichTextFormat.Sheet.Config { + .standard + } } + } - extension RichTextFormat.Sheet.Style { + extension RichTextFormat.Sheet.Style { - fileprivate struct Key: EnvironmentKey { + fileprivate struct Key: EnvironmentKey { - static var defaultValue: RichTextFormat.Sheet.Style { - .standard - } - } + static var defaultValue: RichTextFormat.Sheet.Style { + .standard + } } + } - extension EnvironmentValues { + extension EnvironmentValues { - /// This value can bind to a format sheet config. - public var richTextFormatSheetConfig: RichTextFormat.Sheet.Config { - get { self[RichTextFormat.Sheet.Config.Key.self] } - set { self[RichTextFormat.Sheet.Config.Key.self] = newValue } - } + /// This value can bind to a format sheet config. + public var richTextFormatSheetConfig: RichTextFormat.Sheet.Config { + get { self[RichTextFormat.Sheet.Config.Key.self] } + set { self[RichTextFormat.Sheet.Config.Key.self] = newValue } + } - /// This value can bind to a format sheet style. - public var richTextFormatSheetStyle: RichTextFormat.Sheet.Style { - get { self[RichTextFormat.Sheet.Style.Key.self] } - set { self[RichTextFormat.Sheet.Style.Key.self] = newValue } - } + /// This value can bind to a format sheet style. + public var richTextFormatSheetStyle: RichTextFormat.Sheet.Style { + get { self[RichTextFormat.Sheet.Style.Key.self] } + set { self[RichTextFormat.Sheet.Style.Key.self] = newValue } } + } #endif diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sidebar.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sidebar.swift index cf6c454..48be86b 100644 --- a/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sidebar.swift +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sidebar.swift @@ -6,11 +6,11 @@ // #if os(iOS) || os(macOS) || os(visionOS) - import SwiftUI + import SwiftUI - extension RichTextFormat { + extension RichTextFormat { - /** + /** This sidebar view provides various text format options, and is meant to be used on macOS, in a trailing sidebar. @@ -29,150 +29,142 @@ should also be made to look good on iPadOS in landscape, to let us use it instead of the ``RichTextFormat/Sheet``. */ - public struct Sidebar: RichTextFormatToolbarBase { + public struct Sidebar: RichTextFormatToolbarBase { - /** + /** Create a rich text format sheet. - Parameters: - context: The context to apply changes to. */ - public init( - context: RichEditorState - ) { - self._context = ObservedObject(wrappedValue: context) + public init( + context: RichEditorState + ) { + self._context = ObservedObject(wrappedValue: context) + } + + public typealias Config = RichTextFormat.ToolbarConfig + public typealias Style = RichTextFormat.ToolbarStyle + + @ObservedObject + private var context: RichEditorState + + @Environment(\.richTextFormatSidebarConfig) + var config + + @Environment(\.richTextFormatSidebarStyle) + var style + + public var body: some View { + VStack(alignment: .leading, spacing: style.spacing) { + SidebarSection { + fontPicker(context: context) + HStack { + styleToggleGroup(for: context) + Spacer() + fontSizePicker(for: context) } + otherMenuToggleGroup(for: context) - public typealias Config = RichTextFormat.ToolbarConfig - public typealias Style = RichTextFormat.ToolbarStyle - - @ObservedObject - private var context: RichEditorState - - @Environment(\.richTextFormatSidebarConfig) - var config - - @Environment(\.richTextFormatSidebarStyle) - var style - - public var body: some View { - VStack(alignment: .leading, spacing: style.spacing) { - SidebarSection { - fontPicker(value: $context.fontName) - .onChangeBackPort(of: context.fontName) { - newValue in - context.updateStyle(style: .font(newValue)) - } - HStack { - styleToggleGroup(for: context) - Spacer() - fontSizePicker(for: context) - } - otherMenuToggleGroup(for: context) - - headerPicker(context: context) - } - - Divider() - - SidebarSection { - alignmentPicker(context: context) - .onChangeBackPort(of: context.textAlignment) { - newValue in - context.updateStyle(style: .align(newValue)) - } - // HStack { - // lineSpacingPicker(for: context) - // } - // HStack { - // indentButtons(for: context, greedy: true) - // superscriptButtons(for: context, greedy: true) - // } - } - - Divider() - - if hasColorPickers { - SidebarSection { - colorPickers(for: context) - } - .padding(.trailing, -8) - Divider() - } - - Spacer() - } - .labelsHidden() - .padding(style.padding - 2) - .background(Color.white.opacity(0.05)) + headerPicker(context: context) + } + + Divider() + + SidebarSection { + alignmentPicker(context: context) + // HStack { + // lineSpacingPicker(for: context) + // } + // HStack { + // indentButtons(for: context, greedy: true) + // superscriptButtons(for: context, greedy: true) + // } + } + + Divider() + + if hasColorPickers { + SidebarSection { + colorPickers(for: context) } + .padding(.trailing, -8) + Divider() + } + + Spacer() } + .labelsHidden() + .padding(style.padding - 2) + .background(Color.white.opacity(0.05)) + } } + } - private struct SidebarSection: View { + private struct SidebarSection: View { - @ViewBuilder - let content: () -> Content + @ViewBuilder + let content: () -> Content - @Environment(\.richTextFormatToolbarStyle) - var style + @Environment(\.richTextFormatToolbarStyle) + var style - var body: some View { - VStack(alignment: .leading, spacing: style.spacing) { - content() - } - } + var body: some View { + VStack(alignment: .leading, spacing: style.spacing) { + content() + } } + } - extension View { + extension View { - /// Apply a rich text format sidebar config. - public func richTextFormatSidebarConfig( - _ value: RichTextFormat.Sidebar.Config - ) -> some View { - self.environment(\.richTextFormatSidebarConfig, value) - } + /// Apply a rich text format sidebar config. + public func richTextFormatSidebarConfig( + _ value: RichTextFormat.Sidebar.Config + ) -> some View { + self.environment(\.richTextFormatSidebarConfig, value) + } - /// Apply a rich text format sidebar style. - public func richTextFormatSidebarStyle( - _ value: RichTextFormat.Sidebar.Style - ) -> some View { - self.environment(\.richTextFormatSidebarStyle, value) - } + /// Apply a rich text format sidebar style. + public func richTextFormatSidebarStyle( + _ value: RichTextFormat.Sidebar.Style + ) -> some View { + self.environment(\.richTextFormatSidebarStyle, value) } + } - extension RichTextFormat.Sidebar.Config { + extension RichTextFormat.Sidebar.Config { - fileprivate struct Key: EnvironmentKey { + fileprivate struct Key: EnvironmentKey { - static var defaultValue: RichTextFormat.Sidebar.Config { - .standard - } - } + static var defaultValue: RichTextFormat.Sidebar.Config { + .standard + } } + } - extension RichTextFormat.Sidebar.Style { + extension RichTextFormat.Sidebar.Style { - fileprivate struct Key: EnvironmentKey { + fileprivate struct Key: EnvironmentKey { - static var defaultValue: RichTextFormat.Sidebar.Style { - .standard - } - } + static var defaultValue: RichTextFormat.Sidebar.Style { + .standard + } } + } - extension EnvironmentValues { + extension EnvironmentValues { - /// This value can bind to a format sidebar config. - public var richTextFormatSidebarConfig: RichTextFormat.Sidebar.Config { - get { self[RichTextFormat.Sidebar.Config.Key.self] } - set { self[RichTextFormat.Sidebar.Config.Key.self] = newValue } - } + /// This value can bind to a format sidebar config. + public var richTextFormatSidebarConfig: RichTextFormat.Sidebar.Config { + get { self[RichTextFormat.Sidebar.Config.Key.self] } + set { self[RichTextFormat.Sidebar.Config.Key.self] = newValue } + } - /// This value can bind to a format sidebar style. - public var richTextFormatSidebarStyle: RichTextFormat.Sidebar.Style { - get { self[RichTextFormat.Sidebar.Style.Key.self] } - set { self[RichTextFormat.Sidebar.Style.Key.self] = newValue } - } + /// This value can bind to a format sidebar style. + public var richTextFormatSidebarStyle: RichTextFormat.Sidebar.Style { + get { self[RichTextFormat.Sidebar.Style.Key.self] } + set { self[RichTextFormat.Sidebar.Style.Key.self] = newValue } } + } #endif diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormat+Toolbar.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Toolbar.swift index 13ace86..5015101 100644 --- a/Sources/RichEditorSwiftUI/Format/RichTextFormat+Toolbar.swift +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Toolbar.swift @@ -115,10 +115,7 @@ HStack { #if os(macOS) headerPicker(context: context) - fontPicker(value: $context.fontName) - .onChangeBackPort(of: context.fontName) { newValue in - context.updateStyle(style: .font(newValue)) - } + fontPicker(context: context) #endif styleToggleGroup(for: context) otherMenuToggleGroup(for: context) diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormatToolbarBase.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormatToolbarBase.swift index b6a47a0..3936b71 100644 --- a/Sources/RichEditorSwiftUI/Format/RichTextFormatToolbarBase.swift +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormatToolbarBase.swift @@ -6,196 +6,198 @@ // #if os(iOS) || os(macOS) || os(visionOS) - import SwiftUI + import SwiftUI - /// This internal protocol is used to share code between the - /// two toolbars, which should eventually become one. - protocol RichTextFormatToolbarBase: View { + /// This internal protocol is used to share code between the + /// two toolbars, which should eventually become one. + protocol RichTextFormatToolbarBase: View { - var config: RichTextFormat.ToolbarConfig { get } - var style: RichTextFormat.ToolbarStyle { get } - } + var config: RichTextFormat.ToolbarConfig { get } + var style: RichTextFormat.ToolbarStyle { get } + } - extension RichTextFormatToolbarBase { + extension RichTextFormatToolbarBase { - var hasColorPickers: Bool { - let colors = config.colorPickers - let disclosed = config.colorPickersDisclosed - return !colors.isEmpty || !disclosed.isEmpty - } + var hasColorPickers: Bool { + let colors = config.colorPickers + let disclosed = config.colorPickersDisclosed + return !colors.isEmpty || !disclosed.isEmpty + } + } + + extension RichTextFormatToolbarBase { + + @ViewBuilder + func alignmentPicker( + context: RichEditorState + ) -> some View { + if !config.alignments.isEmpty { + RichTextAlignment.Picker( + selection: context.textAlignmentBinding(), + values: config.alignments + ) + .pickerStyle(.segmented) + .frame(idealWidth: 150, maxWidth: 300) + } } - extension RichTextFormatToolbarBase { - - @ViewBuilder - func alignmentPicker( - context: RichEditorState - ) -> some View { - if !config.alignments.isEmpty { - RichTextAlignment.Picker( - selection: context.textAlignmentBinding(), - values: config.alignments - ) - .pickerStyle(.segmented) - } - } - - @ViewBuilder - func headerPicker( - context: RichEditorState - ) -> some View { - if !config.headers.isEmpty { - RichTextHeader.Picker( - context: context, - values: config.headers - ) - } - } - - @ViewBuilder - func colorPickers( - for context: RichEditorState - ) -> some View { - if hasColorPickers { - VStack(spacing: style.spacing) { - colorPickers( - for: config.colorPickers, - context: context - ) - colorPickersDisclosureGroup( - for: config.colorPickersDisclosed, - context: context - ) - } - } - } + @ViewBuilder + func headerPicker( + context: RichEditorState + ) -> some View { + if !config.headers.isEmpty { + RichTextHeader.Picker( + context: context, + values: config.headers + ) + } + } - @ViewBuilder - func colorPickers( - for colors: [RichTextColor], - context: RichEditorState - ) -> some View { - if !colors.isEmpty { - ForEach(colors) { - colorPicker(for: $0, context: context) - } - } + @ViewBuilder + func colorPickers( + for context: RichEditorState + ) -> some View { + if hasColorPickers { + VStack(spacing: style.spacing) { + colorPickers( + for: config.colorPickers, + context: context + ) + colorPickersDisclosureGroup( + for: config.colorPickersDisclosed, + context: context + ) } + } + } - @ViewBuilder - func colorPickersDisclosureGroup( - for colors: [RichTextColor], - context: RichEditorState - ) -> some View { - if !colors.isEmpty { - DisclosureGroup { - colorPickers( - for: config.colorPickersDisclosed, - context: context - ) - } label: { - Image - .symbol("chevron.down") - .label(RTEL10n.more.text) - .labelStyle(.iconOnly) - .frame(minWidth: 30) - } - } + @ViewBuilder + func colorPickers( + for colors: [RichTextColor], + context: RichEditorState + ) -> some View { + if !colors.isEmpty { + ForEach(colors) { + colorPicker(for: $0, context: context) } + } + } - func colorPicker( - for color: RichTextColor, - context: RichEditorState - ) -> some View { - RichTextColor.Picker( - type: color, - value: context.binding(for: color), - quickColors: .quickPickerColors - ) + @ViewBuilder + func colorPickersDisclosureGroup( + for colors: [RichTextColor], + context: RichEditorState + ) -> some View { + if !colors.isEmpty { + DisclosureGroup { + colorPickers( + for: config.colorPickersDisclosed, + context: context + ) + } label: { + Image + .symbol("chevron.down") + .label(RTEL10n.more.text) + .labelStyle(.iconOnly) + .frame(minWidth: 30) } + } + } - @ViewBuilder - func fontPicker( - value: Binding - ) -> some View { - if config.fontPicker { - RichTextFont.Picker( - selection: value - ) - .richTextFontPickerConfig(.init(fontSize: 12)) - } - } + func colorPicker( + for color: RichTextColor, + context: RichEditorState + ) -> some View { + RichTextColor.Picker( + type: color, + value: context.binding(for: color), + quickColors: .quickPickerColors + ) + } - @ViewBuilder - func fontSizePicker( - for context: RichEditorState - ) -> some View { - if config.fontSizePicker { - RichTextFont.SizePickerStack(context: context) - .buttonStyle(.bordered) - } - } + @ViewBuilder + func fontPicker( + context: RichEditorState + ) -> some View { + if config.fontPicker { + RichTextFont.Picker( + context: context + ) + .richTextFontPickerConfig(.init(fontSize: 12)) + } + } - // @ViewBuilder - // func indentButtons( - // for context: RichEditorState, - // greedy: Bool - // ) -> some View { - // if config.indentButtons { - // RichTextAction.ButtonGroup( - // context: context, - // actions: [.stepIndent(points: -30), .stepIndent(points: 30)], - // greedy: greedy - // ) - // } - // } - - // @ViewBuilder - // func lineSpacingPicker( - // for context: RichEditorState - // ) -> some View { - // if config.lineSpacingPicker { - // RichTextLine.SpacingPickerStack(context: context) - // .buttonStyle(.bordered) - // } - // } - - @ViewBuilder - func styleToggleGroup( - for context: RichEditorState - ) -> some View { - if !config.styles.isEmpty { - RichTextStyle.ToggleGroup( - context: context, - styles: config.styles - ) - } - } + @ViewBuilder + func fontSizePicker( + for context: RichEditorState + ) -> some View { + if config.fontSizePicker { + RichTextFont.SizePickerStack(context: context) + .buttonStyle(.bordered) + } + } - @ViewBuilder - func otherMenuToggleGroup( - for context: RichEditorState - ) -> some View { - if !config.otherMenu.isEmpty { - RichTextOtherMenu.ToggleGroup( - context: context, - styles: config.otherMenu - ) - } - } + // @ViewBuilder + // func indentButtons( + // for context: RichEditorState, + // greedy: Bool + // ) -> some View { + // if config.indentButtons { + // RichTextAction.ButtonGroup( + // context: context, + // actions: [.stepIndent(points: -30), .stepIndent(points: 30)], + // greedy: greedy + // ) + // } + // } + + // @ViewBuilder + // func lineSpacingPicker( + // for context: RichEditorState + // ) -> some View { + // if config.lineSpacingPicker { + // RichTextLine.SpacingPickerStack(context: context) + // .buttonStyle(.bordered) + // } + // } + + @ViewBuilder + func styleToggleGroup( + for context: RichEditorState + ) -> some View { + if !config.styles.isEmpty { + RichTextStyle.ToggleGroup( + context: context, + styles: config.styles + ) + .frame(minWidth: 100, idealWidth: 120, maxWidth: 200) + } + } - // @ViewBuilder - // func superscriptButtons( - // for context: RichEditorState, - // greedy: Bool - // ) -> some View { - // if config.superscriptButtons { - // RichTextAction.ButtonGroup( - // context: context, - // actions: [.stepSuperscript(steps: -1), .stepSuperscript(steps: 1)], - // greedy: greedy - // ) - // } - // } + @ViewBuilder + func otherMenuToggleGroup( + for context: RichEditorState + ) -> some View { + if !config.otherMenu.isEmpty { + RichTextOtherMenu.ToggleGroup( + context: context, + styles: config.otherMenu + ) + } } + + // @ViewBuilder + // func superscriptButtons( + // for context: RichEditorState, + // greedy: Bool + // ) -> some View { + // if config.superscriptButtons { + // RichTextAction.ButtonGroup( + // context: context, + // actions: [.stepSuperscript(steps: -1), .stepSuperscript(steps: 1)], + // greedy: greedy + // ) + // } + // } + } #endif diff --git a/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift b/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift index e098c91..b945bec 100644 --- a/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift +++ b/Sources/RichEditorSwiftUI/Keyboard/RichTextKeyboardToolbar.swift @@ -203,7 +203,7 @@ fileprivate var trailingViews: some View { RichTextAlignment.Picker(selection: $context.textAlignment) .pickerStyle(.segmented) - .frame(maxWidth: 200) + .frame(minWidth: 100, idealWidth: 150, maxWidth: 200) .keyboardShortcutsOnly(if: isCompact) trailingButtons(StandardTrailingButtons()) diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Font.swift b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Font.swift new file mode 100644 index 0000000..6656ee8 --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Font.swift @@ -0,0 +1,32 @@ +// +// RichTextContext+Color.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +import SwiftUI + +extension RichEditorState { + + /// Get a binding for a certain FontName. + public func bindingForFontName() -> Binding { + Binding( + get: { self.getFontName() }, + set: { self.setFontName($0) } + ) + } + + /// Get the value for a certain FontName. + public func getFontName() -> String { + return fontName + } + + /// Set whether or not the context has a certain FontName style. + public func setFontName( + _ name: String + ) { + guard name != fontName else { return } + updateStyle(style: .font(name)) + } +} diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+FontSize.swift b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+FontSize.swift new file mode 100644 index 0000000..7a454df --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+FontSize.swift @@ -0,0 +1,32 @@ +// +// RichTextContext+Color.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 29/10/24. +// + +import SwiftUI + +extension RichEditorState { + + /// Get a binding for a certain FontSize. + public func bindingForFontSize() -> Binding { + Binding( + get: { self.getFontSize() }, + set: { self.setFontSize($0) } + ) + } + + /// Get the value for a certain FontSize. + public func getFontSize() -> CGFloat { + return fontSize + } + + /// Set whether or not the context has a certain FontSize style. + public func setFontSize( + _ size: CGFloat + ) { + guard size != fontSize else { return } + updateStyle(style: .size(Int(size))) + } +} From 63e1f549cd937b60da0f8ef9a145a2c460b9aa7a Mon Sep 17 00:00:00 2001 From: Divyesh Canopas Date: Thu, 9 Jan 2025 16:27:26 +0530 Subject: [PATCH 2/6] Suporting undo for toggle style --- .pre-commit-config.yaml | 2 +- .../RichEditorDemo/ContentView.swift | 330 +++++++-------- .../RichTextCoordinator+Actions.swift | 378 +++++++++--------- .../BaseFoundation/RichTextCoordinator.swift | 24 +- .../Components/RichTextViewComponent.swift | 180 ++++----- .../UI/Context/RichEditorState+Styles.swift | 76 ++-- .../RichEditorState+UndoRedoManager.swift | 200 +++++++++ .../UI/Context/RichEditorState.swift | 24 +- .../UI/Editor/RichEditorState+Spans.swift | 29 +- .../UndoRedo/RichEditorUndoRedoManager.swift | 64 +++ .../UndoRedo/RichTextOperations.swift | 63 +++ 11 files changed, 866 insertions(+), 504 deletions(-) create mode 100644 Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoRedoManager.swift create mode 100644 Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoRedoManager.swift create mode 100644 Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6ed1e45..a24c0c8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: name: Swift Format description: Enforces formatting guidelines for Swift files before committing. language: system - entry: swiftformat --swiftversion 5 + entry: swiftformat --swiftversion 5.9 stages: - pre-commit diff --git a/RichEditorDemo/RichEditorDemo/ContentView.swift b/RichEditorDemo/RichEditorDemo/ContentView.swift index 336e449..d4c14d1 100644 --- a/RichEditorDemo/RichEditorDemo/ContentView.swift +++ b/RichEditorDemo/RichEditorDemo/ContentView.swift @@ -9,183 +9,183 @@ import RichEditorSwiftUI import SwiftUI struct ContentView: View { - @Environment(\.colorScheme) var colorScheme - - @ObservedObject var state: RichEditorState - @State private var isInspectorPresented = false - @State private var fileName: String = "" - @State private var exportFormat: RichTextDataFormat? = nil - @State private var otherExportFormat: RichTextExportOption? = nil - @State private var exportService: StandardRichTextExportService = .init() - - init(state: RichEditorState? = nil) { - if let state { - self.state = state - } else { - if let richText = readJSONFromFile( - fileName: "Sample_json", - type: RichText.self) - { - self.state = .init(richText: richText) - } else { - self.state = .init(input: "Hello World!") - } - } + @Environment(\.colorScheme) var colorScheme + + @ObservedObject var state: RichEditorState + @State private var isInspectorPresented = false + @State private var fileName: String = "" + @State private var exportFormat: RichTextDataFormat? = nil + @State private var otherExportFormat: RichTextExportOption? = nil + @State private var exportService: StandardRichTextExportService = .init() + + init(state: RichEditorState? = nil) { + if let state { + self.state = state + } else { + if let richText = readJSONFromFile( + fileName: "Sample_json", + type: RichText.self) + { + self.state = .init(richText: richText) + } else { + self.state = .init(input: "Bold \n Italic \n Underline \n Strikethrough \n ") + } } + } - var body: some View { - NavigationStack { - VStack { - #if os(macOS) - RichTextFormat.Toolbar(context: state) - #endif - - RichTextEditor( - context: _state, - viewConfiguration: { _ in - - } - ) - .background( - colorScheme == .dark ? .gray.opacity(0.3) : Color.white - ) - .cornerRadius(10) - - #if os(iOS) - RichTextKeyboardToolbar( - context: state, - leadingButtons: { $0 }, - trailingButtons: { $0 }, - formatSheet: { $0 } - ) - #endif - } - #if os(iOS) || os(macOS) - .inspector(isPresented: $isInspectorPresented) { - RichTextFormat.Sidebar(context: state) - #if os(macOS) - .inspectorColumnWidth( - min: 200, ideal: 200, max: 315) - #endif - } - #endif - .padding(10) - #if os(iOS) || os(macOS) - .toolbar { - ToolbarItemGroup(placement: .automatic) { - toolBarGroup - } - } - #endif - .background(colorScheme == .dark ? .black : .gray.opacity(0.07)) - .navigationTitle("Rich Editor") - .alert("Enter file name", isPresented: getBindingAlert()) { - TextField("Enter file name", text: $fileName) - Button("OK", action: submit) - } message: { - Text("Please enter file name") - } - .focusedValue(\.richEditorState, state) - .toolbarRole(.automatic) - #if os(iOS) || os(macOS) || os(visionOS) - .richTextFormatSheetConfig(.init(colorPickers: colorPickers)) - .richTextFormatSidebarConfig( - .init( - colorPickers: colorPickers, - fontPicker: isMac - ) - ) - .richTextFormatToolbarConfig(.init(colorPickers: [])) + var body: some View { + NavigationStack { + VStack { + #if os(macOS) + RichTextFormat.Toolbar(context: state) + #endif + + RichTextEditor( + context: _state, + viewConfiguration: { _ in + + } + ) + .background( + colorScheme == .dark ? .gray.opacity(0.3) : Color.white + ) + .cornerRadius(10) + + #if os(iOS) + RichTextKeyboardToolbar( + context: state, + leadingButtons: { $0 }, + trailingButtons: { $0 }, + formatSheet: { $0 } + ) + #endif + } + #if os(iOS) || os(macOS) + .inspector(isPresented: $isInspectorPresented) { + RichTextFormat.Sidebar(context: state) + #if os(macOS) + .inspectorColumnWidth( + min: 200, ideal: 200, max: 315) #endif } - } - - #if os(iOS) || os(macOS) - var toolBarGroup: some View { - return Group { - RichTextExportMenu.init( - formatAction: { format in - exportFormat = format - }, - otherOptionAction: { format in - otherExportFormat = format - } - ) - #if !os(macOS) - .frame(width: 25, alignment: .center) - #endif - Button( - action: { - print("Exported JSON == \(state.outputAsString())") - }, - label: { - Image(systemName: "printer.inverse") - } - ) - #if !os(macOS) - .frame(width: 25, alignment: .center) - #endif - Toggle(isOn: $isInspectorPresented) { - Image.richTextFormatBrush - .resizable() - .aspectRatio(1, contentMode: .fit) - } - #if !os(macOS) - .frame(width: 25, alignment: .center) - #endif - } + #endif + .padding(10) + #if os(iOS) || os(macOS) + .toolbar { + ToolbarItemGroup(placement: .automatic) { + toolBarGroup + } } - #endif - - func getBindingAlert() -> Binding { - .init( - get: { exportFormat != nil || otherExportFormat != nil }, - set: { newValue in - exportFormat = nil - otherExportFormat = nil - }) + #endif + .background(colorScheme == .dark ? .black : .gray.opacity(0.07)) + .navigationTitle("Rich Editor") + .alert("Enter file name", isPresented: getBindingAlert()) { + TextField("Enter file name", text: $fileName) + Button("OK", action: submit) + } message: { + Text("Please enter file name") + } + .focusedValue(\.richEditorState, state) + .toolbarRole(.automatic) + #if os(iOS) || os(macOS) || os(visionOS) + .richTextFormatSheetConfig(.init(colorPickers: colorPickers)) + .richTextFormatSidebarConfig( + .init( + colorPickers: colorPickers, + fontPicker: isMac + ) + ) + .richTextFormatToolbarConfig(.init(colorPickers: [])) + #endif } - - func submit() { - guard !fileName.isEmpty else { return } - var path: URL? - - if let exportFormat { - path = try? exportService.generateExportFile( - withName: fileName, content: state.attributedString, - format: exportFormat) - } - if let otherExportFormat { - switch otherExportFormat { - case .pdf: - path = try? exportService.generatePdfExportFile( - withName: fileName, content: state.attributedString) - case .json: - path = try? exportService.generateJsonExportFile( - withName: fileName, content: state.richText) - } - } - if let path { - print("Exported at path == \(path)") + } + + #if os(iOS) || os(macOS) + var toolBarGroup: some View { + return Group { + RichTextExportMenu.init( + formatAction: { format in + exportFormat = format + }, + otherOptionAction: { format in + otherExportFormat = format + } + ) + #if !os(macOS) + .frame(width: 25, alignment: .center) + #endif + Button( + action: { + print("Exported JSON == \(state.outputAsString())") + }, + label: { + Image(systemName: "printer.inverse") + } + ) + #if !os(macOS) + .frame(width: 25, alignment: .center) + #endif + Toggle(isOn: $isInspectorPresented) { + Image.richTextFormatBrush + .resizable() + .aspectRatio(1, contentMode: .fit) } + #if !os(macOS) + .frame(width: 25, alignment: .center) + #endif + } + } + #endif + + func getBindingAlert() -> Binding { + .init( + get: { exportFormat != nil || otherExportFormat != nil }, + set: { newValue in + exportFormat = nil + otherExportFormat = nil + }) + } + + func submit() { + guard !fileName.isEmpty else { return } + var path: URL? + + if let exportFormat { + path = try? exportService.generateExportFile( + withName: fileName, content: state.attributedString, + format: exportFormat) + } + if let otherExportFormat { + switch otherExportFormat { + case .pdf: + path = try? exportService.generatePdfExportFile( + withName: fileName, content: state.attributedString) + case .json: + path = try? exportService.generateJsonExportFile( + withName: fileName, content: state.richText) + } + } + if let path { + print("Exported at path == \(path)") } + } } extension ContentView { - var isMac: Bool { - #if os(macOS) - true - #else - false - #endif - } + var isMac: Bool { + #if os(macOS) + true + #else + false + #endif + } - var colorPickers: [RichTextColor] { - [.foreground, .background] - } + var colorPickers: [RichTextColor] { + [.foreground, .background] + } - var formatToolbarEdge: VerticalEdge { - isMac ? .top : .bottom - } + var formatToolbarEdge: VerticalEdge { + isMac ? .top : .bottom + } } diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift index 89b7711..a7df753 100644 --- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift +++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift @@ -8,205 +8,205 @@ import Foundation #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) - import SwiftUI - - 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) - 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) - case .stepFontSize(let points): - textView.stepRichTextFontSize(points: points) - syncContextWithTextView() - case .stepIndent(_): - // textView.stepRichTextIndent(points: points) - return - case .stepLineSpacing(_): - // textView.stepRichTextLineSpacing(points: points) - return - case .stepSuperscript(_): - // textView.stepRichTextSuperscriptLevel(points: points) - return - case .toggleStyle(_): - // textView.toggleRichTextStyle(style) - return - case .undoLatestChange: - textView.undoLatestChange() - syncContextWithTextView() - case .setHeaderStyle(let style): - let size = style.fontSizeMultiplier * .standardRichTextFontSize - let range = textView.textString.getHeaderRangeFor( - textView.selectedRange - ) - var font = textView.richTextFont(at: range) - font = font?.withSize(size) - textView - .setRichTextFont(font ?? .standardRichTextFont, at: range) - case .setLink(let link): - if let link, link != self.context.link { - setLink(link) - } else { - removeLink() - } - } + import SwiftUI + + 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: + context.redoLastChanges() + syncContextWithTextView() + case .selectRange(let range): + setSelectedRange(to: range) + case .setAlignment(let alignment): + textView.setRichTextAlignment(alignment) + 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) + case .stepFontSize(let points): + textView.stepRichTextFontSize(points: points) + syncContextWithTextView() + case .stepIndent(_): + // textView.stepRichTextIndent(points: points) + return + case .stepLineSpacing(_): + // textView.stepRichTextLineSpacing(points: points) + return + case .stepSuperscript(_): + // textView.stepRichTextSuperscriptLevel(points: points) + return + case .toggleStyle(_): + // textView.toggleRichTextStyle(style) + return + case .undoLatestChange: + context.undoLastChanges() + syncContextWithTextView() + case .setHeaderStyle(let style): + let size = style.fontSizeMultiplier * .standardRichTextFontSize + let range = textView.textString.getHeaderRangeFor( + textView.selectedRange + ) + var font = textView.richTextFont(at: range) + font = font?.withSize(size) + textView + .setRichTextFont(font ?? .standardRichTextFont, at: range) + case .setLink(let link): + if let link, link != self.context.link { + setLink(link) + } else { + removeLink() } + } + } + } + + 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 pasteText(_ data: RichTextInsertion) { + // textView.pasteText( + // data.content, + // at: data.index, + // moveCursorToPastedContent: data.moveCursor + // ) + // } + + func setAttributedString(to newValue: NSAttributedString?) { + guard let newValue else { return } + textView.setRichText(newValue) } - 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 pasteText(_ data: RichTextInsertion) { - // textView.pasteText( - // data.content, - // at: data.index, - // moveCursorToPastedContent: data.moveCursor - // ) - // } - - func setAttributedString(to newValue: NSAttributedString?) { - guard let newValue else { return } - textView.setRichText(newValue) - } - - // TODO: This code should be handled by the component - func setColor(_ color: RichTextColor, to val: ColorRepresentable) { - var applyRange: NSRange? - if textView.hasSelectedRange { - applyRange = textView.selectedRange - } - guard let attribute = color.attribute else { return } - if let applyRange { - textView.setRichTextColor(color, to: val, at: applyRange) - } else { - textView.setRichTextAttribute(attribute, to: val) - } - } + // TODO: This code should be handled by the component + func setColor(_ color: RichTextColor, to val: ColorRepresentable) { + var applyRange: NSRange? + if textView.hasSelectedRange { + applyRange = textView.selectedRange + } + guard let attribute = color.attribute else { return } + if let applyRange { + textView.setRichTextColor(color, to: val, at: applyRange) + } else { + textView.setRichTextAttribute(attribute, to: val) + } + } - func setHighlightedRange(to range: NSRange?) { - resetHighlightedRangeAppearance() - guard let range = range else { return } - setHighlightedRangeAppearance(for: range) - } + func setHighlightedRange(to range: NSRange?) { + resetHighlightedRangeAppearance() + guard let range = range else { return } + setHighlightedRangeAppearance(for: range) + } - func setHighlightedRangeAppearance(for range: NSRange) { - let back = textView.richTextColor(.background, at: range) ?? .clear - let fore = - textView.richTextColor(.foreground, at: range) ?? .textColor - highlightedRangeOriginalBackgroundColor = back - highlightedRangeOriginalForegroundColor = fore - let style = textView.highlightingStyle - let background = ColorRepresentable(style.backgroundColor) - let foreground = ColorRepresentable(style.foregroundColor) - textView.setRichTextColor(.background, to: background, at: range) - textView.setRichTextColor(.foreground, to: foreground, at: range) - } + func setHighlightedRangeAppearance(for range: NSRange) { + let back = textView.richTextColor(.background, at: range) ?? .clear + let fore = + textView.richTextColor(.foreground, at: range) ?? .textColor + highlightedRangeOriginalBackgroundColor = back + highlightedRangeOriginalForegroundColor = fore + let style = textView.highlightingStyle + let background = ColorRepresentable(style.backgroundColor) + let foreground = ColorRepresentable(style.foregroundColor) + textView.setRichTextColor(.background, to: background, at: range) + textView.setRichTextColor(.foreground, to: foreground, at: range) + } - func setIsEditable(to newValue: Bool) { - #if os(iOS) || os(macOS) || os(visionOS) - if newValue == textView.isEditable { return } - textView.isEditable = newValue - #endif - } + func setIsEditable(to newValue: Bool) { + #if os(iOS) || os(macOS) || os(visionOS) + if newValue == textView.isEditable { return } + textView.isEditable = newValue + #endif + } - func setIsEditing(to newValue: Bool) { - if newValue == textView.isFirstResponder { return } - if newValue { - #if os(iOS) || os(visionOS) - textView.becomeFirstResponder() - #else - print("macOS currently doesn't resign first responder.") - #endif - } else { - #if os(iOS) || os(visionOS) - textView.resignFirstResponder() - #else - print("macOS currently doesn't resign first responder.") - #endif - } - } + func setIsEditing(to newValue: Bool) { + if newValue == textView.isFirstResponder { return } + if newValue { + #if os(iOS) || os(visionOS) + textView.becomeFirstResponder() + #else + print("macOS currently doesn't resign first responder.") + #endif + } else { + #if os(iOS) || os(visionOS) + textView.resignFirstResponder() + #else + print("macOS currently doesn't resign first responder.") + #endif + } + } - func setSelectedRange(to range: NSRange) { - if range == textView.selectedRange { return } - textView.selectedRange = range - } + func setSelectedRange(to range: NSRange) { + if range == textView.selectedRange { return } + textView.selectedRange = range + } - func setStyle(_ style: RichTextStyle, to newValue: Bool) { - let hasStyle = textView.richTextStyles.hasStyle(style) - guard newValue != hasStyle else { return } - textView.setRichTextStyle(style, to: newValue) - } + func setStyle(_ style: RichTextStyle, to newValue: Bool) { + let hasStyle = textView.richTextStyles.hasStyle(style) + guard newValue != hasStyle else { return } + textView.setRichTextStyle(style, to: newValue) + } - func setLink(_ link: String) { - let style: RichTextSpanStyle = .link(link) - var applyRange: NSRange? - if textView.hasSelectedRange { - applyRange = textView.selectedRange - } - textView.setRichTextLink(.link(link)) - if let applyRange { - textView.setRichTextLink(style, at: applyRange) - } else { - textView.setRichTextLink(style) - } - } + func setLink(_ link: String) { + let style: RichTextSpanStyle = .link(link) + var applyRange: NSRange? + if textView.hasSelectedRange { + applyRange = textView.selectedRange + } + textView.setRichTextLink(.link(link)) + if let applyRange { + textView.setRichTextLink(style, at: applyRange) + } else { + textView.setRichTextLink(style) + } + } - func removeLink() { - textView.removeRichTextLink(.link()) - } + func removeLink() { + textView.removeRichTextLink(.link()) } + } #endif diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift index cbda37b..224ae0a 100644 --- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift +++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift @@ -100,12 +100,14 @@ } open func textViewDidChangeSelection(_ textView: UITextView) { - context.onTextViewEvent( - .didChangeSelection( - selectedRange: textView.selectedRange, - text: textView.attributedText + DispatchQueue.main.async { [weak self] in + self?.context.onTextViewEvent( + .didChangeSelection( + selectedRange: textView.selectedRange, + text: textView.attributedText + ) ) - ) + } syncWithTextView() } @@ -201,15 +203,17 @@ /// Sync state from the text view's current state. func syncWithTextView() { - syncContextWithTextView() + DispatchQueue.main.async { [weak self] in + self?.syncContextWithTextView() + } syncTextWithTextView() } /// Sync the rich text context with the text view. func syncContextWithTextView() { if shouldDelaySyncContextWithTextView { - DispatchQueue.main.async { - self.syncContextWithTextViewAfterDelay() + DispatchQueue.main.async { [weak self] in + self?.syncContextWithTextViewAfterDelay() } } else { syncContextWithTextViewAfterDelay() @@ -229,10 +233,10 @@ sync(&context.canCopy, with: textView.hasSelectedRange) sync( &context.canRedoLatestChange, - with: textView.undoManager?.canRedo ?? false) + with: context.canRedoLatestChange) sync( &context.canUndoLatestChange, - with: textView.undoManager?.canUndo ?? false) + with: context.canUndoLatestChange) sync(&context.fontName, with: font.fontName) sync(&context.fontSize, with: font.pointSize) sync(&context.isEditingText, with: textView.isFirstResponder) diff --git a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift index 46aebec..b7ce6ba 100644 --- a/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift +++ b/Sources/RichEditorSwiftUI/Components/RichTextViewComponent.swift @@ -9,9 +9,9 @@ import CoreGraphics import Foundation #if canImport(UIKit) - import UIKit + import UIKit #elseif canImport(AppKit) && !targetEnvironment(macCatalyst) - import AppKit + import AppKit #endif /// This protocol provides a common interface for the UIKit and @@ -27,117 +27,117 @@ import Foundation /// The protocol implements and extends many other protocols to /// provide more features for components with more capabilities. public protocol RichTextViewComponent: AnyObject, - RichTextPresenter, - RichTextAttributeReader, - RichTextAttributeWriter + RichTextPresenter, + RichTextAttributeReader, + RichTextAttributeWriter { - /// The text view's frame. - var frame: CGRect { get } + /// The text view's frame. + var frame: CGRect { get } - /// The style to use when highlighting text in the view. - var highlightingStyle: RichTextHighlightingStyle { get set } + /// The style to use when highlighting text in the view. + var highlightingStyle: RichTextHighlightingStyle { get set } - /// Whether or not the text view is the first responder. - var isFirstResponder: Bool { get } + /// Whether or not the text view is the first responder. + var isFirstResponder: Bool { get } - #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) - /// The text view's layout manager, if any. - var layoutManagerWrapper: NSLayoutManager? { get } + #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) + /// The text view's layout manager, if any. + var layoutManagerWrapper: NSLayoutManager? { get } - /// The text view's text storage, if any. - var textStorageWrapper: NSTextStorage? { get } - #endif + /// The text view's text storage, if any. + var textStorageWrapper: NSTextStorage? { get } + #endif - /// The text view's mutable attributed string, if any. - var mutableAttributedString: NSMutableAttributedString? { get } + /// The text view's mutable attributed string, if any. + var mutableAttributedString: NSMutableAttributedString? { get } - /// The spacing between the text view's edge and its text. - var textContentInset: CGSize { get set } + /// The spacing between the text view's edge and its text. + var textContentInset: CGSize { get set } - /// The text view current typing attributes. - var typingAttributes: RichTextAttributes { get set } + /// The text view current typing attributes. + var typingAttributes: RichTextAttributes { get set } - // MARK: - Setup - /// Setup the view with a text and data format. - func setup( - with text: NSAttributedString, - format: RichTextDataFormat? - ) + // MARK: - Setup + /// Setup the view with a text and data format. + func setup( + with text: NSAttributedString, + format: RichTextDataFormat? + ) - func setup( - with text: RichText - ) + func setup( + with text: RichText + ) - // MARK: - Functions + // MARK: - Functions - /// Show an alert with a title, message and button text. - func alert(title: String, message: String, buttonTitle: String) + /// Show an alert with a title, message and button text. + func alert(title: String, message: String, buttonTitle: String) - /// Copy the current selection. - func copySelection() + /// Copy the current selection. + func copySelection() - /// Try to redo the latest undone change. - func redoLatestChange() + // /// Try to redo the latest undone change. + // func redoLatestChange() - /// Scroll to a certain range. - func scroll(to range: NSRange) + /// Scroll to a certain range. + func scroll(to range: NSRange) - /// Set the rich text in the text view. - func setRichText(_ text: NSAttributedString) + /// Set the rich text in the text view. + func setRichText(_ text: NSAttributedString) - /// Set the selected range in the text view. - func setSelectedRange(_ range: NSRange) + /// Set the selected range in the text view. + func setSelectedRange(_ range: NSRange) - /// Undo the latest change. - func undoLatestChange() + // /// Undo the latest change. + // func undoLatestChange() } // MARK: - Public Extension extension RichTextViewComponent { - /// Show an alert with a title, message and OK button. - public func alert(title: String, message: String) { - alert(title: title, message: message, buttonTitle: "OK") - } - - /// Delete all characters in a certain range. - public func deleteCharacters(in range: NSRange) { - mutableAttributedString?.deleteCharacters(in: range) - } - - /// Move the text cursor to a certain input index. - public func moveInputCursor(to index: Int) { - let newRange = NSRange(location: index, length: 0) - let safeRange = safeRange(for: newRange) - setSelectedRange(safeRange) - } - - /// Setup the view with data and a data format. - public func setup( - with data: Data, - format: RichTextDataFormat - ) throws { - let string = try NSAttributedString(data: data, format: format) - setup(with: string, format: format) - } - - /// Get the image configuration for a certain format. - // func standardImageConfiguration( - // for format: RichTextDataFormat - // ) -> RichTextImageConfiguration { - // let insertConfig = standardImageInsertConfiguration(for: format) - // return RichTextImageConfiguration( - // pasteConfiguration: insertConfig, - // dropConfiguration: insertConfig, - // maxImageSize: (width: .frame, height: .frame)) - // } - - /// Get the image insert config for a certain format. - // func standardImageInsertConfiguration( - // for format: RichTextDataFormat - // ) -> RichTextImageInsertConfiguration { - // format.supportsImages ? .enabled : .disabled - // } + /// Show an alert with a title, message and OK button. + public func alert(title: String, message: String) { + alert(title: title, message: message, buttonTitle: "OK") + } + + /// Delete all characters in a certain range. + public func deleteCharacters(in range: NSRange) { + mutableAttributedString?.deleteCharacters(in: range) + } + + /// Move the text cursor to a certain input index. + public func moveInputCursor(to index: Int) { + let newRange = NSRange(location: index, length: 0) + let safeRange = safeRange(for: newRange) + setSelectedRange(safeRange) + } + + /// Setup the view with data and a data format. + public func setup( + with data: Data, + format: RichTextDataFormat + ) throws { + let string = try NSAttributedString(data: data, format: format) + setup(with: string, format: format) + } + + /// Get the image configuration for a certain format. + // func standardImageConfiguration( + // for format: RichTextDataFormat + // ) -> RichTextImageConfiguration { + // let insertConfig = standardImageInsertConfiguration(for: format) + // return RichTextImageConfiguration( + // pasteConfiguration: insertConfig, + // dropConfiguration: insertConfig, + // maxImageSize: (width: .frame, height: .frame)) + // } + + /// Get the image insert config for a certain format. + // func standardImageInsertConfiguration( + // for format: RichTextDataFormat + // ) -> RichTextImageInsertConfiguration { + // format.supportsImages ? .enabled : .disabled + // } } diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Styles.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Styles.swift index 5ab990b..c278c80 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Styles.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Styles.swift @@ -9,50 +9,44 @@ import SwiftUI extension RichEditorState { - /// Get a binding for a certain style. - public func binding(for style: RichTextStyle) -> Binding { - Binding( - get: { Bool(self.hasStyle(style)) }, - set: { [weak self] _ in self?.setStyle(style) } - ) - } - - /// Check whether or not the context has a certain style. - public func hasStyle(_ style: RichTextStyle) -> Bool { - styles[style] == true - } - - /// Set whether or not the context has a certain style. - public func setStyle( - _ style: RichTextStyle, - to val: Bool - ) { - guard styles[style] != val else { return } - actionPublisher.send(.setStyle(style, val)) - setStyleInternal(style, to: val) - } - - /// Toggle a certain style for the context. - public func toggleStyle(_ style: RichTextStyle) { - setStyle(style, to: !hasStyle(style)) - } - - public func setStyle(_ style: RichTextStyle) { - toggleStyle(style: style.richTextSpanStyle) - } + /// Get a binding for a certain style. + public func binding(for style: RichTextStyle) -> Binding { + Binding( + get: { Bool(self.hasStyle(style)) }, + set: { [weak self] _ in self?.setStyle(style) } + ) + } + + /// Check whether or not the context has a certain style. + public func hasStyle(_ style: RichTextStyle) -> Bool { + styles[style] == true + } + + /// Set whether or not the context has a certain style. + public func setStyle( + _ style: RichTextStyle, + to val: Bool + ) { + actionPublisher.send(.setStyle(style, val)) + setStyleInternal(style, to: val) + } + + public func setStyle(_ style: RichTextStyle) { + toggleStyle(style: style.richTextSpanStyle) + } } extension RichEditorState { - /// Set the value for a certain color, or remove it. - func setStyleInternal( - _ style: RichTextStyle, - to val: Bool? - ) { - guard let val else { - styles[style] = nil - return - } - styles[style] = val + /// Set the value for a certain color, or remove it. + func setStyleInternal( + _ style: RichTextStyle, + to val: Bool? + ) { + guard let val else { + styles[style] = nil + return } + styles[style] = val + } } diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoRedoManager.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoRedoManager.swift new file mode 100644 index 0000000..680196d --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoRedoManager.swift @@ -0,0 +1,200 @@ +// +// RichEditorState+UndoRedoManager.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 06/01/25. +// + +import Foundation + +extension RichEditorState { + func updateUndoRedoState() { + canUndoLatestChange = undoManager.canUndo() + canRedoLatestChange = undoManager.canRedo() + } + + func observerTextInput() { + $attributedString + .dropFirst() + .drop(while: { _ in !self.isOperationIsFromUser }) + .debounce(for: .milliseconds(700), scheduler: DispatchQueue.main) + .sink { [weak self] attributedText in + guard let self = self else { return } + // let operation = self.getOperationForTextChange(self.attributedString, rawText: self.rawText) + // if let operation { + // self.undoManager.registerUndoOperation(operation) + // } + // self.isOperationIsFromUser = false + // self.operationRawText = self.attributedString.string + updateUndoRedoState() + } + .store(in: &cancellables) + } + + func redoLastChanges() { + guard let lastOperation = undoManager.redo() else { return } + restoreState(for: lastOperation, isRedo: true) + updateUndoRedoState() + } + + func undoLastChanges() { + guard let lastOperation = undoManager.undo() else { return } + restoreState(for: lastOperation, isRedo: false) + updateUndoRedoState() + } + + private func restoreState(for operation: RichTextOperation, isRedo: Bool) { + setSelectedRange(range: operation.range) + if isRedo { + setCurrentAttributes(attributes: operation.attributes) + } + + switch operation.operationType { + case .addOrRemoveText(_, let rawText, _): + // let shouldAdd = isRedo ? isAdded : !isAdded + self.isOperationIsFromUser = false + actionPublisher.send(.setAttributedString(attributedString)) + self.rawText = rawText + // let attributedText = getAttributedStringBy(adding: shouldAdd, chars: rawText, at: operation.range.location) + // self.attributedString = attributedText + // onTextFieldValueChange(newText: attributedText, selection: operation.range) + // self.operationRawText = attributedString.string + + case .addOrRemoveStyle(let style, let isAdded): + let shouldAdd = isRedo ? isAdded : !isAdded + if shouldAdd { + activeStyles.remove(style) + } else { + activeStyles.insert(style) + } + toggleStyle(style: style, shouldRegisterUndo: false) + + case .setStyleStyle(let previousStyle, let newStyle, _): + if let previousStyle { + updateStyle(style: isRedo ? newStyle : previousStyle, shouldRegisterUndo: false) + } else { + updateStyle(style: newStyle, shouldRegisterUndo: false) + } + } + + if !isRedo { + setCurrentAttributes(attributes: undoManager.getPreviousAttributes()) + } + + updateUndoRedoState() + } + + private func setSelectedRange(range: NSRange) { + actionPublisher.send(.selectRange(range)) + selectedRange = range + } + private func setCurrentAttributes(attributes: OperationAttributes) { + activeStyles = attributes.activeStyles + headerType = attributes.headerType + textAlignment = attributes.textAlignment + fontName = attributes.fontName + fontSize = attributes.fontSize + colors = attributes.colors + lineSpacing = attributes.lineSpacing + paragraphStyle = attributes.paragraphStyle + // styles = attributes.styles + link = attributes.link + } + + func getAttributedStringBy(adding: Bool, chars: String, at index: Int) + -> NSMutableAttributedString + { + let attributedString = NSMutableAttributedString(attributedString: self.attributedString) + let attributedText = NSAttributedString(string: chars) + + if adding { + attributedString.insert(attributedText, at: index) + } else { + let range = NSRange(location: index, length: chars.utf16Length) + attributedString.replaceCharacters(in: range, with: "") + } + + return attributedString + } + + func getOperationForTextChange(_ newText: NSAttributedString, rawText: String) + -> RichTextOperation? + { + let isAdded = newText.string.utf16Length > rawText.utf16Length + let range = NSRange( + location: isAdded ? selectedRange.lowerBound : selectedRange.upperBound, length: 0) + return RichTextOperation( + operationType: .addOrRemoveText(newText: newText, rawText: rawText, isAdded: isAdded), + range: range, + attributes: getCurrentAttributes() + ) + } + + func getOperationFor(style: RichTextSpanStyle, isAdded: Bool) -> RichTextOperation { + return RichTextOperation( + operationType: .addOrRemoveStyle(style: style, isAdded: isAdded), + range: selectedRange, + attributes: getCurrentAttributes() + ) + } + + func registerOperationForText(newText: NSAttributedString, rawText: String) { + guard isOperationIsFromUser, + let operation = getOperationForTextChange(newText, rawText: rawText) + else { return } + undoManager.registerUndoOperation(operation) + updateUndoRedoState() + } + + func getOperationForSetStyle( + previousStyle: RichTextSpanStyle?, newStyle: RichTextSpanStyle, isSet: Bool + ) -> RichTextOperation { + return RichTextOperation( + operationType: .setStyleStyle(previousStyle: previousStyle, newStyle: newStyle, isSet: isSet), + range: selectedRange, + attributes: getCurrentAttributes() + ) + } + + // func getAddedOrRemovedTextFor(_ newText: NSAttributedString, rawText: String) -> String? { + // if newText.string.utf16Length > rawText.utf16Length { + // let addedCharsCount = newText.string.utf16Length - rawText.utf16Length + // let startIndex = selectedRange.location - addedCharsCount + // let range = NSRange(location: startIndex, length: addedCharsCount) + // return newText.string[range.closedRange].string() + // } else if rawText.utf16Length > newText.string.utf16Length { + // let removedCharsCount = rawText.utf16Length - newText.string.utf16Length + // let range = NSRange(location: selectedRange.location, length: removedCharsCount) + // return rawText[range.closedRange].string() + // } + // return nil + // } + + func registerUndoFor(style: RichTextSpanStyle, isAdded: Bool) { + let operation = getOperationFor(style: style, isAdded: isAdded) + undoManager.registerUndoOperation(operation) + updateUndoRedoState() + } + + func registerUndoForSetStyle(previousStyle: RichTextSpanStyle?, newStyle: RichTextSpanStyle) { + let operation = getOperationForSetStyle( + previousStyle: previousStyle, newStyle: newStyle, isSet: true) + undoManager.registerUndoOperation(operation) + updateUndoRedoState() + } + + private func getCurrentAttributes() -> OperationAttributes { + return OperationAttributes( + activeStyles: activeStyles, + headerType: headerType, + textAlignment: textAlignment, + fontName: fontName, + fontSize: fontSize, + colors: colors, + lineSpacing: lineSpacing, + paragraphStyle: paragraphStyle, + styles: styles, + link: link + ) + } +} diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift index d08d097..ab1bff6 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift @@ -36,12 +36,13 @@ public class RichEditorState: ObservableObject { Until then, use `setAttributedString(to:)` to change it. */ - public internal(set) var attributedString = NSAttributedString() + @Published public internal(set) var attributedString = NSAttributedString() /// The currently selected range, if any. + @Published public internal(set) var selectedRange = NSRange() - // MARK: - Bindable & Settable Properies + // MARK: - Bindable & Settable Properties /// Whether or not the rich text editor is editable. @Published @@ -124,10 +125,20 @@ public class RichEditorState: ObservableObject { internal var rawText: String = "" internal var updateAttributesQueue: [(span: RichTextSpanInternal, shouldApply: Bool)] = [] + + //MARK: - Alert Controller to handle Link #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) internal let alertController: RichTextAlertController = RichTextAlertController() #endif + //MARK: - Undo Redo manager + internal let undoManager: RichEditorUndoRedoManager = RichEditorUndoRedoManager() + + ///This set is used to store all observable observations. + public var cancellables = Set() + + var isOperationIsFromUser: Bool = true + /** This will provide encoded text which is of type RichText */ @@ -188,6 +199,7 @@ public class RichEditorState: ObservableObject { activeStyles = [] rawText = input + subscribeObservers() } /** @@ -215,6 +227,14 @@ public class RichEditorState: ObservableObject { activeStyles = [] rawText = input + subscribeObservers() + } +} + +//MARK: - Subscribe Observer +extension RichEditorState { + func subscribeObservers() { + observerTextInput() } } diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift index a59ddff..6507bce 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift @@ -47,14 +47,18 @@ extension RichEditorState { - Parameters: - style: is of type RichTextSpanStyle */ - public func toggleStyle(style: RichTextSpanStyle) { - if activeStyles.contains(style) { + public func toggleStyle(style: RichTextSpanStyle, shouldRegisterUndo: Bool = true) { + let shouldAdd: Bool = !activeStyles.contains(style) + if !shouldAdd { setInternalStyles(style: style, add: false) removeStyle(style) } else { setInternalStyles(style: style) addStyle(style) } + if shouldRegisterUndo { + registerUndoFor(style: style, isAdded: shouldAdd) + } } /** @@ -62,9 +66,10 @@ extension RichEditorState { - Parameters: - style: is of type RichTextSpanStyle */ - public func updateStyle(style: RichTextSpanStyle) { + public func updateStyle(style: RichTextSpanStyle, shouldRegisterUndo: Bool = true) { + let wasStyleActive = activeStyles.contains(style) setInternalStyles(style: style) - setStyle(style) + setStyle(style, shouldRegisterUndo: shouldRegisterUndo) } } @@ -91,6 +96,7 @@ extension RichEditorState { case .didChange: onTextFieldValueChange( newText: attributedString, selection: selectedRange) + // isOperationIsFromUser = true case .didEndEditing: selectedRange = .init(location: 0, length: 0) } @@ -102,11 +108,12 @@ extension RichEditorState { - newText: is updated NSMutableAttributedString - selection: is the range of the selected text */ - private func onTextFieldValueChange( + internal func onTextFieldValueChange( newText: NSAttributedString, selection: NSRange ) { self.selectedRange = selection + // registerOperationForText(newText: newText, rawText: rawText) if newText.string.count > rawText.count { handleAddingCharacters(newText) } else if newText.string.count < rawText.count { @@ -115,6 +122,7 @@ extension RichEditorState { rawText = newText.string updateCurrentSpanStyle() + // beginEditingGroup(.textChange) } /** @@ -133,7 +141,8 @@ extension RichEditorState { - style: is of type RichTextSpanStyle This will set the activeStyle according to style passed */ - private func setStyle(_ style: RichTextSpanStyle) { + private func setStyle(_ style: RichTextSpanStyle, shouldRegisterUndo: Bool = true) { + let previousStyles = activeStyles activeStyles.removeAll() activeAttributes = [:] activeStyles.insert(style) @@ -141,10 +150,18 @@ extension RichEditorState { if style.isHeaderStyle || style.isDefault //|| style.isList || style.isAlignmentStyle { + if shouldRegisterUndo { + let previousStyle = previousStyles.first(where: { $0.key == style.key }) + registerUndoForSetStyle(previousStyle: previousStyle, newStyle: style) + } handleAddOrRemoveStyleToLine( in: selectedRange, style: style, byAdding: !style.isDefault) } else if !selectedRange.isCollapsed { let addStyle = checkIfStyleIsActiveWithSameAttributes(style) + if shouldRegisterUndo { + let previousStyle = previousStyles.first(where: { $0.key == style.key }) + registerUndoForSetStyle(previousStyle: previousStyle, newStyle: style) + } processSpansFor(new: style, in: selectedRange, addStyle: addStyle) } diff --git a/Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoRedoManager.swift b/Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoRedoManager.swift new file mode 100644 index 0000000..87982ea --- /dev/null +++ b/Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoRedoManager.swift @@ -0,0 +1,64 @@ +// +// File.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 03/01/25. +// + +import Foundation + +internal class RichEditorUndoRedoManager { + var redoOperations: [RichTextOperation] = [] + var undoOperations: [RichTextOperation] = [] + + func canUndo() -> Bool { + !undoOperations.isEmpty + } + + func canRedo() -> Bool { + !redoOperations.isEmpty + } + + func undo() -> RichTextOperation? { + guard let last = undoOperations.popLast() else { return nil } + redoOperations.append(last) + return last + } + + func redo() -> RichTextOperation? { + guard let last = redoOperations.popLast() else { return nil } + undoOperations.append(last) + return last + } + + func getPreviousAttributes() -> OperationAttributes { + guard let last = undoOperations.last else { return getDefaultAttributes() } + return last.attributes + } + + func getPreviousRange() -> NSRange? { + guard let last = undoOperations.last else { return nil } + return last.range + } + + func registerUndoOperation(_ operation: RichTextOperation) { + if !redoOperations.isEmpty { + redoOperations.removeAll() + } + undoOperations.append(operation) + } + + private func getDefaultAttributes() -> OperationAttributes { + return OperationAttributes( + activeStyles: [], + headerType: .default, + textAlignment: .left, + fontName: "", + fontSize: .standardRichTextFontSize, + colors: [:], + lineSpacing: 10, + paragraphStyle: .default, + styles: [:], + link: nil) + } +} diff --git a/Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift b/Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift new file mode 100644 index 0000000..63eef5a --- /dev/null +++ b/Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift @@ -0,0 +1,63 @@ +// +// RichTextOperations.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 03/01/25. +// + +import Foundation +import SwiftUI + +struct RichTextOperation { + let operationType: OperationType + let range: NSRange + let attributes: OperationAttributes + init(operationType: OperationType, range: NSRange, attributes: OperationAttributes) { + self.operationType = operationType + self.range = range + self.attributes = attributes + } +} + +enum OperationType { + case addOrRemoveText(newText: NSAttributedString, rawText: String, isAdded: Bool) + case addOrRemoveStyle(style: RichTextSpanStyle, isAdded: Bool) + case setStyleStyle(previousStyle: RichTextSpanStyle?, newStyle: RichTextSpanStyle, isSet: Bool) +} + +struct OperationAttributes { + let activeStyles: Set + let headerType: HeaderType + let textAlignment: RichTextAlignment + let fontName: String + let fontSize: CGFloat + let colors: [RichTextColor: ColorRepresentable] + let lineSpacing: CGFloat + let paragraphStyle: NSParagraphStyle + let styles: [RichTextStyle: Bool] + let link: String? + + init( + activeStyles: Set, + headerType: HeaderType, + textAlignment: RichTextAlignment, + fontName: String, + fontSize: CGFloat, + colors: [RichTextColor: ColorRepresentable], + lineSpacing: CGFloat, + paragraphStyle: NSParagraphStyle, + styles: [RichTextStyle: Bool], + link: String? + ) { + self.activeStyles = activeStyles + self.headerType = headerType + self.textAlignment = textAlignment + self.fontName = fontName + self.fontSize = fontSize + self.colors = colors + self.lineSpacing = lineSpacing + self.paragraphStyle = paragraphStyle + self.styles = styles + self.link = link + } +} From a9319e03e66cc10ac679d1122882363139046a0e Mon Sep 17 00:00:00 2001 From: Divyesh Canopas Date: Fri, 10 Jan 2025 17:57:51 +0530 Subject: [PATCH 3/6] Fix undo redo for header, font and alignment --- .../UI/Context/RichEditorState+Header.swift | 49 ++++--- ...wift => RichEditorState+UndoManager.swift} | 138 ++++++++++++++---- .../UI/Context/RichEditorState.swift | 136 ++++++++++++++--- .../UI/Editor/RichEditorState+Spans.swift | 17 +-- .../UndoRedo/RichEditorUndoManager.swift | 62 ++++++++ .../UndoRedo/RichEditorUndoRedoManager.swift | 64 -------- .../UndoRedo/RichTextOperations.swift | 39 ++++- 7 files changed, 356 insertions(+), 149 deletions(-) rename Sources/RichEditorSwiftUI/UI/Context/{RichEditorState+UndoRedoManager.swift => RichEditorState+UndoManager.swift} (57%) create mode 100644 Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoManager.swift delete mode 100644 Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoRedoManager.swift diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Header.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Header.swift index e77428f..b2b526c 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Header.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+Header.swift @@ -9,25 +9,38 @@ import SwiftUI extension RichEditorState { - /// Get a binding for a certain style. - public func headerBinding() -> Binding { - Binding( - get: { self.currentHeader() }, - set: { self.setHeaderStyle($0) } - ) - } + /// Get a binding for a certain style. + public func headerBinding() -> Binding { + Binding( + get: { self.currentHeader() }, + set: { self.setStyle($0) } + ) + } - /// Check whether or not the context has a certain header style. - public func currentHeader() -> HeaderType { - return headerType - } + /// Check whether or not the context has a certain header style. + public func currentHeader() -> HeaderType { + return headerType + } - /// Set whether or not the context has a certain header style. - public func setHeaderStyle( - _ header: HeaderType - ) { - guard header != headerType else { return } - updateStyle(style: header.getTextSpanStyle()) - } + /// Set whether or not the context has a certain header style. + public func setStyle( + _ header: HeaderType + ) { + updateStyle(style: header.getTextSpanStyle()) + } + /// Set whether or not the context has a certain header style. + public func setHeaderStyle( + _ header: HeaderType + ) { + actionPublisher.send(.setHeaderStyle(header.getTextSpanStyle())) + setHeaderInternal(header: header) + } + + private func setHeaderInternal( + header: HeaderType + ) { + guard header != headerType else { return } + headerType = header + } } diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoRedoManager.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoManager.swift similarity index 57% rename from Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoRedoManager.swift rename to Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoManager.swift index 680196d..40c9943 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoRedoManager.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoManager.swift @@ -1,11 +1,12 @@ // -// RichEditorState+UndoRedoManager.swift +// RichEditorState+UndoManager.swift // RichEditorSwiftUI // // Created by Divyesh Vekariya on 06/01/25. // import Foundation +import SwiftUI extension RichEditorState { func updateUndoRedoState() { @@ -24,7 +25,7 @@ extension RichEditorState { // if let operation { // self.undoManager.registerUndoOperation(operation) // } - // self.isOperationIsFromUser = false + self.isOperationIsFromUser = false // self.operationRawText = self.attributedString.string updateUndoRedoState() } @@ -46,7 +47,7 @@ extension RichEditorState { private func restoreState(for operation: RichTextOperation, isRedo: Bool) { setSelectedRange(range: operation.range) if isRedo { - setCurrentAttributes(attributes: operation.attributes) +// setCurrentAttributes(attributes: operation.attributes) } switch operation.operationType { @@ -78,7 +79,7 @@ extension RichEditorState { } if !isRedo { - setCurrentAttributes(attributes: undoManager.getPreviousAttributes()) + setCurrentAttributes(attributes: operation.previousAttributes) } updateUndoRedoState() @@ -89,16 +90,23 @@ extension RichEditorState { selectedRange = range } private func setCurrentAttributes(attributes: OperationAttributes) { - activeStyles = attributes.activeStyles - headerType = attributes.headerType - textAlignment = attributes.textAlignment - fontName = attributes.fontName - fontSize = attributes.fontSize - colors = attributes.colors - lineSpacing = attributes.lineSpacing - paragraphStyle = attributes.paragraphStyle - // styles = attributes.styles - link = attributes.link +// let attributedStringCopy = NSMutableAttributedString(attributedString: attributes.attributedString) +// attributedString = attributedStringCopy + selectedRange = attributes.selectedRange + headerType = attributes.headerType + textAlignment = attributes.textAlignment + fontName = attributes.fontName + fontSize = attributes.fontSize + lineSpacing = attributes.lineSpacing + colors = attributes.colors + highlightingStyle = attributes.highlightingStyle + paragraphStyle = attributes.paragraphStyle + styles = attributes.styles + link = attributes.link + highlightedRange = attributes.highlightedRange + activeStyles = attributes.activeStyles + activeAttributes = attributes.activeAttributes +// rawText = attributes.rawText } func getAttributedStringBy(adding: Bool, chars: String, at index: Int) @@ -126,7 +134,8 @@ extension RichEditorState { return RichTextOperation( operationType: .addOrRemoveText(newText: newText, rawText: rawText, isAdded: isAdded), range: range, - attributes: getCurrentAttributes() + attributes: getCurrentAttributes(), + previousAttributes: getPreviousAttributes() ) } @@ -134,7 +143,8 @@ extension RichEditorState { return RichTextOperation( operationType: .addOrRemoveStyle(style: style, isAdded: isAdded), range: selectedRange, - attributes: getCurrentAttributes() + attributes: getCurrentAttributes(), + previousAttributes: getPreviousAttributes() ) } @@ -152,7 +162,8 @@ extension RichEditorState { return RichTextOperation( operationType: .setStyleStyle(previousStyle: previousStyle, newStyle: newStyle, isSet: isSet), range: selectedRange, - attributes: getCurrentAttributes() + attributes: getCurrentAttributes(), + previousAttributes: getPreviousAttributes() ) } @@ -176,25 +187,88 @@ extension RichEditorState { updateUndoRedoState() } - func registerUndoForSetStyle(previousStyle: RichTextSpanStyle?, newStyle: RichTextSpanStyle) { + func registerUndoForSetStyle(newStyle: RichTextSpanStyle) { + let previousStyle = getPreviousStyleFor(style: newStyle) let operation = getOperationForSetStyle( previousStyle: previousStyle, newStyle: newStyle, isSet: true) undoManager.registerUndoOperation(operation) updateUndoRedoState() } - private func getCurrentAttributes() -> OperationAttributes { - return OperationAttributes( - activeStyles: activeStyles, - headerType: headerType, - textAlignment: textAlignment, - fontName: fontName, - fontSize: fontSize, - colors: colors, - lineSpacing: lineSpacing, - paragraphStyle: paragraphStyle, - styles: styles, - link: link - ) - } + private func getPreviousStyleFor(style: RichTextSpanStyle) -> RichTextSpanStyle? { + var previousStyle: RichTextSpanStyle? = nil + switch style { + case .h1, .h2, .h3, .h4, .h5, .h6: + previousStyle = previousHeaderType.getTextSpanStyle() + case .size(_): + previousStyle = .size(Int(previousFontSize)) + case .font(_): + let fontName: String = previousFontName.isEmpty ? RichTextFont.PickerFont.standardSystemFontDisplayName : previousFontName + previousStyle = .font(fontName) + case .color(_): + if let color = previousColors[.foreground] { + previousStyle = .color(Color(color)) + } else { + previousStyle = .color() + } + case .background(_): + if let color = previousColors[.background] { + previousStyle = .background(Color(color)) + } else { + previousStyle = .background() + } + case .align(_): + previousStyle = .align(previousTextAlignment) + case .link(_): + previousStyle = .link(previousLink) + default: + previousStyle = nil + } + return previousStyle + } + + private func getCurrentAttributes() -> OperationAttributes { + let attributedString = NSMutableAttributedString(attributedString: attributedString) + + return OperationAttributes( + attributedString: attributedString, + selectedRange: selectedRange, + headerType: headerType, + textAlignment: textAlignment, + fontName: fontName, + fontSize: fontSize, + lineSpacing: lineSpacing, + colors: colors, + highlightingStyle: highlightingStyle, + paragraphStyle: paragraphStyle, + styles: styles, + link: link, + highlightedRange: highlightedRange, + activeStyles: activeStyles, + activeAttributes: activeAttributes, + rawText: rawText + ) + } + + private func getPreviousAttributes() -> OperationAttributes { + let previousAttributedString = NSMutableAttributedString(attributedString: previousAttributedString) + return OperationAttributes( + attributedString: previousAttributedString, + selectedRange: previousSelectedRange, + headerType: previousHeaderType, + textAlignment: previousTextAlignment, + fontName: previousFontName, + fontSize: previousFontSize, + lineSpacing: previousLineSpacing, + colors: previousColors, + highlightingStyle: previousHighlightingStyle, + paragraphStyle: previousParagraphStyle, + styles: previousStyles, + link: previousLink, + highlightedRange: previousHighlightedRange, + activeStyles: previousActiveStyles, + activeAttributes: previousActiveAttributes, + rawText: previousRawText + ) + } } diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift index ab1bff6..5d692b8 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift @@ -36,11 +36,19 @@ public class RichEditorState: ObservableObject { Until then, use `setAttributedString(to:)` to change it. */ - @Published public internal(set) var attributedString = NSAttributedString() + @Published + public internal(set) var attributedString = NSAttributedString() + { + willSet { + previousAttributedString = NSMutableAttributedString(attributedString: attributedString) + } + } /// The currently selected range, if any. @Published - public internal(set) var selectedRange = NSRange() + public internal(set) var selectedRange = NSRange() { + willSet { previousSelectedRange = selectedRange } + } // MARK: - Bindable & Settable Properties @@ -53,23 +61,33 @@ public class RichEditorState: ObservableObject { public var isEditingText = false @Published - public var headerType: HeaderType = .default + public var headerType: HeaderType = .default { + willSet { previousHeaderType = headerType } + } /// The current text alignment, if any. @Published - public var textAlignment: RichTextAlignment = .left + public var textAlignment: RichTextAlignment = .left { + willSet { previousTextAlignment = textAlignment } + } /// The current font name. @Published - public var fontName = RichTextFont.PickerFont.all.first?.fontName ?? "" + public var fontName = RichTextFont.PickerFont.all.first?.fontName ?? "" { + willSet { previousFontName = fontName } + } /// The current font size. @Published - public var fontSize = CGFloat.standardRichTextFontSize + public var fontSize = CGFloat.standardRichTextFontSize { + willSet { previousFontSize = fontSize } + } /// The current line spacing. @Published - public var lineSpacing: CGFloat = 10.0 + public var lineSpacing: CGFloat = 10.0 { + willSet { previousLineSpacing = lineSpacing } + } // MARK: - Observable Properties @@ -87,23 +105,36 @@ public class RichEditorState: ObservableObject { /// The current color values. @Published - public internal(set) var colors = [RichTextColor: ColorRepresentable]() + public internal(set) var colors = [RichTextColor: ColorRepresentable]() { + willSet { previousColors = colors } + } /// The style to apply when highlighting a range. @Published public internal(set) var highlightingStyle = RichTextHighlightingStyle .standard + { + willSet { previousHighlightingStyle = highlightingStyle } + } /// The current paragraph style. @Published - public internal(set) var paragraphStyle = NSParagraphStyle.default + public internal(set) var paragraphStyle = NSParagraphStyle.default { + willSet { + previousParagraphStyle = paragraphStyle.copy() as? NSParagraphStyle ?? NSParagraphStyle() + } + } /// The current rich text styles. @Published - public internal(set) var styles = [RichTextStyle: Bool]() + public internal(set) var styles = [RichTextStyle: Bool]() { + willSet { previousStyles = styles } + } @Published - public internal(set) var link: String? = nil + public internal(set) var link: String? = nil { + willSet { previousLink = link } + } // MARK: - Properties @@ -111,28 +142,97 @@ public class RichEditorState: ObservableObject { public let actionPublisher = RichTextAction.Publisher() /// The currently highlighted range, if any. - public var highlightedRange: NSRange? + public var highlightedRange: NSRange? { + willSet { previousHighlightedRange = highlightedRange } + } //MARK: - Variables To Handle JSON internal var adapter: EditorAdapter = DefaultAdapter() - @Published internal var activeStyles: Set = [] - @Published internal var activeAttributes: [NSAttributedString.Key: Any]? = + @Published + internal var activeStyles: Set = [] { + willSet { previousActiveStyles = activeStyles } + } + @Published + internal var activeAttributes: [NSAttributedString.Key: Any]? = [:] + { + willSet { previousActiveAttributes = activeAttributes } + } internal var internalSpans: [RichTextSpanInternal] = [] - internal var rawText: String = "" + internal var rawText: String = "" { + willSet { previousRawText = rawText } + } + + ///===============############################################==================== + //MARK: - Previous State variables + internal private(set) var previousAttributedString = NSAttributedString() + /// The currently selected range, if any. + internal private(set) var previousSelectedRange = NSRange() + + // MARK: - Bindable & Settable Properties + /// The previous headerType, if any. + internal private(set) var previousHeaderType: HeaderType = .default + + /// The current text alignment, if any. + internal private(set) var previousTextAlignment: RichTextAlignment = .left + + /// The current font name. + internal private(set) var previousFontName = + RichTextFont.PickerFont.all.first?.fontName ?? "" + + /// The current font size. + internal private(set) var previousFontSize = CGFloat + .standardRichTextFontSize + + /// The current line spacing. + internal private(set) var previousLineSpacing: CGFloat = 10.0 + + // MARK: - Observable Properties + /// The current color values. + internal private(set) var previousColors = [ + RichTextColor: ColorRepresentable + ]() + + /// The style to apply when highlighting a range. + internal private(set) var previousHighlightingStyle = + RichTextHighlightingStyle + .standard + + /// The previous paragraph style + internal private(set) var previousParagraphStyle = NSParagraphStyle.default + + /// The previous rich text styles. + internal private(set) var previousStyles = [RichTextStyle: Bool]() + + internal private(set) var previousLink: String? = nil + + // MARK: - Properties + + /// The currently highlighted range, if any. + internal private(set) var previousHighlightedRange: NSRange? + + //MARK: - Variables To Handle JSON + + internal private(set) var previousActiveStyles: Set = [] + internal private(set) var previousActiveAttributes: [NSAttributedString.Key: Any]? = + [:] - internal var updateAttributesQueue: [(span: RichTextSpanInternal, shouldApply: Bool)] = [] + internal private(set) var previousRawText: String = "" + //MARK: - END OF PREVIOUS STATE VARIABLES + ///=============##############################################================= //MARK: - Alert Controller to handle Link #if os(iOS) || os(tvOS) || os(macOS) || os(visionOS) - internal let alertController: RichTextAlertController = RichTextAlertController() + internal let alertController: RichTextAlertController = + RichTextAlertController() #endif //MARK: - Undo Redo manager - internal let undoManager: RichEditorUndoRedoManager = RichEditorUndoRedoManager() + internal let undoManager: RichEditorUndoManager = + RichEditorUndoManager() ///This set is used to store all observable observations. public var cancellables = Set() diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift index 6507bce..44233c1 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift @@ -85,12 +85,12 @@ extension RichEditorState { switch event { case .didChangeSelection(let range, let text): selectedRange = range + onSelectionDidChanged() guard rawText.count == text.string.count && selectedRange.isCollapsed else { return } - onSelectionDidChanged() case .didBeginEditing(let range, _): selectedRange = range case .didChange: @@ -150,19 +150,17 @@ extension RichEditorState { if style.isHeaderStyle || style.isDefault //|| style.isList || style.isAlignmentStyle { - if shouldRegisterUndo { - let previousStyle = previousStyles.first(where: { $0.key == style.key }) - registerUndoForSetStyle(previousStyle: previousStyle, newStyle: style) - } handleAddOrRemoveStyleToLine( in: selectedRange, style: style, byAdding: !style.isDefault) + if shouldRegisterUndo { + registerUndoForSetStyle(newStyle: style) + } } else if !selectedRange.isCollapsed { let addStyle = checkIfStyleIsActiveWithSameAttributes(style) + processSpansFor(new: style, in: selectedRange, addStyle: addStyle) if shouldRegisterUndo { - let previousStyle = previousStyles.first(where: { $0.key == style.key }) - registerUndoForSetStyle(previousStyle: previousStyle, newStyle: style) + registerUndoForSetStyle(newStyle: style) } - processSpansFor(new: style, in: selectedRange, addStyle: addStyle) } updateCurrentSpanStyle() @@ -847,6 +845,7 @@ extension RichEditorState { internalSpans.removeAll() rawText = "" attributedString = NSMutableAttributedString(string: "") + undoManager.reset() } /** @@ -886,7 +885,7 @@ extension RichEditorState { setStyle(style, to: add) } case .h1, .h2, .h3, .h4, .h5, .h6, .default: - actionPublisher.send(.setHeaderStyle(style)) + setHeaderStyle(style.headerType) // case .bullet(_): // return case .size(let size): diff --git a/Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoManager.swift b/Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoManager.swift new file mode 100644 index 0000000..23f9b44 --- /dev/null +++ b/Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoManager.swift @@ -0,0 +1,62 @@ +// +// RichEditorUndoManager.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 03/01/25. +// + +import SwiftUI + +/// Manages undo and redo operations for a rich text editor. +internal class RichEditorUndoManager { + + /// Stores redoable operations. + private var redoOperations: [RichTextOperation] = [] + + /// Stores undoable operations. + private var undoOperations: [RichTextOperation] = [] + + /// Checks if there are operations available to undo. + /// - Returns: `true` if there are undo operations, otherwise `false`. + func canUndo() -> Bool { + !undoOperations.isEmpty + } + + /// Checks if there are operations available to redo. + /// - Returns: `true` if there are redo operations, otherwise `false`. + func canRedo() -> Bool { + !redoOperations.isEmpty + } + + /// Performs the undo operation by moving the last undo operation to the redo stack. + /// - Returns: The undone `RichTextOperation`, or `nil` if no undo operations are available. + func undo() -> RichTextOperation? { + guard let last = undoOperations.popLast() else { return nil } + redoOperations.append(last) + return last + } + + /// Performs the redo operation by moving the last redo operation to the undo stack. + /// - Returns: The redone `RichTextOperation`, or `nil` if no redo operations are available. + func redo() -> RichTextOperation? { + guard let last = redoOperations.popLast() else { return nil } + undoOperations.append(last) + return last + } + + /// Clears all undo and redo operations. + func reset() { + undoOperations.removeAll() + redoOperations.removeAll() + } + + /// Registers a new undo operation. + /// - Parameter operation: The `RichTextOperation` to add to the undo stack. + /// - Note: Clears the redo stack when a new undo operation is registered. + func registerUndoOperation(_ operation: RichTextOperation) { + if !redoOperations.isEmpty { + redoOperations.removeAll() + } + undoOperations.append(operation) + } +} diff --git a/Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoRedoManager.swift b/Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoRedoManager.swift deleted file mode 100644 index 87982ea..0000000 --- a/Sources/RichEditorSwiftUI/UndoRedo/RichEditorUndoRedoManager.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// File.swift -// RichEditorSwiftUI -// -// Created by Divyesh Vekariya on 03/01/25. -// - -import Foundation - -internal class RichEditorUndoRedoManager { - var redoOperations: [RichTextOperation] = [] - var undoOperations: [RichTextOperation] = [] - - func canUndo() -> Bool { - !undoOperations.isEmpty - } - - func canRedo() -> Bool { - !redoOperations.isEmpty - } - - func undo() -> RichTextOperation? { - guard let last = undoOperations.popLast() else { return nil } - redoOperations.append(last) - return last - } - - func redo() -> RichTextOperation? { - guard let last = redoOperations.popLast() else { return nil } - undoOperations.append(last) - return last - } - - func getPreviousAttributes() -> OperationAttributes { - guard let last = undoOperations.last else { return getDefaultAttributes() } - return last.attributes - } - - func getPreviousRange() -> NSRange? { - guard let last = undoOperations.last else { return nil } - return last.range - } - - func registerUndoOperation(_ operation: RichTextOperation) { - if !redoOperations.isEmpty { - redoOperations.removeAll() - } - undoOperations.append(operation) - } - - private func getDefaultAttributes() -> OperationAttributes { - return OperationAttributes( - activeStyles: [], - headerType: .default, - textAlignment: .left, - fontName: "", - fontSize: .standardRichTextFontSize, - colors: [:], - lineSpacing: 10, - paragraphStyle: .default, - styles: [:], - link: nil) - } -} diff --git a/Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift b/Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift index 63eef5a..31add04 100644 --- a/Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift +++ b/Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift @@ -12,10 +12,15 @@ struct RichTextOperation { let operationType: OperationType let range: NSRange let attributes: OperationAttributes - init(operationType: OperationType, range: NSRange, attributes: OperationAttributes) { + let previousAttributes: OperationAttributes + init( + operationType: OperationType, range: NSRange, attributes: OperationAttributes, + previousAttributes: OperationAttributes + ) { self.operationType = operationType self.range = range self.attributes = attributes + self.previousAttributes = previousAttributes } } @@ -26,38 +31,56 @@ enum OperationType { } struct OperationAttributes { - let activeStyles: Set + let attributedString: NSAttributedString + let selectedRange: NSRange let headerType: HeaderType let textAlignment: RichTextAlignment let fontName: String let fontSize: CGFloat - let colors: [RichTextColor: ColorRepresentable] let lineSpacing: CGFloat + let colors: [RichTextColor: ColorRepresentable] + let highlightingStyle: RichTextHighlightingStyle let paragraphStyle: NSParagraphStyle let styles: [RichTextStyle: Bool] let link: String? + let highlightedRange: NSRange? + let activeStyles: Set + let activeAttributes: [NSAttributedString.Key: Any]? + let rawText: String init( - activeStyles: Set, + attributedString: NSAttributedString, + selectedRange: NSRange, headerType: HeaderType, textAlignment: RichTextAlignment, fontName: String, fontSize: CGFloat, - colors: [RichTextColor: ColorRepresentable], lineSpacing: CGFloat, + colors: [RichTextColor: ColorRepresentable], + highlightingStyle: RichTextHighlightingStyle, paragraphStyle: NSParagraphStyle, styles: [RichTextStyle: Bool], - link: String? + link: String?, + highlightedRange: NSRange?, + activeStyles: Set, + activeAttributes: [NSAttributedString.Key: Any]?, + rawText: String ) { - self.activeStyles = activeStyles + self.attributedString = attributedString + self.selectedRange = selectedRange self.headerType = headerType self.textAlignment = textAlignment self.fontName = fontName self.fontSize = fontSize - self.colors = colors self.lineSpacing = lineSpacing + self.colors = colors + self.highlightingStyle = highlightingStyle self.paragraphStyle = paragraphStyle self.styles = styles self.link = link + self.highlightedRange = highlightedRange + self.activeStyles = activeStyles + self.activeAttributes = activeAttributes + self.rawText = rawText } } From bc7b8a90024a1d5f844d05c2331294f1994bc436 Mon Sep 17 00:00:00 2001 From: Divyesh Canopas Date: Mon, 13 Jan 2025 12:42:53 +0530 Subject: [PATCH 4/6] Fix redo and undo for color attrubutes --- .../Data/Models/RichAttributes.swift | 5 +- .../UI/Context/RichEditorState.swift | 2 + .../UI/Context/RichTextContext+Color.swift | 91 +++++++++---------- .../UI/Editor/RichEditor.swift | 7 ++ .../UI/Editor/RichEditorState+Spans.swift | 22 +++-- 5 files changed, 73 insertions(+), 54 deletions(-) diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift index e618167..1aa7923 100644 --- a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift +++ b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift @@ -196,9 +196,10 @@ extension RichAttributes { 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), + ? (byAdding ? att.color! : nil) : (att.color == nil && !byAdding) ? nil : self.color), background: (att.background != nil - ? (byAdding ? att.background! : nil) : self.background), + ? (byAdding ? att.background! : nil) + : (att.background == nil && !byAdding) ? nil : self.background), align: (att.align != nil ? (byAdding ? att.align! : nil) : self.align), ///nil link indicates removal as well so removing link if `byAdding == false && att.link == nil` diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift index 5d692b8..9b3a6eb 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState.swift @@ -108,6 +108,8 @@ public class RichEditorState: ObservableObject { public internal(set) var colors = [RichTextColor: ColorRepresentable]() { willSet { previousColors = colors } } + @Published + internal var colorScheme: ColorScheme = .light /// The style to apply when highlighting a range. @Published diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift index ce8258e..b2fc2f9 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift @@ -9,59 +9,58 @@ import SwiftUI extension RichEditorState { - /// Get a binding for a certain color. - public func binding(for color: RichTextColor) -> Binding { - Binding( - get: { Color(self.color(for: color) ?? .clear) }, - set: { self.updateStyleFor(color, to: .init($0)) } - ) - } + /// Get a binding for a certain color. + public func binding(for color: RichTextColor) -> Binding { + Binding( + get: { Color(self.color(for: color) ?? .clear) }, + set: { self.updateStyleFor(color, to: .init($0)) } + ) + } - /// Get the value for a certain color. - public func color(for color: RichTextColor) -> ColorRepresentable? { - colors[color] - } + /// Get the value for a certain color. + public func color(for color: RichTextColor) -> ColorRepresentable? { + colors[color] + } - /// Set the value for a certain color. - public func setColor( - _ color: RichTextColor, - to val: ColorRepresentable - ) { - guard self.color(for: color) != val else { return } - actionPublisher.send(.setColor(color, val)) - setColorInternal(color, to: val) - } + /// Set the value for a certain color. + public func setColor( + _ color: RichTextColor, + to val: ColorRepresentable + ) { + actionPublisher.send(.setColor(color, val)) + setColorInternal(color, to: val) + } - public func updateStyleFor( - _ color: RichTextColor, to val: ColorRepresentable - ) { - let value = Color(val) - switch color { - case .foreground: - updateStyle(style: .color(value)) - case .background: - updateStyle(style: .background(value)) - case .strikethrough: - return - case .stroke: - return - case .underline: - return - } + public func updateStyleFor( + _ color: RichTextColor, to val: ColorRepresentable + ) { + let value = Color(val) + switch color { + case .foreground: + updateStyle(style: .color(value)) + case .background: + updateStyle(style: .background(value)) + case .strikethrough: + return + case .stroke: + return + case .underline: + return } + } } extension RichEditorState { - /// Set the value for a certain color, or remove it. - func setColorInternal( - _ color: RichTextColor, - to val: ColorRepresentable? - ) { - guard let val else { - colors[color] = nil - return - } - colors[color] = val + /// Set the value for a certain color, or remove it. + func setColorInternal( + _ color: RichTextColor, + to val: ColorRepresentable? + ) { + guard let val else { + colors[color] = nil + return } + colors[color] = val + } } diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift index 76def11..71104ac 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditor.swift @@ -51,6 +51,7 @@ /// For more information, see ``RichTextKeyboardToolbarConfig`` /// and ``RichTextKeyboardToolbarStyle``. public struct RichTextEditor: ViewRepresentable { + @Environment(\.colorScheme) var colorScheme @State var cancellable: Set = [] @@ -112,6 +113,9 @@ textView.configuration = config textView.theme = style viewConfiguration(textView) + DispatchQueue.main.async { + self.context.colorScheme = self.colorScheme + } return textView } @@ -129,6 +133,9 @@ textView.configuration = config textView.theme = style viewConfiguration(textView) + DispatchQueue.main.async { + self.context.colorScheme = self.colorScheme + } return scrollView } diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift index 44233c1..ac9aebe 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift @@ -67,9 +67,9 @@ extension RichEditorState { - style: is of type RichTextSpanStyle */ public func updateStyle(style: RichTextSpanStyle, shouldRegisterUndo: Bool = true) { - let wasStyleActive = activeStyles.contains(style) - setInternalStyles(style: style) setStyle(style, shouldRegisterUndo: shouldRegisterUndo) + /// Don't change order of function call as it is comparing active attributes with new one so updating it before applying attribute will break the behavior of undo and redo + setInternalStyles(style: style) } } @@ -180,9 +180,10 @@ extension RichEditorState { addStyle = fontName == self.fontName } case .color(let color): - if let color, color.toHex() != Color.primary.toHex() { + let defaultColor = RichTextColor.foreground.adjust(nil, for: colorScheme) + if let color, color.toHex() != defaultColor.toHex() { if let internalColor = self.color(for: .foreground) { - addStyle = Color(internalColor) != color + addStyle = (Color(internalColor) != color) } else { addStyle = true } @@ -190,9 +191,10 @@ extension RichEditorState { addStyle = false } case .background(let bgColor): - if let color = bgColor, color.toHex() != Color.clear.toHex() { + let defaultColor = RichTextColor.background.adjust(nil, for: colorScheme) + if let color = bgColor, color.toHex() != defaultColor.toHex() { if let internalColor = self.color(for: .background) { - addStyle = Color(internalColor) != color + addStyle = (Color(internalColor) == color) } else { addStyle = true } @@ -899,10 +901,18 @@ extension RichEditorState { case .color(let color): if let color { setColor(.foreground, to: .init(color)) + } else { + setColor( + .foreground, + to: ColorRepresentable(RichTextColor.foreground.adjust(nil, for: colorScheme))) } case .background(let color): if let color { setColor(.background, to: .init(color)) + } else { + setColor( + .background, + to: ColorRepresentable(RichTextColor.background.adjust(nil, for: colorScheme))) } case .align(let alignment): if let alignment, alignment != self.textAlignment { From 448205ae5a2fd6e640d478a5ef5ea50b7c25186b Mon Sep 17 00:00:00 2001 From: Divyesh Canopas Date: Mon, 13 Jan 2025 15:40:34 +0530 Subject: [PATCH 5/6] Fixed overlaping style will gnerate duplicate and incorect span --- .../RichEditorDemo/ContentView.swift | 2 +- .../UI/Editor/RichEditorState+Spans.swift | 272 ++++++------------ 2 files changed, 84 insertions(+), 190 deletions(-) diff --git a/RichEditorDemo/RichEditorDemo/ContentView.swift b/RichEditorDemo/RichEditorDemo/ContentView.swift index d4c14d1..94ad42b 100644 --- a/RichEditorDemo/RichEditorDemo/ContentView.swift +++ b/RichEditorDemo/RichEditorDemo/ContentView.swift @@ -28,7 +28,7 @@ struct ContentView: View { { self.state = .init(richText: richText) } else { - self.state = .init(input: "Bold \n Italic \n Underline \n Strikethrough \n ") + self.state = .init(input: "Hello World!") } } } diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift index ac9aebe..8f2c37b 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift @@ -67,9 +67,9 @@ extension RichEditorState { - style: is of type RichTextSpanStyle */ public func updateStyle(style: RichTextSpanStyle, shouldRegisterUndo: Bool = true) { + setInternalStyles(style: style) setStyle(style, shouldRegisterUndo: shouldRegisterUndo) /// Don't change order of function call as it is comparing active attributes with new one so updating it before applying attribute will break the behavior of undo and redo - setInternalStyles(style: style) } } @@ -150,8 +150,9 @@ extension RichEditorState { if style.isHeaderStyle || style.isDefault //|| style.isList || style.isAlignmentStyle { + let shouldAdd = style.isHeaderStyle ? style.headerType != .default : !style.isDefault handleAddOrRemoveStyleToLine( - in: selectedRange, style: style, byAdding: !style.isDefault) + in: selectedRange, style: style, byAdding: shouldAdd) if shouldRegisterUndo { registerUndoForSetStyle(newStyle: style) } @@ -176,16 +177,17 @@ extension RichEditorState { addStyle = CGFloat(size) != CGFloat.standardRichTextFontSize } case .font(let fontName): - if let fontName { - addStyle = fontName == self.fontName + let defaultName = RichTextFont.PickerFont.standardSystemFontDisplayName + if let fontName, fontName != defaultName { + addStyle = fontName != defaultName + } else { + addStyle = false } case .color(let color): let defaultColor = RichTextColor.foreground.adjust(nil, for: colorScheme) if let color, color.toHex() != defaultColor.toHex() { if let internalColor = self.color(for: .foreground) { - addStyle = (Color(internalColor) != color) - } else { - addStyle = true + addStyle = (Color(internalColor) == color && Color(internalColor) != defaultColor) } } else { addStyle = false @@ -194,9 +196,7 @@ extension RichEditorState { let defaultColor = RichTextColor.background.adjust(nil, for: colorScheme) if let color = bgColor, color.toHex() != defaultColor.toHex() { if let internalColor = self.color(for: .background) { - addStyle = (Color(internalColor) == color) - } else { - addStyle = true + addStyle = (Color(internalColor) == color && Color(internalColor) != defaultColor) } } else { addStyle = false @@ -207,6 +207,7 @@ extension RichEditorState { } case .link(let link): addStyle = link != nil + default: return addStyle } @@ -587,8 +588,10 @@ extension RichEditorState { internalSpans.removeAll(where: { selectedParts.contains($0) }) } +} - //MARK: - Add Header style +//MARK: - Process Spans for style +extension RichEditorState { /** This will create span for selected text with provided style - Parameters: @@ -604,151 +607,97 @@ extension RichEditorState { var processedSpans: [RichTextSpanInternal] = [] - let completeOverlap = getCompleteOverlappingSpans(for: range) - var partialOverlap = getPartialOverlappingSpans(for: range) - var sameSpans = getSameSpans(for: range) - - partialOverlap.removeAll(where: { completeOverlap.contains($0) }) - sameSpans.removeAll(where: { completeOverlap.contains($0) }) - - let partialOverlapSpan = processPartialOverlappingSpans( - partialOverlap, range: range, style: style, addStyle: addStyle) - let completeOverlapSpan = processCompleteOverlappingSpans( - completeOverlap, range: range, style: style, addStyle: addStyle) - let sameSpan = processSameSpans( - sameSpans, range: range, style: style, addStyle: addStyle) - - processedSpans.append(contentsOf: partialOverlapSpan) - processedSpans.append(contentsOf: completeOverlapSpan) - processedSpans.append(contentsOf: sameSpan) - - processedSpans = mergeSameStyledSpans(processedSpans) - - internalSpans.removeAll(where: { - $0.closedRange.overlaps(range.closedRange) - }) - internalSpans.append(contentsOf: processedSpans) - internalSpans = mergeSameStyledSpans(internalSpans) - internalSpans.sort(by: { $0.from < $1.from }) - } - - private func processCompleteOverlappingSpans( - _ spans: [RichTextSpanInternal], range: NSRange, - style: RichTextSpanStyle, addStyle: Bool = true - ) -> [RichTextSpanInternal] { - var processedSpans: [RichTextSpanInternal] = [] - - for span in spans { - if span.closedRange.isInRange(range.closedRange) { - processedSpans.append( - span.copy( - attributes: span.attributes?.copy( - with: style, byAdding: addStyle))) - } else { - if span.from < range.lowerBound { - let leftPart = span.copy(to: range.lowerBound - 1) - processedSpans.append(leftPart) - } - - if span.from <= (range.lowerBound) - && span.to >= (range.upperBound - 1) - { - let centerPart = span.copy( - from: range.lowerBound, to: range.upperBound - 1, - attributes: span.attributes?.copy( - with: style, byAdding: addStyle)) - processedSpans.append(centerPart) - } - - if span.to > (range.upperBound - 1) { - let rightPart = span.copy(from: range.upperBound) - processedSpans.append(rightPart) + // First split existing spans at selection boundaries + let splitPoints = Set([range.lowerBound, range.upperBound]) + var currentSpans = internalSpans + + // Split spans at boundaries + for splitPoint in splitPoints { + currentSpans = currentSpans.flatMap { span -> [RichTextSpanInternal] in + if span.from < splitPoint && span.to >= splitPoint { + return [ + span.copy(to: splitPoint - 1), + span.copy(from: splitPoint), + ] } + return [span] } } - processedSpans = mergeSameStyledSpans(processedSpans) - - return processedSpans - } - - private func processPartialOverlappingSpans( - _ spans: [RichTextSpanInternal], range: NSRange, - style: RichTextSpanStyle, addStyle: Bool = true - ) -> [RichTextSpanInternal] { - var processedSpans: [RichTextSpanInternal] = [] - - for span in spans { - if span.from < range.location { - let leftPart = span.copy(to: range.lowerBound - 1) - let rightPart = span.copy( - from: range.lowerBound, - attributes: span.attributes?.copy( - with: style, byAdding: addStyle)) - processedSpans.append(leftPart) - processedSpans.append(rightPart) + // Process spans in selection range + for span in currentSpans { + if span.closedRange.overlaps(range.closedRange) { + // Span is within selection - apply new style + if span.closedRange.isInRange(range.closedRange) { + let newAttributes = span.attributes?.copy(with: style, byAdding: addStyle) + processedSpans.append(span.copy(attributes: newAttributes)) + } + // Span partially overlaps - split and apply style only to overlapping part + else { + if span.from < range.lowerBound { + processedSpans.append(span.copy(to: range.lowerBound - 1)) + } + + let overlapStart = max(span.from, range.lowerBound) + let overlapEnd = min(span.to, range.upperBound - 1) + + if overlapStart <= overlapEnd { + let newAttributes = span.attributes?.copy(with: style, byAdding: addStyle) + processedSpans.append( + span.copy( + from: overlapStart, + to: overlapEnd, + attributes: newAttributes + ) + ) + } + + if span.to >= range.upperBound { + processedSpans.append(span.copy(from: range.upperBound)) + } + } } else { - let leftPart = span.copy( - to: min(span.to, range.upperBound), - attributes: span.attributes?.copy( - with: style, byAdding: addStyle)) - let rightPart = span.copy(from: range.location) - processedSpans.append(leftPart) - processedSpans.append(rightPart) + // Span outside selection - keep unchanged + processedSpans.append(span) } } + // Merge adjacent spans with identical styles processedSpans = mergeSameStyledSpans(processedSpans) - return processedSpans - } - private func processSameSpans( - _ spans: [RichTextSpanInternal], range: NSRange, - style: RichTextSpanStyle, addStyle: Bool = true - ) -> [RichTextSpanInternal] { - var processedSpans: [RichTextSpanInternal] = [] - - processedSpans = spans.map({ - $0.copy( - attributes: $0.attributes?.copy(with: style, byAdding: addStyle) - ) - }) - - processedSpans = mergeSameStyledSpans(processedSpans) - return processedSpans + // Update internal spans + internalSpans = processedSpans.sorted(by: { $0.from < $1.from }) } - // merge adjacent spans with same style - private func mergeSameStyledSpans(_ spans: [RichTextSpanInternal]) - -> [RichTextSpanInternal] - { + ///Merge adjacent spans with same style + private func mergeSameStyledSpans(_ spans: [RichTextSpanInternal]) -> [RichTextSpanInternal] { guard !spans.isEmpty else { return [] } + var mergedSpans: [RichTextSpanInternal] = [] - var previousSpan: RichTextSpanInternal? + var currentSpan: RichTextSpanInternal? = spans[0] - for span in spans.sorted(by: { $0.from < $1.from }) { - if let current = previousSpan { - if span.attributes?.stylesSet() - == current.attributes?.stylesSet() - { - // Merge overlapping spans - previousSpan = current.copy(to: max(current.to, span.to)) - } else { - // Add merged span and start a new span - mergedSpans.append(current) - previousSpan = span - } + for nextSpan in spans.dropFirst() { + guard let current = currentSpan else { + currentSpan = nextSpan + continue + } + + // Only merge if styles exactly match and spans are adjacent + if current.attributes?.stylesSet() == nextSpan.attributes?.stylesSet() + && current.to + 1 == nextSpan.from + { + currentSpan = current.copy(to: nextSpan.to) } else { - previousSpan = span + mergedSpans.append(current) + currentSpan = nextSpan } } - // Append the last current span - if let lastSpan = previousSpan { + if let lastSpan = currentSpan { mergedSpans.append(lastSpan) } - return mergedSpans.sorted(by: { $0.from < $1.from }) + return mergedSpans } } @@ -783,61 +732,6 @@ extension RichEditorState { } } -//MARK: - RichTextSpanInternal Helper -extension RichEditorState { - /** - This will provide overlapping span for range - - Parameters: - - selectedRange: is of type NSRange - */ - private func getOverlappingSpans(for selectedRange: NSRange) - -> [RichTextSpanInternal] - { - return internalSpans.filter { - $0.closedRange.overlaps(selectedRange.closedRange) - } - } - - /** - This will provide partial overlapping span for range - - Parameters: - - selectedRange: selectedRange is of type NSRange - */ - func getPartialOverlappingSpans(for selectedRange: NSRange) - -> [RichTextSpanInternal] - { - return getOverlappingSpans(for: selectedRange).filter({ - $0.closedRange.isPartialOverlap(selectedRange.closedRange) - }) - } - - /** - This will provide complete overlapping span for range - - Parameters: - - selectedRange: selectedRange is of type NSRange - */ - func getCompleteOverlappingSpans(for selectedRange: NSRange) - -> [RichTextSpanInternal] - { - return getOverlappingSpans(for: selectedRange).filter({ - $0.closedRange.isInRange(selectedRange.closedRange) - || selectedRange.closedRange.isInRange($0.closedRange) - }) - } - - /** - This will provide same span for range - - Parameters: - - selectedRange: selectedRange is of type NSRange - */ - - func getSameSpans(for selectedRange: NSRange) -> [RichTextSpanInternal] { - return getOverlappingSpans(for: selectedRange).filter({ - $0.closedRange.isSameAs(selectedRange.closedRange) - }) - } -} - //MARK: - Helper Methods extension RichEditorState { /** From ae1e218a7fb418cd5c2f5a1465986e5ed809391d Mon Sep 17 00:00:00 2001 From: Divyesh Canopas Date: Tue, 21 Jan 2025 15:45:51 +0530 Subject: [PATCH 6/6] Fix color undo and redo --- .../Actions/RichTextAction.swift | 268 +++++++++--------- .../RichTextCoordinator+Actions.swift | 12 +- .../BaseFoundation/RichTextCoordinator.swift | 2 + .../Context/RichEditorState+UndoManager.swift | 219 +++++++------- .../UI/Context/RichTextContext+Color.swift | 2 +- .../UI/Editor/RichEditorState+Spans.swift | 13 +- .../UndoRedo/RichTextOperations.swift | 2 +- 7 files changed, 263 insertions(+), 255 deletions(-) diff --git a/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift b/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift index 532080c..33088f6 100644 --- a/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift +++ b/Sources/RichEditorSwiftUI/Actions/RichTextAction.swift @@ -15,179 +15,179 @@ import SwiftUI /// types and views, like ``RichTextAction/Button``. public enum RichTextAction: Identifiable, Equatable { - /// Copy the currently selected text, if any. - case copy + /// Copy the currently selected text, if any. + case copy - /// Dismiss any presented software keyboard. - case dismissKeyboard + /// Dismiss any presented software keyboard. + case dismissKeyboard - /// Paste a single image. - // case pasteImage(RichTextInsertion) - // - // /// Paste multiple images. - // case pasteImages(RichTextInsertion<[ImageRepresentable]>) - // - // /// Paste plain text. - // case pasteText(RichTextInsertion) + /// Paste a single image. + // case pasteImage(RichTextInsertion) + // + // /// Paste multiple images. + // case pasteImages(RichTextInsertion<[ImageRepresentable]>) + // + // /// Paste plain text. + // case pasteText(RichTextInsertion) - /// A print command. - case print + /// A print command. + case print - /// Redo the latest undone change. - case redoLatestChange + /// Redo the latest undone change. + case redoLatestChange - /// Select a range. - case selectRange(NSRange) + /// Select a range. + case selectRange(NSRange) - /// Set the text alignment. - case setAlignment(_ alignment: RichTextAlignment) + /// Set the text alignment. + case setAlignment(_ alignment: RichTextAlignment) - /// Set the entire attributed string. - case setAttributedString(NSAttributedString) + /// Set the entire attributed string. + case setAttributedString(NSAttributedString) - // Change background color - case setColor(RichTextColor, ColorRepresentable) + // Change background color + case setColor(RichTextColor, ColorRepresentable?) - // Highlighted renge - case setHighlightedRange(NSRange?) + // Highlighted renge + case setHighlightedRange(NSRange?) - // Change highlighting style - case setHighlightingStyle(RichTextHighlightingStyle) + // Change highlighting style + case setHighlightingStyle(RichTextHighlightingStyle) - /// Set a certain ``RichTextStyle``. - case setStyle(RichTextStyle, Bool) + /// Set a certain ``RichTextStyle``. + case setStyle(RichTextStyle, Bool) - /// Step the font size. - case stepFontSize(points: Int) + /// Step the font size. + case stepFontSize(points: Int) - /// Step the indent level. - case stepIndent(points: CGFloat) + /// Step the indent level. + case stepIndent(points: CGFloat) - /// Step the line spacing. - case stepLineSpacing(points: CGFloat) + /// Step the line spacing. + case stepLineSpacing(points: CGFloat) - /// Step the superscript level. - case stepSuperscript(steps: Int) + /// Step the superscript level. + case stepSuperscript(steps: Int) - /// Toggle a certain style. - case toggleStyle(_ style: RichTextStyle) + /// Toggle a certain style. + case toggleStyle(_ style: RichTextStyle) - /// Undo the latest change. - case undoLatestChange + /// Undo the latest change. + case undoLatestChange - /// Set HeaderStyle. - case setHeaderStyle(_ style: RichTextSpanStyle) + /// Set HeaderStyle. + case setHeaderStyle(_ style: RichTextSpanStyle) - /// Set link - case setLink(String? = nil) + /// Set link + case setLink(String? = nil) } extension RichTextAction { - public typealias Publisher = PassthroughSubject - - /// The action's unique identifier. - public var id: String { title } - - /// The action's standard icon. - public 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 - case .setLink: .richTextLink - } + public typealias Publisher = PassthroughSubject + + /// The action's unique identifier. + public var id: String { title } + + /// The action's standard icon. + public 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 + case .setLink: .richTextLink } - - /// The localized label to use for the action. - public var label: some View { - icon.label(title) - } - - /// The localized title to use in the main menu. - public var menuTitle: String { - menuTitleKey.text - } - - /// The localized title key to use in the main menu. - public var menuTitleKey: RTEL10n { - switch self { - case .stepIndent(let points): .menuIndent(points) - default: titleKey - } - } - - /// The localized action title. - public var title: String { - titleKey.text + } + + /// The localized label to use for the action. + public var label: some View { + icon.label(title) + } + + /// The localized title to use in the main menu. + public var menuTitle: String { + menuTitleKey.text + } + + /// The localized title key to use in the main menu. + public var menuTitleKey: RTEL10n { + switch self { + case .stepIndent(let points): .menuIndent(points) + default: titleKey } - - /// The localized action title key. - public 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 .setLink: .link - case .setHeaderStyle: .ignoreIt - } + } + + /// The localized action title. + public var title: String { + titleKey.text + } + + /// The localized action title key. + public 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 .setLink: .link + case .setHeaderStyle: .ignoreIt } + } } // MARK: - Aliases extension RichTextAction { - /// A name alias for `.redoLatestChange`. - public static var redo: RichTextAction { .redoLatestChange } + /// A name alias for `.redoLatestChange`. + public static var redo: RichTextAction { .redoLatestChange } - /// A name alias for `.undoLatestChange`. - public static var undo: RichTextAction { .undoLatestChange } + /// A name alias for `.undoLatestChange`. + public static var undo: RichTextAction { .undoLatestChange } } extension CGFloat { - /// The default rich text indent step size. - public static var defaultRichTextIntentStepSize: CGFloat = 30.0 + /// The default rich text indent step size. + public static var defaultRichTextIntentStepSize: CGFloat = 30.0 } extension UInt { - /// The default rich text indent step size. - public static var defaultRichTextIntentStepSize: UInt = 30 + /// The default rich text indent step size. + public static var defaultRichTextIntentStepSize: UInt = 30 } diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift index a7df753..470f5c6 100644 --- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift +++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator+Actions.swift @@ -29,6 +29,9 @@ import Foundation case .redoLatestChange: context.redoLastChanges() syncContextWithTextView() + case .undoLatestChange: + context.undoLastChanges() + syncContextWithTextView() case .selectRange(let range): setSelectedRange(to: range) case .setAlignment(let alignment): @@ -58,9 +61,6 @@ import Foundation case .toggleStyle(_): // textView.toggleRichTextStyle(style) return - case .undoLatestChange: - context.undoLastChanges() - syncContextWithTextView() case .setHeaderStyle(let style): let size = style.fontSizeMultiplier * .standardRichTextFontSize let range = textView.textString.getHeaderRangeFor( @@ -109,7 +109,7 @@ import Foundation // moveCursorToPastedContent: data.moveCursor // ) // } - + // // func pasteText(_ data: RichTextInsertion) { // textView.pasteText( // data.content, @@ -124,12 +124,12 @@ import Foundation } // TODO: This code should be handled by the component - func setColor(_ color: RichTextColor, to val: ColorRepresentable) { + func setColor(_ color: RichTextColor, to val: ColorRepresentable?) { var applyRange: NSRange? if textView.hasSelectedRange { applyRange = textView.selectedRange } - guard let attribute = color.attribute else { return } + guard let attribute = color.attribute, let val else { return } if let applyRange { textView.setRichTextColor(color, to: val, at: applyRange) } else { diff --git a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift index 224ae0a..4598ea5 100644 --- a/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift +++ b/Sources/RichEditorSwiftUI/BaseFoundation/RichTextCoordinator.swift @@ -251,6 +251,8 @@ RichTextColor.allCases.forEach { if let color = textView.richTextColor($0) { context.setColor($0, to: color) + } else { + context.setColor($0, to: nil) } } diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoManager.swift b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoManager.swift index 40c9943..cf3b67e 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoManager.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichEditorState+UndoManager.swift @@ -25,7 +25,7 @@ extension RichEditorState { // if let operation { // self.undoManager.registerUndoOperation(operation) // } - self.isOperationIsFromUser = false + self.isOperationIsFromUser = false // self.operationRawText = self.attributedString.string updateUndoRedoState() } @@ -45,22 +45,23 @@ extension RichEditorState { } private func restoreState(for operation: RichTextOperation, isRedo: Bool) { - setSelectedRange(range: operation.range) - if isRedo { -// setCurrentAttributes(attributes: operation.attributes) - } + setSelectedRange( + range: isRedo + ? operation.previousAttributes.selectedRange : operation.attributes.selectedRange) switch operation.operationType { - case .addOrRemoveText(_, let rawText, _): - // let shouldAdd = isRedo ? isAdded : !isAdded - self.isOperationIsFromUser = false - actionPublisher.send(.setAttributedString(attributedString)) - self.rawText = rawText - // let attributedText = getAttributedStringBy(adding: shouldAdd, chars: rawText, at: operation.range.location) - // self.attributedString = attributedText - // onTextFieldValueChange(newText: attributedText, selection: operation.range) - // self.operationRawText = attributedString.string - + case .addOrRemoveText: + let attributedText = NSMutableAttributedString( + attributedString: isRedo + ? operation.attributes.attributedString : operation.previousAttributes.attributedString) + if isRedo { + self.rawText = operation.previousAttributes.rawText + } else { + self.rawText = operation.attributes.rawText + } + actionPublisher.send(.setAttributedString(attributedText)) + onTextFieldValueChange( + newText: attributedText, selection: operation.range, shouldRegisterUndo: false) case .addOrRemoveStyle(let style, let isAdded): let shouldAdd = isRedo ? isAdded : !isAdded if shouldAdd { @@ -79,7 +80,7 @@ extension RichEditorState { } if !isRedo { - setCurrentAttributes(attributes: operation.previousAttributes) + setCurrentAttributes(attributes: operation.previousAttributes) } updateUndoRedoState() @@ -90,23 +91,23 @@ extension RichEditorState { selectedRange = range } private func setCurrentAttributes(attributes: OperationAttributes) { -// let attributedStringCopy = NSMutableAttributedString(attributedString: attributes.attributedString) -// attributedString = attributedStringCopy - selectedRange = attributes.selectedRange - headerType = attributes.headerType - textAlignment = attributes.textAlignment - fontName = attributes.fontName - fontSize = attributes.fontSize - lineSpacing = attributes.lineSpacing - colors = attributes.colors - highlightingStyle = attributes.highlightingStyle - paragraphStyle = attributes.paragraphStyle - styles = attributes.styles - link = attributes.link - highlightedRange = attributes.highlightedRange - activeStyles = attributes.activeStyles - activeAttributes = attributes.activeAttributes -// rawText = attributes.rawText + // let attributedStringCopy = NSMutableAttributedString(attributedString: attributes.attributedString) + // attributedString = attributedStringCopy + selectedRange = attributes.selectedRange + headerType = attributes.headerType + textAlignment = attributes.textAlignment + fontName = attributes.fontName + fontSize = attributes.fontSize + lineSpacing = attributes.lineSpacing + // colors = attributes.colors + highlightingStyle = attributes.highlightingStyle + paragraphStyle = attributes.paragraphStyle + styles = attributes.styles + link = attributes.link + highlightedRange = attributes.highlightedRange + activeStyles = attributes.activeStyles + activeAttributes = attributes.activeAttributes + // rawText = attributes.rawText } func getAttributedStringBy(adding: Bool, chars: String, at index: Int) @@ -132,7 +133,7 @@ extension RichEditorState { let range = NSRange( location: isAdded ? selectedRange.lowerBound : selectedRange.upperBound, length: 0) return RichTextOperation( - operationType: .addOrRemoveText(newText: newText, rawText: rawText, isAdded: isAdded), + operationType: .addOrRemoveText, range: range, attributes: getCurrentAttributes(), previousAttributes: getPreviousAttributes() @@ -149,8 +150,7 @@ extension RichEditorState { } func registerOperationForText(newText: NSAttributedString, rawText: String) { - guard isOperationIsFromUser, - let operation = getOperationForTextChange(newText, rawText: rawText) + guard let operation = getOperationForTextChange(newText, rawText: rawText) else { return } undoManager.registerUndoOperation(operation) updateUndoRedoState() @@ -195,80 +195,83 @@ extension RichEditorState { updateUndoRedoState() } - private func getPreviousStyleFor(style: RichTextSpanStyle) -> RichTextSpanStyle? { - var previousStyle: RichTextSpanStyle? = nil - switch style { - case .h1, .h2, .h3, .h4, .h5, .h6: - previousStyle = previousHeaderType.getTextSpanStyle() - case .size(_): - previousStyle = .size(Int(previousFontSize)) - case .font(_): - let fontName: String = previousFontName.isEmpty ? RichTextFont.PickerFont.standardSystemFontDisplayName : previousFontName - previousStyle = .font(fontName) - case .color(_): - if let color = previousColors[.foreground] { - previousStyle = .color(Color(color)) - } else { - previousStyle = .color() - } - case .background(_): - if let color = previousColors[.background] { - previousStyle = .background(Color(color)) - } else { - previousStyle = .background() - } - case .align(_): - previousStyle = .align(previousTextAlignment) - case .link(_): - previousStyle = .link(previousLink) - default: - previousStyle = nil - } - return previousStyle + private func getPreviousStyleFor(style: RichTextSpanStyle) -> RichTextSpanStyle? { + var previousStyle: RichTextSpanStyle? = nil + switch style { + case .h1, .h2, .h3, .h4, .h5, .h6: + previousStyle = previousHeaderType.getTextSpanStyle() + case .size(_): + previousStyle = .size(Int(previousFontSize)) + case .font(_): + let fontName: String = + previousFontName.isEmpty + ? RichTextFont.PickerFont.standardSystemFontDisplayName : previousFontName + previousStyle = .font(fontName) + case .color(_): + if let color = previousColors[.foreground] { + previousStyle = .color(Color(color)) + } else { + previousStyle = .color() + } + case .background(_): + if let color = previousColors[.background] { + previousStyle = .background(Color(color)) + } else { + previousStyle = .background() + } + case .align(_): + previousStyle = .align(previousTextAlignment) + case .link(_): + previousStyle = .link(previousLink) + default: + previousStyle = nil } + return previousStyle + } - private func getCurrentAttributes() -> OperationAttributes { - let attributedString = NSMutableAttributedString(attributedString: attributedString) + private func getCurrentAttributes() -> OperationAttributes { + let attributedString = NSMutableAttributedString(attributedString: attributedString) - return OperationAttributes( - attributedString: attributedString, - selectedRange: selectedRange, - headerType: headerType, - textAlignment: textAlignment, - fontName: fontName, - fontSize: fontSize, - lineSpacing: lineSpacing, - colors: colors, - highlightingStyle: highlightingStyle, - paragraphStyle: paragraphStyle, - styles: styles, - link: link, - highlightedRange: highlightedRange, - activeStyles: activeStyles, - activeAttributes: activeAttributes, - rawText: rawText - ) - } + return OperationAttributes( + attributedString: attributedString, + selectedRange: selectedRange, + headerType: headerType, + textAlignment: textAlignment, + fontName: fontName, + fontSize: fontSize, + lineSpacing: lineSpacing, + colors: colors, + highlightingStyle: highlightingStyle, + paragraphStyle: paragraphStyle, + styles: styles, + link: link, + highlightedRange: highlightedRange, + activeStyles: activeStyles, + activeAttributes: activeAttributes, + rawText: rawText + ) + } - private func getPreviousAttributes() -> OperationAttributes { - let previousAttributedString = NSMutableAttributedString(attributedString: previousAttributedString) - return OperationAttributes( - attributedString: previousAttributedString, - selectedRange: previousSelectedRange, - headerType: previousHeaderType, - textAlignment: previousTextAlignment, - fontName: previousFontName, - fontSize: previousFontSize, - lineSpacing: previousLineSpacing, - colors: previousColors, - highlightingStyle: previousHighlightingStyle, - paragraphStyle: previousParagraphStyle, - styles: previousStyles, - link: previousLink, - highlightedRange: previousHighlightedRange, - activeStyles: previousActiveStyles, - activeAttributes: previousActiveAttributes, - rawText: previousRawText - ) - } + private func getPreviousAttributes() -> OperationAttributes { + let previousAttributedString = NSMutableAttributedString( + attributedString: previousAttributedString) + return OperationAttributes( + attributedString: previousAttributedString, + selectedRange: previousSelectedRange, + headerType: previousHeaderType, + textAlignment: previousTextAlignment, + fontName: previousFontName, + fontSize: previousFontSize, + lineSpacing: previousLineSpacing, + colors: previousColors, + highlightingStyle: previousHighlightingStyle, + paragraphStyle: previousParagraphStyle, + styles: previousStyles, + link: previousLink, + highlightedRange: previousHighlightedRange, + activeStyles: previousActiveStyles, + activeAttributes: previousActiveAttributes, + rawText: previousRawText + ) + } } diff --git a/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift index b2fc2f9..4093cef 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift @@ -25,7 +25,7 @@ extension RichEditorState { /// Set the value for a certain color. public func setColor( _ color: RichTextColor, - to val: ColorRepresentable + to val: ColorRepresentable? ) { actionPublisher.send(.setColor(color, val)) setColorInternal(color, to: val) diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift index 8f2c37b..1ca6b26 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift @@ -109,11 +109,13 @@ extension RichEditorState { - selection: is the range of the selected text */ internal func onTextFieldValueChange( - newText: NSAttributedString, selection: NSRange + newText: NSAttributedString, + selection: NSRange, + shouldRegisterUndo: Bool = true ) { self.selectedRange = selection - // registerOperationForText(newText: newText, rawText: rawText) + updateCurrentSpanStyle() if newText.string.count > rawText.count { handleAddingCharacters(newText) } else if newText.string.count < rawText.count { @@ -121,8 +123,9 @@ extension RichEditorState { } rawText = newText.string - updateCurrentSpanStyle() - // beginEditingGroup(.textChange) + if shouldRegisterUndo { + registerOperationForText(newText: newText, rawText: rawText) + } } /** @@ -179,7 +182,7 @@ extension RichEditorState { case .font(let fontName): let defaultName = RichTextFont.PickerFont.standardSystemFontDisplayName if let fontName, fontName != defaultName { - addStyle = fontName != defaultName + addStyle = fontName == self.fontName && defaultName != fontName } else { addStyle = false } diff --git a/Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift b/Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift index 31add04..0eefe35 100644 --- a/Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift +++ b/Sources/RichEditorSwiftUI/UndoRedo/RichTextOperations.swift @@ -25,7 +25,7 @@ struct RichTextOperation { } enum OperationType { - case addOrRemoveText(newText: NSAttributedString, rawText: String, isAdded: Bool) + case addOrRemoveText case addOrRemoveStyle(style: RichTextSpanStyle, isAdded: Bool) case setStyleStyle(previousStyle: RichTextSpanStyle?, newStyle: RichTextSpanStyle, isSet: Bool) }