Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,26 @@ 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

var body: some 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",
Expand All @@ -38,6 +44,7 @@ struct PlaygroundScreen: View {

setMarkdownButton
preview
editor
}
.padding(16)
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T>(_ 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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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)?
}
Loading
Loading