diff --git a/apps/ios-example/EnrichedMarkdownExample/EnrichedMarkdownExample/Views/Playground/PlaygroundScreen.swift b/apps/ios-example/EnrichedMarkdownExample/EnrichedMarkdownExample/Views/Playground/PlaygroundScreen.swift index bda328cb..120baa85 100644 --- a/apps/ios-example/EnrichedMarkdownExample/EnrichedMarkdownExample/Views/Playground/PlaygroundScreen.swift +++ b/apps/ios-example/EnrichedMarkdownExample/EnrichedMarkdownExample/Views/Playground/PlaygroundScreen.swift @@ -4,12 +4,16 @@ import SwiftUI struct PlaygroundScreen: View { // MARK: - Properties + @StateObject private var controller = MarkdownEditorController() + @State private var markdown: String = "" @State private var underlineEnabled: Bool = true @State private var setMarkdownSheetVisible: Bool = false @State private var rawInput: String = "" @State private var blockImageURI: String? @State private var inlineImageURI: String? + @State private var markdownAlertVisible: Bool = false + @State private var markdownAlertText: String = "" // MARK: - Views @@ -17,7 +21,9 @@ struct PlaygroundScreen: View { ScrollView { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 8) { - PlaygroundButton(label: "Blur", accessibilityId: "blur-button") {} + PlaygroundButton(label: "Blur", accessibilityId: "blur-button") { + controller.blur() + } PlaygroundButton( label: "Underline", accessibilityId: "underline-button", @@ -38,6 +44,7 @@ struct PlaygroundScreen: View { setMarkdownButton preview + editor } .padding(16) } @@ -51,10 +58,16 @@ struct PlaygroundScreen: View { onCancel: { setMarkdownSheetVisible = false }, onConfirm: { markdown = rawInput + controller.setMarkdown(rawInput) setMarkdownSheetVisible = false } ) } + .alert("Markdown", isPresented: $markdownAlertVisible) { + Button("OK", role: .cancel) {} + } message: { + Text(markdownAlertText) + } } private var setMarkdownButton: some View { @@ -96,6 +109,39 @@ struct PlaygroundScreen: View { } } + private var editor: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Editor") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Color.gray400) + + EnrichedMarkdownTextInput(controller: controller, placeholder: "Type markdown here…") + .onMarkdownChange { markdown = $0 } + .frame(height: 160) + .padding(14) + .background(Color.white, in: RoundedRectangle(cornerRadius: 10)) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(Color.gray300, lineWidth: 1) + ) + .accessibilityIdentifier("editor-container") + + HStack(spacing: 8) { + PlaygroundButton(label: "Focus", accessibilityId: "focus-button") { + controller.focus() + } + PlaygroundButton(label: "Clear", accessibilityId: "clear-button") { + controller.clear() + markdown = "" + } + PlaygroundButton(label: "Get Markdown", accessibilityId: "get-markdown-button") { + markdownAlertText = controller.getMarkdown() + markdownAlertVisible = true + } + } + } + } + // MARK: - Methods private func loadBundledImages() { diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/Engine/EditSession.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/Engine/EditSession.swift new file mode 100644 index 00000000..87c31229 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/Engine/EditSession.swift @@ -0,0 +1,78 @@ +import QuartzCore +import UIKit + +/// Tracks what the editor is currently doing so delegate callbacks can tell a +/// change the user made from one the editor made to itself. +/// +/// Text mutations performed while applying formatting or importing markdown +/// trigger the very same `UITextViewDelegate` callbacks as typing does. Without +/// a phase to check, those callbacks re-enter the code that caused them. +@available(iOS 16.0, *) +@MainActor +final class EditSession { + enum Phase { + case idle + case processing + case formatting + case importing + } + + private(set) var phase: Phase = .idle + + private weak var textView: UITextView? + private let gracePeriod: CFTimeInterval + private let now: () -> CFTimeInterval + private var lastTextChangeTime: CFTimeInterval? + + init( + gracePeriod: CFTimeInterval = 0.1, + now: @escaping () -> CFTimeInterval = CACurrentMediaTime + ) { + self.gracePeriod = gracePeriod + self.now = now + } + + func attach(to textView: UITextView?) { + self.textView = textView + } + + /// Runs `body` in `phase`, then restores the phase that was active before. + /// Restoring rather than resetting to `.idle` keeps an outer phase intact + /// when another nests inside it, such as a reformat during an import. + @discardableResult + func withPhase(_ phase: Phase, _ body: () throws -> T) rethrows -> T { + let previous = self.phase + self.phase = phase + defer { self.phase = previous } + return try body() + } + + func recordTextChange() { + lastTextChangeTime = now() + } + + /// True while an input method or dictation has uncommitted marked text. + var isComposing: Bool { + textView?.markedTextRange != nil + } + + /// True just after an edit, while selection callbacks are still catching up. + var isPostEditGracePeriod: Bool { + guard let lastTextChangeTime else { return false } + return now() - lastTextChangeTime < gracePeriod + } + + /// Reformatting during composition would discard the marked-text run and + /// break the input method mid-word. + var shouldSuppressFormatting: Bool { + phase == .formatting || phase == .importing || isComposing + } + + var shouldSuppressEvents: Bool { + phase == .importing + } + + var shouldSuppressSelectionSideEffects: Bool { + phase != .idle + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/MarkdownEditorController.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/MarkdownEditorController.swift new file mode 100644 index 00000000..ccb95db0 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/MarkdownEditorController.swift @@ -0,0 +1,73 @@ +import SwiftUI +import UIKit + +/// Drives an ``EnrichedMarkdownTextInput``: hold one in your view, pass it to the +/// input, and call its commands to focus, clear, or replace the editor's content. +/// +/// Observe it for editor state — `isFocused` and `selectedRange` publish changes +/// as the user moves through the text. +@available(iOS 16.0, *) +@MainActor +public final class MarkdownEditorController: ObservableObject { + @Published public private(set) var isFocused: Bool = false + @Published public private(set) var selectedRange: NSRange = NSRange(location: 0, length: 0) + + let session: EditSession = EditSession() + + private weak var textView: MarkdownInputTextView? + + public init() {} + + // MARK: - Commands + + public func focus() { + textView?.becomeFirstResponder() + } + + public func blur() { + textView?.resignFirstResponder() + } + + public func clear() { + setMarkdown("") + } + + /// Replaces the editor's content. Like RN's `setValue`, this does not report + /// an `onMarkdownChange` — the caller already knows what it set. + public func setMarkdown(_ markdown: String) { + guard let textView else { return } + + session.withPhase(.importing) { + textView.text = markdown + textView.updatePlaceholderVisibility() + } + updateSelection(textView.selectedRange) + } + + public func getMarkdown() -> String { + textView?.text ?? "" + } + + // MARK: - Text view binding + + func attach(to textView: MarkdownInputTextView) { + self.textView = textView + session.attach(to: textView) + } + + func detach() { + textView = nil + session.attach(to: nil) + isFocused = false + } + + func updateFocus(_ focused: Bool) { + guard isFocused != focused else { return } + isFocused = focused + } + + func updateSelection(_ range: NSRange) { + guard selectedRange != range else { return } + selectedRange = range + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/EnrichedMarkdownTextInput.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/EnrichedMarkdownTextInput.swift new file mode 100644 index 00000000..bcff0468 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/EnrichedMarkdownTextInput.swift @@ -0,0 +1,71 @@ +import SwiftUI +import UIKit + +/// An editable markdown field, styled by the same ``MarkdownTheme`` as +/// ``EnrichedMarkdownText``. +/// +/// Commands and editor state live on the ``MarkdownEditorController`` you pass in; +/// changes are reported through the `on…` modifiers. +/// +/// ```swift +/// EnrichedMarkdownTextInput(controller: controller, placeholder: "Write something…") +/// .onMarkdownChange { markdown = $0 } +/// ``` +@available(iOS 16.0, *) +public struct EnrichedMarkdownTextInput: View { + private let controller: MarkdownEditorController + private let placeholder: String? + private var events: MarkdownInputEvents = MarkdownInputEvents() + + @Environment(\.markdownThemeLayers) private var themeLayers + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + public init(controller: MarkdownEditorController, placeholder: String? = nil) { + self.controller = controller + self.placeholder = placeholder + } + + private var styleConfig: MarkdownStyleConfig { + MarkdownStyleConfig.resolve( + layers: themeLayers, + colorScheme: colorScheme, + dynamicTypeSize: dynamicTypeSize + ) + } + + public var body: some View { + MarkdownInputTextViewRepresentable( + controller: controller, + placeholder: placeholder, + styleConfig: styleConfig, + events: events + ) + } + + // MARK: - Events + + public func onTextChange(_ action: @escaping (String) -> Void) -> Self { + var copy = self + copy.events.onTextChange = action + return copy + } + + public func onMarkdownChange(_ action: @escaping (String) -> Void) -> Self { + var copy = self + copy.events.onMarkdownChange = action + return copy + } + + public func onSelectionChange(_ action: @escaping (NSRange) -> Void) -> Self { + var copy = self + copy.events.onSelectionChange = action + return copy + } + + public func onFocusChange(_ action: @escaping (Bool) -> Void) -> Self { + var copy = self + copy.events.onFocusChange = action + return copy + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/MarkdownInputEvents.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/MarkdownInputEvents.swift new file mode 100644 index 00000000..eee6d88b --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/MarkdownInputEvents.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Callbacks an `EnrichedMarkdownTextInput` reports to its host. +@available(iOS 16.0, *) +struct MarkdownInputEvents { + var onTextChange: ((String) -> Void)? + var onMarkdownChange: ((String) -> Void)? + var onSelectionChange: ((NSRange) -> Void)? + var onFocusChange: ((Bool) -> Void)? +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/MarkdownInputTextView.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/MarkdownInputTextView.swift new file mode 100644 index 00000000..0c465487 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/MarkdownInputTextView.swift @@ -0,0 +1,84 @@ +import UIKit + +@available(iOS 16.0, *) +final class MarkdownInputTextView: UITextView { + var placeholder: String? { + didSet { + placeholderLabel.text = placeholder + updatePlaceholderVisibility() + setNeedsLayout() + } + } + + private let placeholderLabel: UILabel = UILabel() + + init() { + super.init(frame: .zero, textContainer: nil) + configure() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func configure() { + isEditable = true + isSelectable = true + isScrollEnabled = true + backgroundColor = .clear + textContainerInset = .zero + textContainer.lineFragmentPadding = 0 + dataDetectorTypes = [] + + placeholderLabel.numberOfLines = 0 + placeholderLabel.textColor = .placeholderText + placeholderLabel.isUserInteractionEnabled = false + addSubview(placeholderLabel) + } + + /// Applies the resolved theme's paragraph style as the editor's base style — + /// the appearance of text carrying no inline formatting. + /// + /// Assigning `font` or `textColor` re-stamps that attribute over the whole + /// text storage, so only a genuine change may go through: this runs on every + /// SwiftUI update pass, including one per keystroke. + func applyBaseStyle(_ config: MarkdownStyleConfig) { + let baseFont = config.paragraph.font ?? UIFont.preferredFont(forTextStyle: .body) + let baseColor = config.paragraph.foregroundColor ?? .label + + if placeholderLabel.font != baseFont { + placeholderLabel.font = baseFont + setNeedsLayout() + } + + guard font != baseFont || textColor != baseColor else { return } + font = baseFont + textColor = baseColor + } + + func updatePlaceholderVisibility() { + placeholderLabel.isHidden = placeholder == nil || !text.isEmpty + } + + override func layoutSubviews() { + super.layoutSubviews() + layoutPlaceholder() + } + + private func layoutPlaceholder() { + let padding = textContainer.lineFragmentPadding + let width = bounds.width - textContainerInset.left - textContainerInset.right - padding * 2 + guard width > 0 else { return } + + let fittingSize = placeholderLabel.sizeThatFits( + CGSize(width: width, height: .greatestFiniteMagnitude) + ) + placeholderLabel.frame = CGRect( + x: textContainerInset.left + padding, + y: textContainerInset.top, + width: width, + height: fittingSize.height + ) + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/MarkdownInputTextViewRepresentable.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/MarkdownInputTextViewRepresentable.swift new file mode 100644 index 00000000..37dd5e8b --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Input/View/MarkdownInputTextViewRepresentable.swift @@ -0,0 +1,70 @@ +import SwiftUI +import UIKit + +@available(iOS 16.0, *) +struct MarkdownInputTextViewRepresentable: UIViewRepresentable { + let controller: MarkdownEditorController + let placeholder: String? + let styleConfig: MarkdownStyleConfig + let events: MarkdownInputEvents + + func makeCoordinator() -> Coordinator { + Coordinator(controller: controller, events: events) + } + + func makeUIView(context: Context) -> MarkdownInputTextView { + let textView = MarkdownInputTextView() + textView.delegate = context.coordinator + textView.placeholder = placeholder + textView.applyBaseStyle(styleConfig) + controller.attach(to: textView) + return textView + } + + func updateUIView(_ textView: MarkdownInputTextView, context: Context) { + context.coordinator.events = events + textView.placeholder = placeholder + textView.applyBaseStyle(styleConfig) + } + + static func dismantleUIView(_ textView: MarkdownInputTextView, coordinator: Coordinator) { + textView.delegate = nil + coordinator.controller.detach() + } + + @MainActor + final class Coordinator: NSObject, UITextViewDelegate { + let controller: MarkdownEditorController + var events: MarkdownInputEvents + + init(controller: MarkdownEditorController, events: MarkdownInputEvents) { + self.controller = controller + self.events = events + } + + func textViewDidChange(_ textView: UITextView) { + (textView as? MarkdownInputTextView)?.updatePlaceholderVisibility() + controller.session.recordTextChange() + + guard !controller.session.shouldSuppressEvents else { return } + events.onTextChange?(textView.text) + events.onMarkdownChange?(controller.getMarkdown()) + } + + func textViewDidChangeSelection(_ textView: UITextView) { + guard !controller.session.shouldSuppressSelectionSideEffects else { return } + controller.updateSelection(textView.selectedRange) + events.onSelectionChange?(textView.selectedRange) + } + + func textViewDidBeginEditing(_ textView: UITextView) { + controller.updateFocus(true) + events.onFocusChange?(true) + } + + func textViewDidEndEditing(_ textView: UITextView) { + controller.updateFocus(false) + events.onFocusChange?(false) + } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig+Resolve.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig+Resolve.swift index ba87b3d1..a4f24b89 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig+Resolve.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig+Resolve.swift @@ -1,6 +1,19 @@ +import SwiftUI import UIKit extension MarkdownStyleConfig { + static func resolve( + layers: [MarkdownTheme], + colorScheme: ColorScheme, + dynamicTypeSize: DynamicTypeSize + ) -> MarkdownStyleConfig { + let traitCollection = ThemeResolver.traitCollection( + colorScheme: colorScheme, + dynamicTypeSize: dynamicTypeSize + ) + return resolve(layers: layers, traitCollection: traitCollection) + } + public static func resolve( layers: [MarkdownTheme], traitCollection: UITraitCollection diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift index 9354dab5..4d928bc7 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift @@ -17,11 +17,11 @@ public struct EnrichedMarkdownText: View { } private var styleConfig: MarkdownStyleConfig { - let traitCollection = ThemeResolver.traitCollection( + MarkdownStyleConfig.resolve( + layers: themeLayers, colorScheme: colorScheme, dynamicTypeSize: dynamicTypeSize ) - return MarkdownStyleConfig.resolve(layers: themeLayers, traitCollection: traitCollection) } public var body: some View { diff --git a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/Input/EditSessionTests.swift b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/Input/EditSessionTests.swift new file mode 100644 index 00000000..50b382c4 --- /dev/null +++ b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/Input/EditSessionTests.swift @@ -0,0 +1,126 @@ +import QuartzCore +import UIKit +import XCTest +@testable import EnrichedMarkdown + +@available(iOS 16.0, *) +@MainActor +final class EditSessionTests: XCTestCase { + private var clock: CFTimeInterval = 0 + + private func makeSession(gracePeriod: CFTimeInterval = 0.1) -> EditSession { + EditSession(gracePeriod: gracePeriod, now: { [unowned self] in self.clock }) + } + + // MARK: - Phases + + func testStartsIdle() { + XCTAssertEqual(makeSession().phase, .idle) + } + + func testWithPhaseAppliesPhaseForDurationOfBody() { + let session = makeSession() + + session.withPhase(.formatting) { + XCTAssertEqual(session.phase, .formatting) + } + + XCTAssertEqual(session.phase, .idle) + } + + func testNestedPhaseRestoresOuterPhase() { + let session = makeSession() + + session.withPhase(.importing) { + session.withPhase(.formatting) { + XCTAssertEqual(session.phase, .formatting) + } + XCTAssertEqual(session.phase, .importing, "inner phase must not reset the outer one to idle") + } + + XCTAssertEqual(session.phase, .idle) + } + + func testWithPhaseRestoresPhaseWhenBodyThrows() { + let session = makeSession() + struct Failure: Error {} + + XCTAssertThrowsError( + try session.withPhase(.importing) { throw Failure() } + ) + XCTAssertEqual(session.phase, .idle) + } + + func testWithPhaseReturnsBodyResult() { + XCTAssertEqual(makeSession().withPhase(.processing) { 42 }, 42) + } + + // MARK: - Suppression queries + + func testSuppressesEventsOnlyWhileImporting() { + let session = makeSession() + XCTAssertFalse(session.shouldSuppressEvents) + + session.withPhase(.importing) { XCTAssertTrue(session.shouldSuppressEvents) } + session.withPhase(.formatting) { XCTAssertFalse(session.shouldSuppressEvents) } + session.withPhase(.processing) { XCTAssertFalse(session.shouldSuppressEvents) } + } + + func testSuppressesFormattingWhileFormattingOrImporting() { + let session = makeSession() + XCTAssertFalse(session.shouldSuppressFormatting) + + session.withPhase(.formatting) { XCTAssertTrue(session.shouldSuppressFormatting) } + session.withPhase(.importing) { XCTAssertTrue(session.shouldSuppressFormatting) } + session.withPhase(.processing) { XCTAssertFalse(session.shouldSuppressFormatting) } + } + + func testSuppressesSelectionSideEffectsWheneverNotIdle() { + let session = makeSession() + XCTAssertFalse(session.shouldSuppressSelectionSideEffects) + + for phase in [EditSession.Phase.processing, .formatting, .importing] { + session.withPhase(phase) { + XCTAssertTrue(session.shouldSuppressSelectionSideEffects, "\(phase) must suppress") + } + } + } + + // MARK: - Grace period + + func testGracePeriodInactiveBeforeAnyTextChange() { + XCTAssertFalse(makeSession().isPostEditGracePeriod) + } + + func testGracePeriodActiveImmediatelyAfterTextChange() { + let session = makeSession(gracePeriod: 0.1) + session.recordTextChange() + + XCTAssertTrue(session.isPostEditGracePeriod) + } + + func testGracePeriodExpiresAfterInterval() { + let session = makeSession(gracePeriod: 0.1) + session.recordTextChange() + + clock += 0.05 + XCTAssertTrue(session.isPostEditGracePeriod) + + clock += 0.06 + XCTAssertFalse(session.isPostEditGracePeriod) + } + + // MARK: - Composition + + func testNotComposingWithoutTextView() { + XCTAssertFalse(makeSession().isComposing) + } + + func testNotComposingWhenTextViewHasNoMarkedText() { + let session = makeSession() + session.attach(to: UITextView()) + + XCTAssertFalse(session.isComposing) + XCTAssertFalse(session.shouldSuppressFormatting) + } +}