From 3c62768ec0a9d69724012831006ae720d57ed092 Mon Sep 17 00:00:00 2001 From: Divyesh Canopas Date: Thu, 21 Nov 2024 12:11:41 +0530 Subject: [PATCH] Support font color customization --- .../Alignment/RichTextAlignment+Picker.swift | 45 +++ .../Alignment/RichTextAlignment.swift | 2 +- .../Colors/RichTextColor+Picker.swift | 200 ++++++++++ .../Data/Models/RichAttributes.swift | 14 +- .../Fonts/RichTextFont+ListPicker.swift | 74 ++++ .../Fonts/RichTextFont+Picker.swift | 153 ++++++++ .../Fonts/RichTextFont+PickerConfig.swift | 100 +++++ .../Fonts/RichTextFont+PickerItem.swift | 46 +++ .../Fonts/RichTextFont+SizePicker.swift | 2 +- .../Format/RichTextFormat+Sheet.swift | 142 +++++++ .../Format/RichTextFormat+Sidebar.swift | 167 +++++++++ .../Format/RichTextFormat+Toolbar.swift | 135 +++++++ .../Format/RichTextFormat+ToolbarConfig.swift | 88 +++++ .../Format/RichTextFormat+ToolbarStyle.swift | 63 ++++ .../Format/RichTextFormat.swift | 11 + .../Format/RichTextFormatToolbarBase.swift | 177 +++++++++ .../UI/Context/RichTextContext+Color.swift | 18 +- .../UI/Editor/RichEditorState+Spans.swift | 43 ++- .../UI/Editor/TextSpanStyle.swift | 351 +++++++++--------- .../UI/EditorToolBar/EditorToolBarView.swift | 51 +-- .../UI/Extensions/Color+Extension.swift | 28 ++ .../UI/Views/ForEachPicker.swift | 87 +++++ .../UI/Views/ListPicker.swift | 69 ++++ .../UI/Views/ListPickerItem.swift | 29 ++ .../UI/Views/ListPickerSection.swift | 35 ++ 25 files changed, 1934 insertions(+), 196 deletions(-) create mode 100644 Sources/RichEditorSwiftUI/Alignment/RichTextAlignment+Picker.swift create mode 100644 Sources/RichEditorSwiftUI/Colors/RichTextColor+Picker.swift create mode 100644 Sources/RichEditorSwiftUI/Fonts/RichTextFont+ListPicker.swift create mode 100644 Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift create mode 100644 Sources/RichEditorSwiftUI/Fonts/RichTextFont+PickerConfig.swift create mode 100644 Sources/RichEditorSwiftUI/Fonts/RichTextFont+PickerItem.swift create mode 100644 Sources/RichEditorSwiftUI/Format/RichTextFormat+Sheet.swift create mode 100644 Sources/RichEditorSwiftUI/Format/RichTextFormat+Sidebar.swift create mode 100644 Sources/RichEditorSwiftUI/Format/RichTextFormat+Toolbar.swift create mode 100644 Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarConfig.swift create mode 100644 Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarStyle.swift create mode 100644 Sources/RichEditorSwiftUI/Format/RichTextFormat.swift create mode 100644 Sources/RichEditorSwiftUI/Format/RichTextFormatToolbarBase.swift create mode 100644 Sources/RichEditorSwiftUI/UI/Views/ForEachPicker.swift create mode 100644 Sources/RichEditorSwiftUI/UI/Views/ListPicker.swift create mode 100644 Sources/RichEditorSwiftUI/UI/Views/ListPickerItem.swift create mode 100644 Sources/RichEditorSwiftUI/UI/Views/ListPickerSection.swift diff --git a/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment+Picker.swift b/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment+Picker.swift new file mode 100644 index 0000000..dc85c02 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment+Picker.swift @@ -0,0 +1,45 @@ +// +// RichTextAlignment+Picker.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +import SwiftUI + +public extension RichTextAlignment { + + /// This picker can be used to pick a text alignment. + /// + /// This view returns a plain SwiftUI `Picker` view that + /// can be styled and configured with a `PickerStyle`. + struct Picker: View { + + /// Create a rich text alignment picker. + /// + /// - Parameters: + /// - selection: The binding to update with the picker. + /// - values: The pickable alignments, by default `.allCases`. + public init( + selection: Binding, + values: [RichTextAlignment] = RichTextAlignment.allCases + ) { + self._selection = selection + self.values = values + } + + let values: [RichTextAlignment] + + @Binding + private var selection: RichTextAlignment + + public var body: some View { + SwiftUI.Picker(RTEL10n.textAlignment.text, selection: $selection) { + ForEach(values) { value in + value.label + .labelStyle(.iconOnly) + } + } + } + } +} diff --git a/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift b/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift index 38e9b20..8bc247d 100644 --- a/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift +++ b/Sources/RichEditorSwiftUI/Alignment/RichTextAlignment.swift @@ -11,7 +11,7 @@ import SwiftUI This enum defines supported rich text alignments, like left, right, center, and justified. */ -public enum RichTextAlignment: String, CaseIterable, Codable, Equatable, Identifiable { +public enum RichTextAlignment: String, CaseIterable, Codable, Equatable, Identifiable, RichTextLabelValue { /** Initialize a rich text alignment with a native alignment. diff --git a/Sources/RichEditorSwiftUI/Colors/RichTextColor+Picker.swift b/Sources/RichEditorSwiftUI/Colors/RichTextColor+Picker.swift new file mode 100644 index 0000000..cbbc098 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Colors/RichTextColor+Picker.swift @@ -0,0 +1,200 @@ +// +// RichTextColor+Picker.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +import SwiftUI + +public extension RichTextColor { + + /** + This picker can be used to select a rich text color. + + This picker renders an icon next to the color picker as + well as an optional list of quick colors. + + The quick color list is empty by default. You can add a + custom set of colors, for instance `.quickPickerColors`. + */ + struct Picker: View { + + /** + Create a rich text color picker that binds to a color. + + - Parameters: + - type: The type of color to pick. + - icon: The icon to show, if any, by default the `type` icon. + - value: The value to bind to. + - quickColors: Colors to show in the trailing list, by default no colors. + */ + public init( + type: RichTextColor, + icon: Image? = nil, + value: Binding, + quickColors: [Color] = [] + ) { + self.type = type + self.icon = icon ?? type.icon + self._value = value + self.quickColors = quickColors + } + + private let type: RichTextColor + private let icon: Image? + private let quickColors: [Color] + + @Binding + private var value: Color + + private let spacing = 10.0 + + @Environment(\.colorScheme) + private var colorScheme + + public var body: some View { + HStack(spacing: 0) { + iconView + picker + if hasColors { + HStack(spacing: spacing) { + quickPickerDivider + quickPickerButton(for: nil) + quickPickerDivider + } + quickPicker + } + } + .labelsHidden() + } + } +} + +private extension RichTextColor.Picker { + + var hasColors: Bool { + !quickColors.isEmpty + } +} + +public extension Color { + + /// Get a curated list of quick color picker colors. + static var quickPickerColors: [Self] { + [ + .black, .gray, .white, + .red, .pink, .orange, .yellow, + .indigo, .purple, .blue, .cyan, .teal, .mint, + .green, .brown + ] + } +} + +public extension Collection where Element == Color { + + /// Get a curated list of quick color picker colors. + static var quickPickerColors: [Element] { + Element.quickPickerColors + } +} + +private extension RichTextColor.Picker { + + @ViewBuilder + var iconView: some View { + if let icon { + icon.frame(minWidth: 30) + } + } + + @ViewBuilder + var picker: some View { + #if iOS || macOS || os(visionOS) + ColorPicker("", selection: $value) + .fixedSize() + .padding(.horizontal, spacing) + #endif + } + + var quickPicker: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: spacing) { + ForEach(Array(quickColors.enumerated()), id: \.offset) { + quickPickerButton(for: $0.element) + } + } + .padding(.horizontal, spacing) + .padding(.vertical, 2) + }.frame(maxWidth: .infinity) + } + + func quickPickerButton(for color: Color?) -> some View { + Button { + value = type.adjust(color, for: colorScheme) + } label: { + if let color { + color + } else { + Image.richTextColorReset + } + } + .buttonStyle(ColorButtonStyle()) + } + + var quickPickerDivider: some View { + Divider() + .padding(0) + .frame(maxHeight: 30) + } +} + +private struct ColorButtonStyle: ButtonStyle { + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(width: 20, height: 20) + .clipShape(Circle()) + .shadow(radius: 1, x: 0, y: 1) + } +} + +#Preview { + + struct Preview: View { + @State + private var foregroundColor = Color.black + + @State + private var backgroundColor = Color.white + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Preview") + .foregroundStyle(foregroundColor) + .padding() + .background(backgroundColor) + .frame(maxWidth: .infinity) + .border(Color.black) + .background(Color.red) + .padding() + + RichTextColor.Picker( + type: .foreground, + value: $foregroundColor, + quickColors: [.white, .black, .red, .green, .blue] + ) + .padding(.leading) + + RichTextColor.Picker( + type: .background, + value: $backgroundColor, + quickColors: [.white, .black, .red, .green, .blue] + ) + .padding(.leading) + } + } + } + + return Preview() +} diff --git a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift index 37a4afe..2c5f59e 100644 --- a/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift +++ b/Sources/RichEditorSwiftUI/Data/Models/RichAttributes.swift @@ -5,7 +5,7 @@ // Created by Divyesh Vekariya on 04/04/24. // -import Foundation +import SwiftUI // MARK: - RichAttributes public struct RichAttributes: Codable { @@ -224,6 +224,18 @@ extension RichAttributes { if let list = list { styles.insert(list.getTextSpanStyle()) } + if let size = size { + styles.insert(.size(size)) + } + if let font = font { + styles.insert(.font(font)) + } + if let color = color { + styles.insert(.color(Color(hex: color))) + } + if let background = background { + styles.insert(.background(Color(hex: background))) + } return styles } } diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+ListPicker.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+ListPicker.swift new file mode 100644 index 0000000..6127374 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+ListPicker.swift @@ -0,0 +1,74 @@ +// +// RichTextFont+ListPicker.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +import SwiftUI + +public extension RichTextFont { + + /** + This view uses a `List` to list a set of fonts of which + one can be selected. + + Unlike ``RichTextFont/Picker`` this picker presents all + pickers with proper previews on all platforms. You must + therefore add it ina way that gives it space. + + You can configure this picker by applying a config view + modifier to your view hierarchy: + + ```swift + VStack { + RichTextFont.ListPicker(...) + ... + } + .richTextFontPickerConfig(...) + ``` + */ + struct ListPicker: View { + + /** + Create a font list picker. + + - Parameters: + - selection: The selected font name. + */ + public init( + selection: Binding + ) { + self._selection = selection + } + + public typealias Config = RichTextFont.PickerConfig + public typealias Font = Config.Font + public typealias FontName = Config.FontName + + @Binding + private var selection: FontName + + @Environment(\.richTextFontPickerConfig) + private var config + + 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 + ) + } + } + } +} diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift new file mode 100644 index 0000000..99c51cf --- /dev/null +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+Picker.swift @@ -0,0 +1,153 @@ +// +// RichTextFont+Picker.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +import SwiftUI + +public extension RichTextFont { + + /** + This font picker can be used to pick a font from a list, + using ``RichTextFont/PickerFont/all`` as default fonts. + + This view uses a plain `Picker`, which renders fonts on + macOS, but not on iOS. To render fonts correctly on all + platforms, you can use a ``RichTextFont/ListPicker`` or + a ``RichTextFont/ForEachPicker``. + + You can configure this picker by applying a config view + modifier to your view hierarchy: + + ```swift + VStack { + RichTextFont.Picker(...) + ... + } + .richTextFontPickerConfig(...) + ``` + + Note that this picker will not apply all configurations. + */ + struct Picker: View { + + /** + Create a font picker. + + - Parameters: + - selection: The selected font name. + */ + public init( + selection: Binding + ) { + self._selection = selection + self.selectedFont = Config.Font.all.first + } + + public typealias Config = RichTextFont.PickerConfig + public typealias Font = Config.Font + public typealias FontName = Config.FontName + + @State + private var selectedFont: Font? + + @Binding + private var selection: FontName + + @Environment(\.richTextFontPickerConfig) + private var config + + public var body: some View { + SwiftUI.Picker(selection: $selection) { + ForEach(config.fonts) { font in + RichTextFont.PickerItem( + font: font, + fontSize: config.fontSize, + isSelected: false + ) + .tag(font.fontName) + } + } label: { + EmptyView() + } + } + } +} + +private extension RichTextFont.PickerFont { + + /** + A system font has a font name that may be resolved to a + different name when picked. We must thus try to pattern + match, using the currently selected font name. + */ + func matches(_ fontName: String) -> Bool { + let compare = fontName.lowercased() + let fontName = self.fontName.lowercased() + if fontName == compare { return true } + if compare.hasPrefix(fontName.replacingOccurrences(of: " ", with: "")) { return true } + if compare.hasPrefix(fontName.replacingOccurrences(of: " ", with: "-")) { return true } + return false + } + + /** + Use the selected font name as tag for the selected font. + */ + func tag(for selectedFont: Self?, selectedName: String) -> String { + let isSelected = fontName == selectedFont?.fontName + return isSelected ? selectedName : fontName + } +} + + +//extension View { +// +// func withPreviewPickerStyles() -> some View { +// NavigationView { +// #if macOS +// Color.clear +// #endif +// ScrollView { +// VStack(spacing: 10) { +// self.label("Default") +// self.pickerStyle(.automatic).label(".automatic") +// self.pickerStyle(.inline).label(".inline") +// #if iOS || macOS +// self.pickerStyle(.menu).label(".menu") +// #endif +// #if iOS +// if #available(iOS 16.0, *) { +// pickerStyle(.navigationLink).label(".navigationLink") +// } +// #endif +// #if iOS || macOS +// if #available(iOS 17.0, macOS 14.0, watchOS 10.0, *) { +// pickerStyle(.palette).label(".palette") +// } +// #endif +// #if iOS || macOS || os(tvOS) || os(visionOS) +// self.pickerStyle(.segmented).label(".segmented") +// #endif +// #if iOS +// pickerStyle(.wheel).label(".wheel") +// #endif +// } +// } +// } +// } +//} +// +//private extension View { +// +// func label(_ title: String) -> some View { +// VStack { +// Text(title) +// .font(.footnote) +// .foregroundStyle(.secondary) +// self +// Divider() +// } +// } +//} diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+PickerConfig.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+PickerConfig.swift new file mode 100644 index 0000000..49af262 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+PickerConfig.swift @@ -0,0 +1,100 @@ +// +// RichTextFont+PickerConfig.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +import SwiftUI + +public extension RichTextFont { + + /// This type can configure a ``RichTextFont/Picker``. + /// + /// This configuration contains configuration properties + /// for many different font pickers types. Some of these + /// properties are not used in some pickers. + struct PickerConfig { + + /// Create a custom font picker config. + /// + /// - Parameters: + /// - fonts: The fonts to display in the list, by default `all`. + /// - fontSize: The font size to use in the list items, by default `20`. + /// - dismissAfterPick: Whether or not to dismiss the picker after a font is selected, by default `false`. + /// - moveSelectionTopmost: Whether or not to place the selected font topmost, by default `true`. + public init( + fonts: [RichTextFont.PickerFont] = .all, + fontSize: CGFloat = 20, + dismissAfterPick: Bool = false, + moveSelectionTopmost: Bool = true + ) { + self.fonts = fonts + self.fontSize = fontSize + self.dismissAfterPick = dismissAfterPick + self.moveSelectionTopmost = moveSelectionTopmost + } + + public typealias Font = RichTextFont.PickerFont + public typealias FontName = String + + /// The fonts to display in the list. + public var fonts: [RichTextFont.PickerFont] + + /// The font size to use in the list items. + public var fontSize: CGFloat + + /// Whether or not to dismiss the picker after a font is selected. + public var dismissAfterPick: Bool + + /// Whether or not to move the selected font topmost + public var moveSelectionTopmost: Bool + } +} + +public extension RichTextFont.PickerConfig { + + /// The standard font picker configuration. + static var standard: Self { .init() } +} + +public extension RichTextFont.PickerConfig { + + /// The fonts to list for a given selection. + func fontsToList(for selection: FontName) -> [Font] { + if moveSelectionTopmost { + return fonts.moveTopmost(selection) + } else { + return fonts + } + } +} + +public extension View { + + /// Apply a ``RichTextFont`` picker configuration. + func richTextFontPickerConfig( + _ config: RichTextFont.PickerConfig + ) -> some View { + self.environment(\.richTextFontPickerConfig, config) + } +} + +private extension RichTextFont.PickerConfig { + + struct Key: EnvironmentKey { + + public static var defaultValue: RichTextFont.PickerConfig { + .standard + } + } +} + +public extension EnvironmentValues { + + /// This value can bind to a font picker config. + var richTextFontPickerConfig: RichTextFont.PickerConfig { + get { self [RichTextFont.PickerConfig.Key.self] } + set { self [RichTextFont.PickerConfig.Key.self] = newValue } + } +} diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+PickerItem.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+PickerItem.swift new file mode 100644 index 0000000..c9938a7 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+PickerItem.swift @@ -0,0 +1,46 @@ +// +// RichTextFont+PickerItem.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +import SwiftUI + +extension RichTextFont { + + /** + This struct is used by the various library font pickers. + */ + struct PickerItem: View, ListPickerItem { + + init( + font: Item, + fontSize: CGFloat = 20, + isSelected: Bool + ) { + self.font = font + self.fontSize = fontSize + self.isSelected = isSelected + } + + typealias Item = RichTextFont.PickerFont + + let font: Item + let fontSize: CGFloat + let isSelected: Bool + + var item: Item { font } + + var body: some View { + HStack { + Text(font.fontDisplayName) + .font(.custom(font.fontName, size: fontSize)) + Spacer() + if isSelected { + checkmark + } + }.contentShape(Rectangle()) + } + } +} diff --git a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift index aaa46c0..bb2c122 100644 --- a/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift +++ b/Sources/RichEditorSwiftUI/Fonts/RichTextFont+SizePicker.swift @@ -47,7 +47,7 @@ public extension RichTextFont { private var config public var body: some View { - Picker("", selection: $selection) { + SwiftUI.Picker("", selection: $selection) { ForEach(values( for: config.values, selection: selection diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sheet.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sheet.swift new file mode 100644 index 0000000..fe88765 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sheet.swift @@ -0,0 +1,142 @@ +// +// RichTextFormat+Sheet.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +#if iOS || macOS || os(visionOS) +import SwiftUI + +public extension RichTextFormat { + + /** + This sheet contains a font picker and a bottom toolbar. + + You can configure and style the view by applying config + and style view modifiers to your view hierarchy: + + ```swift + VStack { + ... + } + .richTextFormatSheetStyle(...) + .richTextFormatSheetConfig(...) + ``` + */ + 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 extension View { + + /// Apply a rich text format sheet config. + func richTextFormatSheetConfig( + _ value: RichTextFormat.Sheet.Config + ) -> some View { + self.environment(\.richTextFormatSheetConfig, value) + } + + /// Apply a rich text format sheet style. + func richTextFormatSheetStyle( + _ value: RichTextFormat.Sheet.Style + ) -> some View { + self.environment(\.richTextFormatSheetStyle, value) + } +} + +private extension RichTextFormat.Sheet.Config { + + struct Key: EnvironmentKey { + + static var defaultValue: RichTextFormat.Sheet.Config { + .standard + } + } +} + +private extension RichTextFormat.Sheet.Style { + + struct Key: EnvironmentKey { + + static var defaultValue: RichTextFormat.Sheet.Style { + .standard + } + } +} + +public extension EnvironmentValues { + + /// This value can bind to a format sheet config. + 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. + 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 new file mode 100644 index 0000000..49b4842 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Sidebar.swift @@ -0,0 +1,167 @@ +// +// RichTextFormat+Sidebar.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +#if iOS || macOS || os(visionOS) +import SwiftUI + +public extension RichTextFormat { + + /** + This sidebar view provides various text format options, and + is meant to be used on macOS, in a trailing sidebar. + + You can configure and style the view by applying its config + and style view modifiers to your view hierarchy: + + ```swift + VStack { + ... + } + .richTextFormatSidebarStyle(...) + .richTextFormatSidebarConfig(...) + ``` + + > Note: The sidebar is currently designed for macOS, but it + should also be made to look good on iPadOS in landscape, to + let us use it instead of the ``RichTextFormat/Sheet``. + */ + 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 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) + HStack { +// styleToggleGroup(for: context) +// Spacer() + fontSizePicker(for: context) + } + } + + Divider() + +// SidebarSection { +// alignmentPicker(value: $context.textAlignment) +// 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 { + + @ViewBuilder + let content: () -> Content + + @Environment(\.richTextFormatToolbarStyle) + var style + + var body: some View { + VStack(alignment: .leading, spacing: style.spacing) { + content() + } + } +} + +public extension View { + + /// Apply a rich text format sidebar config. + func richTextFormatSidebarConfig( + _ value: RichTextFormat.Sidebar.Config + ) -> some View { + self.environment(\.richTextFormatSidebarConfig, value) + } + + /// Apply a rich text format sidebar style. + func richTextFormatSidebarStyle( + _ value: RichTextFormat.Sidebar.Style + ) -> some View { + self.environment(\.richTextFormatSidebarStyle, value) + } +} + +private extension RichTextFormat.Sidebar.Config { + + struct Key: EnvironmentKey { + + static var defaultValue: RichTextFormat.Sidebar.Config { + .standard + } + } +} + +private extension RichTextFormat.Sidebar.Style { + + struct Key: EnvironmentKey { + + static var defaultValue: RichTextFormat.Sidebar.Style { + .standard + } + } +} + +public extension EnvironmentValues { + + /// This value can bind to a format sidebar config. + 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. + 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 new file mode 100644 index 0000000..d9c3d69 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat+Toolbar.swift @@ -0,0 +1,135 @@ +// +// RichTextFormat+Toolbar.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +#if iOS || macOS || os(visionOS) +import SwiftUI + +public extension RichTextFormat { + + /** + This horizontal toolbar provides text format controls. + + This toolbar adapts the layout based on horizontal size + class. The control row is split in two for compact size, + while macOS and regular sizes get a single row. + + You can configure and style the view by applying config + and style view modifiers to your view hierarchy: + + ```swift + VStack { + ... + } + .richTextFormatToolbarStyle(...) + .richTextFormatToolbarConfig(...) + ``` + */ + struct Toolbar: RichTextFormatToolbarBase { + + /** + Create a rich text format sheet. + + - Parameters: + - context: The context to apply changes to. + */ + public init( + context: RichEditorState + ) { + self._context = ObservedObject(wrappedValue: context) + } + + @ObservedObject + private var context: RichEditorState + + @Environment(\.richTextFormatToolbarConfig) + var config + + @Environment(\.richTextFormatToolbarStyle) + var style + + @Environment(\.horizontalSizeClass) + private var horizontalSizeClass + + public var body: some View { + VStack(spacing: style.spacing) { +// controls + if hasColorPickers { +// Divider() + colorPickers(for: context) + } + } + .labelsHidden() + .padding(.vertical, style.padding) + .environment(\.sizeCategory, .medium) +// .background(background) + #if macOS + .frame(minWidth: 650) + #endif + } + } +} + +// MARK: - Views + +private extension RichTextFormat.Toolbar { + + var useSingleLine: Bool { + #if macOS + true + #else + horizontalSizeClass == .regular + #endif + } +} + +private extension RichTextFormat.Toolbar { + + var background: some View { + Color.clear + .overlay(Color.primary.opacity(0.1)) + .shadow(color: .black.opacity(0.1), radius: 5) + .edgesIgnoringSafeArea(.all) + } + + @ViewBuilder + var controls: some View { + if useSingleLine { + HStack { + controlsContent + } + .padding(.horizontal, style.padding) + } else { + VStack(spacing: style.spacing) { + controlsContent + } + .padding(.horizontal, style.padding) + } + } + + @ViewBuilder + var controlsContent: some View { + HStack { + #if macOS + fontPicker(value: $context.fontName) + #endif +// styleToggleGroup(for: context) + if !useSingleLine { + Spacer() + } + fontSizePicker(for: context) + if horizontalSizeClass == .regular { + Spacer() + } + } + HStack { + alignmentPicker(value: $context.textAlignment) +// superscriptButtons(for: context, greedy: false) +// indentButtons(for: context, greedy: false) + } + } +} +#endif diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarConfig.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarConfig.swift new file mode 100644 index 0000000..34df524 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarConfig.swift @@ -0,0 +1,88 @@ +// +// RichTextFormat+ToolbarConfig.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +#if iOS || macOS || os(visionOS) +import SwiftUI + +public extension RichTextFormat { + + /// This type can be used to configure a format toolbar. + struct ToolbarConfig { + + public init( + alignments: [RichTextAlignment] = .all, + colorPickers: [RichTextColor] = [.foreground], + colorPickersDisclosed: [RichTextColor] = [], + fontPicker: Bool = true, + fontSizePicker: Bool = true, + indentButtons: Bool = true, + lineSpacingPicker: Bool = false, + styles: [RichTextStyle] = [.bold, .italic, .underline, .strikethrough], + superscriptButtons: Bool = true + ) { + self.alignments = alignments + self.colorPickers = colorPickers + self.colorPickersDisclosed = colorPickersDisclosed + self.fontPicker = fontPicker + self.fontSizePicker = fontSizePicker + self.indentButtons = indentButtons + self.lineSpacingPicker = lineSpacingPicker + self.styles = styles + #if macOS + self.superscriptButtons = superscriptButtons + #else + self.superscriptButtons = false + #endif + } + + public var alignments: [RichTextAlignment] + public var colorPickers: [RichTextColor] + public var colorPickersDisclosed: [RichTextColor] + public var fontPicker: Bool + public var fontSizePicker: Bool + public var indentButtons: Bool + public var lineSpacingPicker: Bool + public var styles: [RichTextStyle] + public var superscriptButtons: Bool + } +} + +public extension RichTextFormat.ToolbarConfig { + + /// The standard rich text format toolbar configuration. + static var standard: Self { .init() } +} + +public extension View { + + /// Apply a rich text format toolbar style. + func richTextFormatToolbarConfig( + _ value: RichTextFormat.ToolbarConfig + ) -> some View { + self.environment(\.richTextFormatToolbarConfig, value) + } +} + +private extension RichTextFormat.ToolbarConfig { + + struct Key: EnvironmentKey { + + public static var defaultValue: RichTextFormat.ToolbarConfig { + .init() + } + } +} + +public extension EnvironmentValues { + + /// This value can bind to a format toolbar config. + var richTextFormatToolbarConfig: RichTextFormat.ToolbarConfig { + get { self [RichTextFormat.ToolbarConfig.Key.self] } + set { self [RichTextFormat.ToolbarConfig.Key.self] = newValue } + } +} +#endif diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarStyle.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarStyle.swift new file mode 100644 index 0000000..1e49a84 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat+ToolbarStyle.swift @@ -0,0 +1,63 @@ +// +// RichTextFormat+ToolbarStyle.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +#if iOS || macOS || os(visionOS) +import SwiftUI + +public extension RichTextFormat { + + /// This type can be used to style a format toolbar. + struct ToolbarStyle { + + public init( + padding: Double = 10, + spacing: Double = 10 + ) { + self.padding = padding + self.spacing = spacing + } + + public var padding: Double + public var spacing: Double + } +} + +public extension RichTextFormat.ToolbarStyle { + + /// The standard rich text format toolbar style. + static var standard: Self { .init() } +} + +public extension View { + + /// Apply a rich text format toolbar style. + func richTextFormatToolbarStyle( + _ style: RichTextFormat.ToolbarStyle + ) -> some View { + self.environment(\.richTextFormatToolbarStyle, style) + } +} + +private extension RichTextFormat.ToolbarStyle { + + struct Key: EnvironmentKey { + + public static var defaultValue: RichTextFormat.ToolbarStyle { + .standard + } + } +} + +public extension EnvironmentValues { + + /// This value can bind to a format toolbar style. + var richTextFormatToolbarStyle: RichTextFormat.ToolbarStyle { + get { self [RichTextFormat.ToolbarStyle.Key.self] } + set { self [RichTextFormat.ToolbarStyle.Key.self] = newValue } + } +} +#endif diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormat.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormat.swift new file mode 100644 index 0000000..2c7f527 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormat.swift @@ -0,0 +1,11 @@ +// +// RichTextLine.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +import Foundation + +/// This is a namespace for format-related types and views. +public struct RichTextFormat {} diff --git a/Sources/RichEditorSwiftUI/Format/RichTextFormatToolbarBase.swift b/Sources/RichEditorSwiftUI/Format/RichTextFormatToolbarBase.swift new file mode 100644 index 0000000..0fdf072 --- /dev/null +++ b/Sources/RichEditorSwiftUI/Format/RichTextFormatToolbarBase.swift @@ -0,0 +1,177 @@ +// +// RichTextFormatToolbarBase.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +#if iOS || macOS || os(visionOS) +import SwiftUI + +/// 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 } +} + +extension RichTextFormatToolbarBase { + + var hasColorPickers: Bool { + let colors = config.colorPickers + let disclosed = config.colorPickersDisclosed + return !colors.isEmpty || !disclosed.isEmpty + } +} + +extension RichTextFormatToolbarBase { + + @ViewBuilder + func alignmentPicker( + value: Binding + ) -> some View { + if !config.alignments.isEmpty { + RichTextAlignment.Picker( + selection: value, + values: config.alignments + ) + .pickerStyle(.segmented) + } + } + + @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 colorPickers( + for colors: [RichTextColor], + context: RichEditorState + ) -> some View { + if !colors.isEmpty { + ForEach(colors) { + colorPicker(for: $0, 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) + } + } + } + + func colorPicker( + for color: RichTextColor, + context: RichEditorState + ) -> some View { + RichTextColor.Picker( + type: color, + value: context.binding(for: color), + quickColors: .quickPickerColors + ) + } + + @ViewBuilder + func fontPicker( + value: Binding + ) -> some View { + if config.fontPicker { + RichTextFont.Picker( + selection: value + ) + .richTextFontPickerConfig(.init(fontSize: 12)) + } + } + + @ViewBuilder + func fontSizePicker( + for context: RichEditorState + ) -> some View { + if config.fontSizePicker { + RichTextFont.SizePickerStack(context: context) + .buttonStyle(.bordered) + } + } + +// @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 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/UI/Context/RichTextContext+Color.swift b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift index edc686a..65ac784 100644 --- a/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift +++ b/Sources/RichEditorSwiftUI/UI/Context/RichTextContext+Color.swift @@ -13,7 +13,7 @@ public extension RichEditorState { func binding(for color: RichTextColor) -> Binding { Binding( get: { Color(self.color(for: color) ?? .clear) }, - set: { self.setColor(color, to: .init($0)) } + set: { self.updateStyleFor(color, to: .init($0)) } ) } @@ -31,6 +31,22 @@ public extension RichEditorState { actionPublisher.send(.setColor(color, val)) setColorInternal(color, to: val) } + + 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 { diff --git a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift index 02ad6cc..a5e668e 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/RichEditorState+Spans.swift @@ -137,10 +137,7 @@ extension RichEditorState { if style.isHeaderStyle || style.isDefault || style.isList { handleAddOrRemoveHeaderOrListStyle(in: selectedRange, style: style, byAdding: !style.isDefault) } else if !selectedRange.isCollapsed { - var addStyle = true - if case .size(let size) = style, let size, CGFloat(size) == CGFloat.standardRichTextFontSize { - addStyle = false - } + var addStyle = checkIfStyleIsActiveWithSameAttributes(style) processSpansFor(new: style, in: selectedRange, addStyle: addStyle) } @@ -148,6 +145,44 @@ extension RichEditorState { updateCurrentSpanStyle() } + func checkIfStyleIsActiveWithSameAttributes(_ style: TextSpanStyle) -> Bool { + var addStyle: Bool = true + switch style { + case .size(let size): + if let size { + addStyle = CGFloat(size) == CGFloat.standardRichTextFontSize + } + case .font(let fontName): + if let fontName { + addStyle = fontName == self.fontName + } + case .color(let color): + if let color, color.toHex() != Color.primary.toHex() { + if let internalColor = self.color(for: .foreground) { + addStyle = Color(internalColor) != color + } else { + addStyle = true + } + } else { + addStyle = false + } + case .background(let bgColor): + if let color = bgColor, color != .clear { + if let internalColor = self.color(for: .background) { + addStyle = Color(internalColor) != color + } else { + addStyle = true + } + } else { + addStyle = false + } + default: + return addStyle + } + + return addStyle + } + /** Update the activeStyles and activeAttributes */ diff --git a/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift b/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift index 3e18da8..13eb854 100644 --- a/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift +++ b/Sources/RichEditorSwiftUI/UI/Editor/TextSpanStyle.swift @@ -1,6 +1,6 @@ // // TextSpanStyle.swift -// +// // // Created by Divyesh Vekariya on 11/10/23. // @@ -10,7 +10,7 @@ import SwiftUI public typealias RichTextStyle = TextSpanStyle public enum TextSpanStyle: Equatable, CaseIterable, Hashable { - + public static var allCases: [TextSpanStyle] = [ .default, .bold, @@ -32,7 +32,7 @@ public enum TextSpanStyle: Equatable, CaseIterable, Hashable { public func hash(into hasher: inout Hasher) { hasher.combine(key) - + if case .bullet(let indent) = self { hasher.combine(indent) } @@ -50,7 +50,7 @@ public enum TextSpanStyle: Equatable, CaseIterable, Hashable { case h5 case h6 case bullet(_ indent: Int? = nil) -// case ordered(_ indent: Int? = nil) + // case ordered(_ indent: Int? = nil) case size(Int? = nil) case font(String? = nil) case color(Color? = nil) @@ -58,91 +58,91 @@ public enum TextSpanStyle: Equatable, CaseIterable, Hashable { var key: String { switch self { - case .default: - return "default" - case .bold: - return "bold" - case .italic: - return "italic" - case .underline: - return "underline" - case .strikethrough: - return "strikethrough" - case .h1: - return "h1" - case .h2: - return "h2" - case .h3: - return "h3" - case .h4: - return "h4" - case .h5: - return "h5" - case .h6: - return "h6" - case .bullet: - return "bullet" - // case .ordered: - // return "ordered" - case .size: - return "size" - case .font: - return "font" - case .color: - return "color" - case .background: - return "background" + case .default: + return "default" + case .bold: + return "bold" + case .italic: + return "italic" + case .underline: + return "underline" + case .strikethrough: + return "strikethrough" + case .h1: + return "h1" + case .h2: + return "h2" + case .h3: + return "h3" + case .h4: + return "h4" + case .h5: + return "h5" + case .h6: + return "h6" + case .bullet: + return "bullet" + // case .ordered: + // return "ordered" + case .size: + return "size" + case .font: + return "font" + case .color: + return "color" + case .background: + return "background" } } func defaultAttributeValue(font: FontRepresentable? = nil) -> Any { let font = font ?? .systemFont(ofSize: .standardRichTextFontSize) switch self { - case .underline: - return NSUnderlineStyle.single.rawValue - case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6: + case .underline: + return NSUnderlineStyle.single.rawValue + case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6: + return getFontWithUpdating(font: font) + case .bullet(let indent): + return getListStyleAttributeValue(listType ?? .bullet(), indent: indent) + case .strikethrough: + return NSUnderlineStyle.single.rawValue + case .size(let size): + if let fontSize = size { + return getFontWithUpdating(font: font) + .withSize(CGFloat(fontSize)) + } else { return getFontWithUpdating(font: font) - case .bullet(let indent): - return getListStyleAttributeValue(listType ?? .bullet(), indent: indent) - case .strikethrough: - return NSUnderlineStyle.single.rawValue - case .size(let size): - if let fontSize = size { - return getFontWithUpdating(font: font) - .withSize(CGFloat(fontSize)) - } else { - return getFontWithUpdating(font: font) - } - case .font(let name): - if let name { - return FontRepresentable( - name: name, - size: .standardRichTextFontSize - ) ?? font - } else { - return RichTextView.Theme.standard.font - } - case .color: - return RichTextView.Theme.standard.fontColor - case .background: + } + case .font(let name): + if let name { + return FontRepresentable( + name: name, + size: .standardRichTextFontSize + ) ?? font + } else { + return RichTextView.Theme.standard.font + } + case .color: + return RichTextView.Theme.standard.fontColor + case .background: return ColorRepresentable.white } } var attributedStringKey: NSAttributedString.Key { switch self { - case .underline: - return .underlineStyle - case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6, .size, .font: - return .font - case .bullet: - return .paragraphStyle - case .strikethrough: - return .strikethroughStyle - case .color: - return .foregroundColor - case .background: - return .backgroundColor + case .underline: + return .underlineStyle + case .default, .bold, .italic, .h1, .h2, .h3, .h4, .h5, .h6, .size, .font: + return .font + case .bullet: + return .paragraphStyle + case .strikethrough: + return .strikethroughStyle + case .color: + return .foregroundColor + case .background: + return .backgroundColor } } @@ -174,33 +174,33 @@ public enum TextSpanStyle: Equatable, CaseIterable, Hashable { var editorTools: EditorTextStyleTool? { switch self { - case .default: - return .none - case .bold: - return .bold - case .italic: - return .italic - case .underline: - return .underline - case .strikethrough: - return .strikethrough + case .default: + return .none + case .bold: + return .bold + case .italic: + return .italic + case .underline: + return .underline + case .strikethrough: + return .strikethrough case .bullet(_): -// return .list(.bullet(indent)) - return nil - case .h1: - return .header(.h1) - case .h2: - return .header(.h2) - case .h3: - return .header(.h3) - case .h4: - return .header(.h4) - case .h5: - return .header(.h5) - case .h6: - return .header(.h6) - default: - return .none + // return .list(.bullet(indent)) + return nil + case .h1: + return .header(.h1) + case .h2: + return .header(.h2) + case .h3: + return .header(.h3) + case .h4: + return .header(.h4) + case .h5: + return .header(.h5) + case .h6: + return .header(.h6) + default: + return .none } } @@ -213,6 +213,25 @@ public enum TextSpanStyle: Equatable, CaseIterable, Hashable { } } + var headerType: HeaderType { + switch self { + case .h1: + return .h1 + case .h2: + return .h2 + case .h3: + return .h3 + case .h4: + return .h4 + case .h5: + return .h5 + case .h6: + return .h6 + default: + return .default + } + } + var isHeaderStyle: Bool { switch self { case .h1, .h2, .h3, .h4, .h5, .h6: @@ -242,36 +261,36 @@ public enum TextSpanStyle: Equatable, CaseIterable, Hashable { func getFontWithUpdating(font: FontRepresentable) -> FontRepresentable { switch self { - case .default: + case .default: + return font + case .bold,.italic: + return font.addFontStyle(self) + case .underline, .bullet, .strikethrough, .color, .background: + return font + case .h1: + return font.updateFontSize(multiple: 1.5) + case .h2: + return font.updateFontSize(multiple: 1.4) + case .h3: + return font.updateFontSize(multiple: 1.3) + case .h4: + return font.updateFontSize(multiple: 1.2) + case .h5: + return font.updateFontSize(multiple: 1.1) + case .h6: + return font.updateFontSize(multiple: 1) + case .size(let size): + if let size { + return font.updateFontSize(size: CGFloat(size)) + } else { return font - case .bold,.italic: - return font.addFontStyle(self) - case .underline, .bullet, .strikethrough, .color, .background: + } + case .font(let name): + if let name { + return FontRepresentable(name: name, size: font.pointSize) ?? font + } else { return font - case .h1: - return font.updateFontSize(multiple: 1.5) - case .h2: - return font.updateFontSize(multiple: 1.4) - case .h3: - return font.updateFontSize(multiple: 1.3) - case .h4: - return font.updateFontSize(multiple: 1.2) - case .h5: - return font.updateFontSize(multiple: 1.1) - case .h6: - return font.updateFontSize(multiple: 1) - case .size(let size): - if let size { - return font.updateFontSize(size: CGFloat(size)) - } else { - return font - } - case .font(let name): - if let name { - return FontRepresentable(name: name, size: font.pointSize) ?? font - } else { - return font - } + } } } @@ -318,9 +337,9 @@ public extension RichTextStyle { /// The symbolic font traits for the style, if any. var symbolicTraits: UIFontDescriptor.SymbolicTraits? { switch self { - case .bold: .traitBold - case .italic: .traitItalic - default: nil + case .bold: .traitBold + case .italic: .traitItalic + default: nil } } } @@ -332,9 +351,9 @@ public extension RichTextStyle { /// The symbolic font traits for the trait, if any. var symbolicTraits: NSFontDescriptor.SymbolicTraits? { switch self { - case .bold: .bold - case .italic: .italic - default: nil + case .bold: .bold + case .italic: .italic + default: nil } } } @@ -344,37 +363,37 @@ public extension RichTextStyle { extension TextSpanStyle { func getRichAttribute() -> RichAttributes? { switch self { - case .default: - return nil - case .bold: - return RichAttributes(bold: true) - case .italic: - return RichAttributes(italic: true) - case .underline: - return RichAttributes(underline: true) - case .strikethrough: - return RichAttributes(strike: true) - case .bullet: - return RichAttributes(list: .bullet()) - case .h1: - return RichAttributes(header: .h1) - case .h2: - return RichAttributes(header: .h2) - case .h3: - return RichAttributes(header: .h3) - case .h4: - return RichAttributes(header: .h4) - case .h5: - return RichAttributes(header: .h5) - case .h6: - return RichAttributes(header: .h6) - case .size(let size): - return RichAttributes(size: size) - case .font(let font): - return RichAttributes(font: font) - case .color(let color): + case .default: + return nil + case .bold: + return RichAttributes(bold: true) + case .italic: + return RichAttributes(italic: true) + case .underline: + return RichAttributes(underline: true) + case .strikethrough: + return RichAttributes(strike: true) + case .bullet: + return RichAttributes(list: .bullet()) + case .h1: + return RichAttributes(header: .h1) + case .h2: + return RichAttributes(header: .h2) + case .h3: + return RichAttributes(header: .h3) + case .h4: + return RichAttributes(header: .h4) + case .h5: + return RichAttributes(header: .h5) + case .h6: + return RichAttributes(header: .h6) + case .size(let size): + return RichAttributes(size: size) + case .font(let font): + return RichAttributes(font: font) + case .color(let color): return RichAttributes(color: color?.hexString) - case .background(let background): + case .background(let background): return RichAttributes(background: background?.hexString) } } diff --git a/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift index b40aa79..b629b1a 100644 --- a/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift +++ b/Sources/RichEditorSwiftUI/UI/EditorToolBar/EditorToolBarView.swift @@ -18,23 +18,29 @@ public struct EditorToolBarView: View { colorScheme == .dark ? .white.opacity(0.3) : .gray.opacity(0.1) } + var background: some View { + Color.clear + .overlay(Color.primary.opacity(0.1)) + .shadow(color: .black.opacity(0.1), radius: 5) + .edgesIgnoringSafeArea(.all) + } + public init(state: RichEditorState) { self.state = state } public var body: some View { - LazyHStack(spacing: 5, content: { + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 5, content: { Section { ForEach(EditorTextStyleTool.allCases, id: \.self) { tool in - Group { - if tool.isContainManu { - TitleStyleButton(tool: tool, appliedTools: state.activeStyles, setStyle: state.updateStyle(style:)) - RTEVDivider() - } else { - // if tool != .list() { - ToggleStyleButton(tool: tool, appliedTools: state.activeStyles, onToolSelect: state.toggleStyle(style:)) - // } - } + if tool.isContainManu { + TitleStyleButton(tool: tool, appliedTools: state.activeStyles, setStyle: state.updateStyle(style:)) + RTEVDivider() + } else { + // if tool != .list() { + ToggleStyleButton(tool: tool, appliedTools: state.activeStyles, onToolSelect: state.toggleStyle(style:)) + // } } } } @@ -43,6 +49,7 @@ public struct EditorToolBarView: View { Section { RichTextFont.SizePickerStack(context: state) + } }) #if os(iOS) @@ -50,10 +57,14 @@ public struct EditorToolBarView: View { #else .frame(height: 40) #endif + + RichTextFormat.Toolbar(context: state) + } .padding(.horizontal) .frame(maxWidth: .infinity, alignment: .leading) - .background(selectedColor) - .cornerRadius(6) + .background(background) + .cornerRadius(8) + } } @@ -80,7 +91,7 @@ private struct ToggleStyleButton: View { Button(action: { onToolSelect(tool.getTextSpanStyle()) }, label: { - HStack(alignment: .center, spacing: 4, content: { + HStack(alignment: .center, spacing: 0, content: { Image(systemName: tool.systemImageName) #if os(iOS) .frame(width: 25, height: 25) @@ -89,8 +100,8 @@ private struct ToggleStyleButton: View { #endif }) .foregroundColor(isSelected ? .blue : normalDarkColor) -#if os(iOS) - .frame(width: 40, height: 40, alignment: .center) + #if os(iOS) + .frame(width: 30, height: 30, alignment: .center) #endif .background(isSelected ? selectedColor : Color.clear) .cornerRadius(5) @@ -118,12 +129,9 @@ struct TitleStyleButton: View { var body: some View { Picker("", selection: $selection) { ForEach(HeaderType.allCases, id: \.self) { header in - if hasStyle(header.getTextSpanStyle()) { - Label(header.title, systemImage:"checkmark") - .foregroundColor(normalDarkColor) - } else { +// if hasStyle(header.getTextSpanStyle()) { Text(header.title) - } +// } } } .onChangeBackPort(of: selection) { newValue in @@ -140,9 +148,8 @@ struct TitleStyleButton: View { struct RTEVDivider: View { var body: some View { Rectangle() - .frame(width: 1) + .frame(width: 1, height: 20) .foregroundColor(.secondary) - .padding(.vertical) } } #endif diff --git a/Sources/RichEditorSwiftUI/UI/Extensions/Color+Extension.swift b/Sources/RichEditorSwiftUI/UI/Extensions/Color+Extension.swift index a6dc79b..64286e1 100644 --- a/Sources/RichEditorSwiftUI/UI/Extensions/Color+Extension.swift +++ b/Sources/RichEditorSwiftUI/UI/Extensions/Color+Extension.swift @@ -93,3 +93,31 @@ public extension Color { } } +extension ColorRepresentable { + func toHex(alpha: Bool = false) -> String? { + guard let components = cgColor.components, components.count >= 3 else { + return nil + } + + let r = Float(components[0]) + let g = Float(components[1]) + let b = Float(components[2]) + var a = Float(1.0) + + if components.count >= 4 { + a = Float(components[3]) + } + + if alpha { + return String(format: "%02lX%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255), lroundf(a * 255)) + } else { + return String(format: "%02lX%02lX%02lX", lroundf(r * 255), lroundf(g * 255), lroundf(b * 255)) + } + } +} + +extension Color { + public func toHex(alpha: Bool = false) -> String? { + ColorRepresentable(self).toHex(alpha: alpha) + } +} diff --git a/Sources/RichEditorSwiftUI/UI/Views/ForEachPicker.swift b/Sources/RichEditorSwiftUI/UI/Views/ForEachPicker.swift new file mode 100644 index 0000000..fb7148b --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Views/ForEachPicker.swift @@ -0,0 +1,87 @@ +// +// ForEachPicker.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +import SwiftUI + +/** + This is an internal version of the original that is defined + and available in https://github.com/danielsaidi/swiftuikit. + This will not be made public or documented for this library. + */ +struct ForEachPicker: View { + + init( + items: [Item], + selection: Binding, + animatedSelection: Bool = false, + dismissAfterPick: Bool = false, + listItem: @escaping ItemViewBuilder + ) { + self.items = items + self.selection = selection + self.animatedSelection = animatedSelection + self.dismissAfterPick = dismissAfterPick + self.listItem = listItem + } + + private let items: [Item] + private let selection: Binding + private let animatedSelection: Bool + private let dismissAfterPick: Bool + private let listItem: ItemViewBuilder + + typealias ItemViewBuilder = (_ item: Item, _ isSelected: Bool) -> ItemView + + @Environment(\.dismiss) + var dismiss + + var body: some View { + ForEach(items) { item in + Button { + select(item) + } label: { + listItem(item, isSelected(item)) + } + .buttonStyle(.plain) + } + } +} + +private extension ForEachPicker { + + var selectedId: Item.ID { + selection.wrappedValue.id + } +} + +private extension ForEachPicker { + + func isSelected(_ item: Item) -> Bool { + selectedId == item.id + } + + func select(_ item: Item) { + if animatedSelection { + selectWithAnimation(item) + } else { + selectWithoutAnimation(item) + } + } + + func selectWithAnimation(_ item: Item) { + withAnimation { + selectWithoutAnimation(item) + } + } + + func selectWithoutAnimation(_ item: Item) { + selection.wrappedValue = item + if dismissAfterPick { + dismiss() + } + } +} diff --git a/Sources/RichEditorSwiftUI/UI/Views/ListPicker.swift b/Sources/RichEditorSwiftUI/UI/Views/ListPicker.swift new file mode 100644 index 0000000..bcc3135 --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Views/ListPicker.swift @@ -0,0 +1,69 @@ +// +// ListPicker.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +import SwiftUI + +/** + This is an internal version of the original that is defined + and available in https://github.com/danielsaidi/swiftuikit. + This will not be made public or documented for this library. + */ +struct ListPicker: View { + + init( + items: [Item], + selection: Binding, + animatedSelection: Bool = false, + dismissAfterPick: Bool = true, + listItem: @escaping ItemViewBuilder + ) { + self.init( + sections: [ListPickerSection(title: "", items: items)], + selection: selection, + animatedSelection: animatedSelection, + dismissAfterPick: dismissAfterPick, + listItem: listItem) + } + + init( + sections: [ListPickerSection], + selection: Binding, + animatedSelection: Bool = false, + dismissAfterPick: Bool = true, + listItem: @escaping ItemViewBuilder + ) { + self.sections = sections + self.selection = selection + self.animatedSelection = animatedSelection + self.dismissAfterPick = dismissAfterPick + self.listItem = listItem + } + + private let sections: [ListPickerSection] + private let selection: Binding + private let animatedSelection: Bool + private let dismissAfterPick: Bool + private let listItem: ItemViewBuilder + + typealias ItemViewBuilder = (_ item: Item, _ isSelected: Bool) -> ItemView + + var body: some View { + List { + ForEach(sections) { section in + Section(header: section.header) { + ForEachPicker( + items: section.items, + selection: selection, + animatedSelection: animatedSelection, + dismissAfterPick: dismissAfterPick, + listItem: listItem + ) + } + } + } + } +} diff --git a/Sources/RichEditorSwiftUI/UI/Views/ListPickerItem.swift b/Sources/RichEditorSwiftUI/UI/Views/ListPickerItem.swift new file mode 100644 index 0000000..e38267d --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Views/ListPickerItem.swift @@ -0,0 +1,29 @@ +// +// ListPickerItem.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +import SwiftUI + +/** + This is an internal version of the original that is defined + and available in https://github.com/danielsaidi/swiftuikit. + This will not be made public or documented for this library. + */ +protocol ListPickerItem: View { + + associatedtype Item: Equatable + + var item: Item { get } + var isSelected: Bool { get } +} + +extension ListPickerItem { + + var checkmark: some View { + Image(systemName: "checkmark") + .opacity(isSelected ? 1 : 0) + } +} diff --git a/Sources/RichEditorSwiftUI/UI/Views/ListPickerSection.swift b/Sources/RichEditorSwiftUI/UI/Views/ListPickerSection.swift new file mode 100644 index 0000000..fb79ce3 --- /dev/null +++ b/Sources/RichEditorSwiftUI/UI/Views/ListPickerSection.swift @@ -0,0 +1,35 @@ +// +// ListPickerSection.swift +// RichEditorSwiftUI +// +// Created by Divyesh Vekariya on 18/11/24. +// + +import SwiftUI + +/** + This is an internal version of the original that is defined + and available in https://github.com/danielsaidi/swiftuikit. + This will not be made public or documented for this library. + */ +struct ListPickerSection: Identifiable { + + init(title: String, items: [Item]) { + self.id = UUID() + self.title = title + self.items = items + } + + let id: UUID + let title: String + let items: [Item] + + @ViewBuilder + var header: some View { + if title.trimmingCharacters(in: .whitespaces).isEmpty { + EmptyView() + } else { + Text(title) + } + } +}