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
4 changes: 2 additions & 2 deletions Packages/Models/Sources/Models/Alias/HTMLString.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public struct HTMLString: Codable, Equatable, Hashable, @unchecked Sendable {

asMarkdown = ""
do {
let document: Document = try SwiftSoup.parse(htmlValue)
let document: SwiftSoup.Document = try SwiftSoup.parse(htmlValue)
var listCounters: [Int] = []
handleNode(node: document, listCounters: &listCounters)

Expand Down Expand Up @@ -136,7 +136,7 @@ public struct HTMLString: Codable, Equatable, Hashable, @unchecked Sendable {
try container.encode(hadTrailingTags, forKey: .hadTrailingTags)
}

private mutating func removeTrailingTags(doc: Document) {
private mutating func removeTrailingTags(doc: SwiftSoup.Document) {
// Fast bail-outs
if !asMarkdown.contains("#") { return }

Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,25 @@
import SwiftUI

extension TextView {
struct Representable: UIViewRepresentable {
struct Representable: UIViewControllerRepresentable {
@Binding var text: NSMutableAttributedString
@Binding var calculatedHeight: CGFloat
@Environment(\.sizeCategory) var sizeCategory

let keyboard: UIKeyboardType
var getTextView: ((UITextView) -> Void)?

func makeUIView(context: Context) -> UIKitTextView {
context.coordinator.textView
func makeUIViewController(context: Context) -> TextViewController {
TextViewController(textView: context.coordinator.textView)
}

func updateUIView(_: UIKitTextView, context: Context) {
func updateUIViewController(_: TextViewController, context: Context) {
context.coordinator.update(representable: self)
if !context.coordinator.didBecomeFirstResponder {
context.coordinator.textView.becomeFirstResponder()
context.coordinator.didBecomeFirstResponder = true
}
// Deliberately no automatic becomeFirstResponder here: on iOS 26/27 a
// focused UITextView in this sheet deadlocks SwiftUI's AttributeGraph
// when the device rotates ("cycle detected" spam until the system kills
// the app). Tap the field to start typing; TextViewController drops focus
// in viewWillTransition, *before* the rotation begins.
}

@discardableResult func makeCoordinator() -> Coordinator {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,62 @@ public struct TextView: View {
}
}

/// Hosts the editor's `UIKitTextView` so the editor gets a `viewWillTransition`
/// hook on the owning view controller.
///
/// iOS 26/27: a focused `UITextView` inside the compose sheet deadlocks
/// SwiftUI's AttributeGraph when the interface rotates ("cycle detected" spam
/// until the watchdog kills the app). `viewWillTransition(to:with:)` fires
/// *before* the rotation animation begins, so resigning first responder here
/// dismisses the keyboard in time to prevent the cycle from ever forming — the
/// device-orientation observer in `UIKitTextView` only lands afterwards and so
/// can't. It stays as a post-rotation backstop.
final class TextViewController: UIViewController {
private let textView: UIKitTextView

init(textView: UIKitTextView) {
self.textView = textView
super.init(nibName: nil, bundle: nil)
}

@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func loadView() {
// The text view *is* the controller's view, so SwiftUI sizes it exactly as
// it did when the representable vended the UITextView directly.
view = textView
}

override func viewWillTransition(
to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator
) {
if textView.isFirstResponder {
textView.resignFirstResponder()
}
super.viewWillTransition(to: size, with: coordinator)
}
}

final class UIKitTextView: UITextView {
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
// iOS 26/27 backstop: dropping focus before rotation happens in
// TextViewController.viewWillTransition. This post-rotation observer only
// helps settle layout if a transition was left half-finished.
UIDevice.current.beginGeneratingDeviceOrientationNotifications()
NotificationCenter.default.addObserver(
self, selector: #selector(deviceOrientationDidChange),
name: UIDevice.orientationDidChangeNotification, object: nil)
}

@available(*, unavailable)
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override var keyCommands: [UIKeyCommand]? {
(super.keyCommands ?? []) + [
UIKeyCommand(
Expand All @@ -71,4 +126,15 @@ final class UIKitTextView: UITextView {
@objc private func escape(_: Any) {
resignFirstResponder()
}

@objc private func deviceOrientationDidChange() {
// This lands only after the interface has already switched orientation, so
// it can't prevent the iOS 26 rotation deadlock — but if we were focused
// through a rotation and the transition was left half-finished, dropping
// focus here lets UIKit settle the layout and resume touch delivery.
guard isFirstResponder,
UIDevice.current.orientation.isValidInterfaceOrientation
else { return }
resignFirstResponder()
}
}