From 534216acbb6e2d9118da6484a4f4ede5992fc471 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sat, 1 Aug 2026 20:00:37 +0100 Subject: [PATCH] Preserve inline formatting while typing --- .../Editor/NativeEditorBlockRow.swift | 32 ++---- .../Editor/NativeEditorBlockRowPolicy.swift | 4 +- .../Editor/NativeEditorBodyView.swift | 10 +- .../Editor/NativeEditorInlineMark.swift | 101 +++++++++++++++--- .../NativeEditorInlineTypingContext.swift | 6 ++ .../Editor/NativeEditorNSTextView.swift | 33 ++++++ ...veEditorPlatformTypingAttributes+iOS.swift | 65 +++++++++++ ...EditorPlatformTypingAttributes+macOS.swift | 67 ++++++++++++ ...iveEditorQuickFormattingToolbarGroup.swift | 15 +++ .../NativeEditorTextInputView+iOS.swift | 49 +++++++-- .../NativeEditorTextInputView+macOS.swift | 79 +++++++------- .../Editor/NativeEditorTextMutation.swift | 29 +++-- ...tiveRichEditorViewModel+BlockEditing.swift | 58 ++++++++-- .../Editor/NativeRichEditorViewModel.swift | 9 +- .../NativeEditorBlockRowPolicyTests.swift | 6 +- .../NativeEditorTextMutationTests.swift | 41 ++++++- .../NativeRichEditorInlineTypingTests.swift | 54 ++++++++++ .../NativeRichEditorViewModelTests.swift | 13 --- 18 files changed, 544 insertions(+), 127 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorInlineTypingContext.swift create mode 100644 docmostly/Features/Editor/NativeEditorNSTextView.swift create mode 100644 docmostly/Features/Editor/NativeEditorPlatformTypingAttributes+iOS.swift create mode 100644 docmostly/Features/Editor/NativeEditorPlatformTypingAttributes+macOS.swift create mode 100644 docmostlyTests/Editor/NativeRichEditorInlineTypingTests.swift diff --git a/docmostly/Features/Editor/NativeEditorBlockRow.swift b/docmostly/Features/Editor/NativeEditorBlockRow.swift index 73610812..66cf75dc 100644 --- a/docmostly/Features/Editor/NativeEditorBlockRow.swift +++ b/docmostly/Features/Editor/NativeEditorBlockRow.swift @@ -4,6 +4,7 @@ struct NativeEditorBlockRow: View { @Binding var block: NativeEditorBlock let isActive: Bool let focusRequestID: UUID? + let retainsResponderDuringFocusHandoff: Bool let isSelected: Bool let isShowingControls: Bool let isReadOnly: Bool @@ -20,8 +21,9 @@ struct NativeEditorBlockRow: View { let presenceProjection: NativeEditorRemotePresenceProjection let presenceScope: [NativeEditorRemotePresenceScope] let presenceBlockIndex: Int - let focusBlock: () -> Void let textInputFocusChanged: (Bool) -> Void + let typingInlineMarks: Set + let invalidateInlineTypingContext: () -> Void let moveBefore: (UUID) -> Void let splitBlock: (Range) -> Bool let insertHardBreak: (Range) -> Bool @@ -61,13 +63,17 @@ struct NativeEditorBlockRow: View { .frame(width: 24, alignment: .center) } - if showsEditableTextEditor { + if usesTextInputSurface { NativeEditorBlockTextSurface(kind: block.kind) { NativeEditorTextInputView( block: $block, + isEditable: isReadOnly == false, isFocused: isActive, focusRequestID: focusRequestID, + retainsResponderDuringFocusHandoff: retainsResponderDuringFocusHandoff, focusChanged: textInputFocusChanged, + typingInlineMarks: typingInlineMarks, + invalidateTypingContext: invalidateInlineTypingContext, accessibilityLabel: block.kind.accessibilityLabel, actions: NativeEditorTextInputActions( handleReturn: handleReturn, @@ -82,24 +88,6 @@ struct NativeEditorBlockRow: View { selectionChanged() } } - } else if block.isEditable && isReadOnly == false { - Button(action: focusBlock) { - NativeEditorBlockTextSurface(kind: block.kind) { - NativeEditorRichBlockPreviewView( - block: block, - pageID: pageID, - spaceID: spaceID, - serverURLString: serverURLString, - presenceProjection: presenceProjection, - presenceScope: presenceScope, - presenceBlockIndex: presenceBlockIndex - ) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - } - } - .buttonStyle(.plain) - .accessibilityLabel(block.kind.accessibilityLabel) } else { NativeEditorRichBlockPreviewView( block: block, @@ -196,8 +184,8 @@ struct NativeEditorBlockRow: View { } } - private var showsEditableTextEditor: Bool { - NativeEditorBlockRowPolicy.showsEditableTextEditor(block: block, isReadOnly: isReadOnly) + private var usesTextInputSurface: Bool { + NativeEditorBlockRowPolicy.usesTextInputSurface(block: block) } private var showsControls: Bool { diff --git a/docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift b/docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift index 7dfa7855..ce4fe2da 100644 --- a/docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift +++ b/docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift @@ -1,8 +1,8 @@ import Foundation nonisolated enum NativeEditorBlockRowPolicy { - static func showsEditableTextEditor(block: NativeEditorBlock, isReadOnly: Bool) -> Bool { - block.isEditable && isReadOnly == false + static func usesTextInputSurface(block: NativeEditorBlock) -> Bool { + block.isEditable } static func allowsTaskToggle(isReadOnly: Bool) -> Bool { diff --git a/docmostly/Features/Editor/NativeEditorBodyView.swift b/docmostly/Features/Editor/NativeEditorBodyView.swift index 66dd6f8e..5805f811 100644 --- a/docmostly/Features/Editor/NativeEditorBodyView.swift +++ b/docmostly/Features/Editor/NativeEditorBodyView.swift @@ -62,6 +62,8 @@ struct NativeEditorBodyView: View { focusRequestID: textInputFocusRequest?.blockID == block.id ? textInputFocusRequest?.id : nil, + retainsResponderDuringFocusHandoff: viewModel.activeBlockID != nil && + viewModel.activeBlockID != block.id, isSelected: viewModel.selectedBlockID == block.id, isShowingControls: viewModel.visibleBlockControlsID == block.id, isReadOnly: authoringIsAvailable == false, @@ -92,10 +94,6 @@ struct NativeEditorBodyView: View { presenceProjection: activePresenceProjection, presenceScope: presenceScope, presenceBlockIndex: blockIndex(for: block.id), - focusBlock: { - guard authoringIsAvailable else { return } - viewModel.focus(blockID: block.id) - }, textInputFocusChanged: { isFocused in if isFocused { guard authoringIsAvailable else { return } @@ -104,6 +102,10 @@ struct NativeEditorBodyView: View { viewModel.textInputDidEndEditing(blockID: block.id) } }, + typingInlineMarks: viewModel.typingInlineMarks(for: block.id), + invalidateInlineTypingContext: { + viewModel.invalidateInlineTypingContext(for: block.id) + }, moveBefore: { movedBlockID in guard authoringIsAvailable else { return } viewModel.moveBlock(movedBlockID, before: block.id) diff --git a/docmostly/Features/Editor/NativeEditorInlineMark.swift b/docmostly/Features/Editor/NativeEditorInlineMark.swift index 83c4ff1e..c23095d0 100644 --- a/docmostly/Features/Editor/NativeEditorInlineMark.swift +++ b/docmostly/Features/Editor/NativeEditorInlineMark.swift @@ -1,7 +1,7 @@ import Foundation import SwiftUI -enum NativeEditorInlineMark { +nonisolated enum NativeEditorInlineMark: CaseIterable, Hashable, Sendable { case bold case italic case underline @@ -11,49 +11,122 @@ enum NativeEditorInlineMark { case superscript func toggle(in attributes: inout AttributeContainer) { + set(isActive(in: attributes) == false, in: &attributes) + } + + func toggle(in text: inout AttributedString) { if case .underline = self { - attributes.underlineStyle = attributes.underlineStyle == nil ? .single : nil + text.underlineStyle = text.underlineStyle == nil ? .single : nil return } if let baselineOffset { - attributes.baselineOffset = attributes.baselineOffset == baselineOffset ? nil : baselineOffset + text.baselineOffset = text.baselineOffset == baselineOffset ? nil : baselineOffset return } guard let intent else { return } - var currentIntent = attributes.inlinePresentationIntent ?? [] - + var currentIntent = text.inlinePresentationIntent ?? [] if currentIntent.contains(intent) { currentIntent.remove(intent) } else { currentIntent.insert(intent) } + text.inlinePresentationIntent = currentIntent.isEmpty ? nil : currentIntent + } - attributes.inlinePresentationIntent = currentIntent.isEmpty ? nil : currentIntent + func isActive(in attributes: AttributeContainer) -> Bool { + if case .underline = self { + return attributes.underlineStyle != nil + } + + if let baselineOffset { + return attributes.baselineOffset == baselineOffset + } + + guard let intent else { return false } + return attributes.inlinePresentationIntent?.contains(intent) == true } - func toggle(in text: inout AttributedString) { + func set(_ isActive: Bool, in attributes: inout AttributeContainer) { if case .underline = self { - text.underlineStyle = text.underlineStyle == nil ? .single : nil + attributes.underlineStyle = isActive ? .single : nil return } if let baselineOffset { - text.baselineOffset = text.baselineOffset == baselineOffset ? nil : baselineOffset + attributes.baselineOffset = isActive ? baselineOffset : nil return } guard let intent else { return } - var currentIntent = text.inlinePresentationIntent ?? [] - - if currentIntent.contains(intent) { + var currentIntent = attributes.inlinePresentationIntent ?? [] + if isActive { + currentIntent.insert(intent) + } else { currentIntent.remove(intent) + } + attributes.inlinePresentationIntent = currentIntent.isEmpty ? nil : currentIntent + } + + static func setActiveMarks(_ marks: Set, in attributes: inout AttributeContainer) { + for mark in allCases where mark != .subscript && mark != .superscript { + mark.set(marks.contains(mark), in: &attributes) + } + if marks.contains(.subscript) { + attributes.baselineOffset = -4 + } else if marks.contains(.superscript) { + attributes.baselineOffset = 4 } else { - currentIntent.insert(intent) + attributes.baselineOffset = nil } + } - text.inlinePresentationIntent = currentIntent.isEmpty ? nil : currentIntent + static func activeMarks( + for selection: AttributedTextSelection, + in text: AttributedString + ) -> Set { + switch selection.indices(in: text) { + case .insertionPoint(let index): + return activeMarks(in: inheritedAttributes(at: index, in: text)) + case .ranges(let ranges): + guard ranges.isEmpty == false else { + return activeMarks(in: inheritedAttributes(at: text.endIndex, in: text)) + } + return Set(allCases.filter { mark in + ranges.ranges.allSatisfy { range in + text[range].runs.allSatisfy { mark.isActive(in: $0.attributes) } + } + }) + } + } + + private static func activeMarks(in attributes: AttributeContainer?) -> Set { + guard let attributes else { return [] } + return Set(allCases.filter { $0.isActive(in: attributes) }) + } + + private static func inheritedAttributes( + at insertionIndex: AttributedString.Index, + in text: AttributedString + ) -> AttributeContainer? { + if insertionIndex > text.startIndex { + let previousIndex = text.characters.index(before: insertionIndex) + if let attributes = text[previousIndex.. +} diff --git a/docmostly/Features/Editor/NativeEditorNSTextView.swift b/docmostly/Features/Editor/NativeEditorNSTextView.swift new file mode 100644 index 00000000..8d6e3949 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorNSTextView.swift @@ -0,0 +1,33 @@ +#if os(macOS) +import AppKit + +@MainActor +final class NativeEditorNSTextView: NSTextView { + var requestsFirstResponder = false + var renderedRemotePresenceSegments: [NativeEditorRemotePresenceSegment] = [] + var remotePresenceHighlightRanges: [NSRange] = [] + var remotePresenceOverlayViews: [NSView] = [] + var remotePresenceRenderingIsInvalid = true + + override func layout() { + super.layout() + layoutRemotePresenceOverlays() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + requestFirstResponderIfPossible() + } + + func requestFirstResponderIfPossible() { + guard + requestsFirstResponder, + let window, + window.firstResponder !== self + else { + return + } + window.makeFirstResponder(self) + } +} +#endif diff --git a/docmostly/Features/Editor/NativeEditorPlatformTypingAttributes+iOS.swift b/docmostly/Features/Editor/NativeEditorPlatformTypingAttributes+iOS.swift new file mode 100644 index 00000000..7bbc491d --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorPlatformTypingAttributes+iOS.swift @@ -0,0 +1,65 @@ +#if os(iOS) +import UIKit + +nonisolated enum NativeEditorPlatformTypingAttributes { + static func attributes( + baseFont: UIFont, + marks: Set, + kind: NativeEditorBlockKind, + paragraphStyle: NSParagraphStyle + ) -> [NSAttributedString.Key: Any] { + var attributes: [NSAttributedString.Key: Any] = [ + .font: font(baseFont: baseFont, marks: marks, kind: kind), + .foregroundColor: UIColor.label, + .paragraphStyle: paragraphStyle + ] + if marks.contains(.underline) { + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + } + if marks.contains(.strikethrough) { + attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + } + if marks.contains(.subscript) { + attributes[.baselineOffset] = -4.0 + } else if marks.contains(.superscript) { + attributes[.baselineOffset] = 4.0 + } + return attributes + } + + private static func font( + baseFont: UIFont, + marks: Set, + kind: NativeEditorBlockKind + ) -> UIFont { + let hasStrongEmphasis = marks.contains(.bold) + let usesHeavyHeadingWeight = hasStrongEmphasis && kind.isTypingAttributesHeading + var descriptor = usesHeavyHeadingWeight + ? UIFont.systemFont(ofSize: baseFont.pointSize, weight: .heavy).fontDescriptor + : baseFont.fontDescriptor + if marks.contains(.code), let monospacedDescriptor = descriptor.withDesign(.monospaced) { + descriptor = monospacedDescriptor + } + var traits = descriptor.symbolicTraits + if hasStrongEmphasis && usesHeavyHeadingWeight == false { + traits.insert(.traitBold) + } + if marks.contains(.italic) { + traits.insert(.traitItalic) + } + guard let styledDescriptor = descriptor.withSymbolicTraits(traits) else { + return baseFont + } + return UIFont(descriptor: styledDescriptor, size: baseFont.pointSize) + } +} + +private extension NativeEditorBlockKind { + nonisolated var isTypingAttributesHeading: Bool { + if case .heading = self { + return true + } + return false + } +} +#endif diff --git a/docmostly/Features/Editor/NativeEditorPlatformTypingAttributes+macOS.swift b/docmostly/Features/Editor/NativeEditorPlatformTypingAttributes+macOS.swift new file mode 100644 index 00000000..6c13b7b1 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorPlatformTypingAttributes+macOS.swift @@ -0,0 +1,67 @@ +#if os(macOS) +import AppKit + +nonisolated enum NativeEditorPlatformTypingAttributes { + static func attributes( + baseFont: NSFont, + marks: Set, + kind: NativeEditorBlockKind, + paragraphStyle: NSParagraphStyle + ) -> [NSAttributedString.Key: Any] { + var attributes: [NSAttributedString.Key: Any] = [ + .font: font(baseFont: baseFont, marks: marks, kind: kind), + .foregroundColor: NSColor.labelColor, + .paragraphStyle: paragraphStyle + ] + if marks.contains(.underline) { + attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue + } + if marks.contains(.strikethrough) { + attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue + } + if marks.contains(.subscript) { + attributes[.baselineOffset] = -4.0 + } else if marks.contains(.superscript) { + attributes[.baselineOffset] = 4.0 + } + return attributes + } + + private static func font( + baseFont: NSFont, + marks: Set, + kind: NativeEditorBlockKind + ) -> NSFont { + let hasStrongEmphasis = marks.contains(.bold) + let usesHeavyHeadingWeight = hasStrongEmphasis && kind.isTypingAttributesHeading + let emphasizedWeight: NSFont.Weight = usesHeavyHeadingWeight ? .heavy : .regular + var font: NSFont + if marks.contains(.code) { + font = NSFont.monospacedSystemFont( + ofSize: baseFont.pointSize, + weight: emphasizedWeight + ) + } else if usesHeavyHeadingWeight { + font = NSFont.systemFont(ofSize: baseFont.pointSize, weight: emphasizedWeight) + } else { + font = baseFont + } + if hasStrongEmphasis && usesHeavyHeadingWeight == false { + font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) + } + if marks.contains(.italic) { + font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) + } + return font + } +} + +private extension NativeEditorBlockKind { + nonisolated var isTypingAttributesHeading: Bool { + if case .heading = self { + return true + } + return false + } +} +#endif diff --git a/docmostly/Features/Editor/NativeEditorQuickFormattingToolbarGroup.swift b/docmostly/Features/Editor/NativeEditorQuickFormattingToolbarGroup.swift index e081b741..502a290d 100644 --- a/docmostly/Features/Editor/NativeEditorQuickFormattingToolbarGroup.swift +++ b/docmostly/Features/Editor/NativeEditorQuickFormattingToolbarGroup.swift @@ -12,6 +12,11 @@ struct NativeEditorQuickFormattingToolbarGroup: View { } .keyboardShortcut("b", modifiers: .command) .nativeEditorToolbarControlFrame() + .background( + viewModel.isInlineMarkActive(.bold) ? DocmostlyTheme.primaryTint : .clear, + in: .rect(cornerRadius: 8) + ) + .accessibilityAddTraits(viewModel.isInlineMarkActive(.bold) ? .isSelected : []) Button { viewModel.toggleInlineMark(.italic) @@ -20,6 +25,11 @@ struct NativeEditorQuickFormattingToolbarGroup: View { } .keyboardShortcut("i", modifiers: .command) .nativeEditorToolbarControlFrame() + .background( + viewModel.isInlineMarkActive(.italic) ? DocmostlyTheme.primaryTint : .clear, + in: .rect(cornerRadius: 8) + ) + .accessibilityAddTraits(viewModel.isInlineMarkActive(.italic) ? .isSelected : []) Button { viewModel.toggleInlineMark(.code) @@ -28,6 +38,11 @@ struct NativeEditorQuickFormattingToolbarGroup: View { } .keyboardShortcut("e", modifiers: .command) .nativeEditorToolbarControlFrame() + .background( + viewModel.isInlineMarkActive(.code) ? DocmostlyTheme.primaryTint : .clear, + in: .rect(cornerRadius: 8) + ) + .accessibilityAddTraits(viewModel.isInlineMarkActive(.code) ? .isSelected : []) Button { isShowingLinkPrompt = true diff --git a/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift b/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift index 2d5ce054..a8283f94 100644 --- a/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift +++ b/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift @@ -5,9 +5,13 @@ import UIKit struct NativeEditorTextInputView: UIViewRepresentable { @Binding var block: NativeEditorBlock + let isEditable: Bool let isFocused: Bool let focusRequestID: UUID? + let retainsResponderDuringFocusHandoff: Bool let focusChanged: (Bool) -> Void + let typingInlineMarks: Set + let invalidateTypingContext: () -> Void let accessibilityLabel: String let actions: NativeEditorTextInputActions var remotePresenceSegments: [NativeEditorRemotePresenceSegment] = [] @@ -20,6 +24,8 @@ struct NativeEditorTextInputView: UIViewRepresentable { let textView = NativeEditorUITextView() textView.delegate = context.coordinator textView.backgroundColor = .clear + textView.isEditable = isEditable + textView.isSelectable = true textView.isScrollEnabled = false textView.textContainerInset = .zero textView.textContainer.lineFragmentPadding = 0 @@ -33,6 +39,7 @@ struct NativeEditorTextInputView: UIViewRepresentable { textView.backspaceAtStartAction = { [weak coordinator = context.coordinator] in coordinator?.mergeBlockBackward() ?? false } + context.coordinator.configure(textView) context.coordinator.applySource(to: textView) context.coordinator.updateFocus(textView) textView.updateRemotePresence(remotePresenceSegments) @@ -41,6 +48,7 @@ struct NativeEditorTextInputView: UIViewRepresentable { func updateUIView(_ textView: NativeEditorUITextView, context: Context) { context.coordinator.parent = self + textView.isEditable = isEditable context.coordinator.configure(textView) context.coordinator.updateFromBoundBlock(textView) @@ -81,6 +89,7 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { private var renderedPlainText: String private var isApplyingSource = false private var pendingTextDelta: NativeEditorTextDelta? + private var textDrivenSelection: Range? private var pendingSelectionCorrection: Range? private var handledFocusRequestID: UUID? private var bindingEchoReconciler = NativeEditorTextBindingEchoReconciler() @@ -100,11 +109,12 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { textView.font = font textView.textAlignment = paragraphStyle.alignment - textView.typingAttributes = [ - .font: font, - .foregroundColor: UIColor.label, - .paragraphStyle: paragraphStyle - ] + textView.typingAttributes = NativeEditorPlatformTypingAttributes.attributes( + baseFont: font, + marks: parent.typingInlineMarks, + kind: parent.block.kind, + paragraphStyle: paragraphStyle + ) textView.accessibilityLabel = parent.accessibilityLabel } @@ -173,6 +183,13 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { } func updateFocus(_ textView: NativeEditorUITextView) { + guard parent.isEditable else { + textView.requestsFirstResponder = false + guard textView.isFirstResponder else { return } + textView.resignFirstResponder() + return + } + if let focusRequestID = parent.focusRequestID, focusRequestID != handledFocusRequestID, parent.isFocused { @@ -183,7 +200,8 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { switch focusBindingEchoReconciler.disposition( for: parent.isFocused, - platformIsFocused: textView.isFirstResponder + platformIsFocused: textView.isFirstResponder, + preservesPlatformFocusDuringHandoff: parent.retainsResponderDuringFocusHandoff ) { case .activate: textView.requestsFirstResponder = true @@ -191,6 +209,9 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { case .preserveLocalActivation: textView.requestsFirstResponder = true return + case .preserveDuringHandoff: + textView.requestsFirstResponder = true + return case .deactivate: textView.requestsFirstResponder = false guard textView.isFirstResponder else { return } @@ -233,6 +254,9 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { replacedCharacterRange: characterRange, replacement: text ) + textDrivenSelection = pendingTextDelta.map { delta in + delta.insertionCharacterOffset.. Void + let typingInlineMarks: Set + let invalidateTypingContext: () -> Void let accessibilityLabel: String let actions: NativeEditorTextInputActions var remotePresenceSegments: [NativeEditorRemotePresenceSegment] = [] @@ -21,7 +25,7 @@ struct NativeEditorTextInputView: NSViewRepresentable { textView.delegate = context.coordinator textView.drawsBackground = false textView.isRichText = true - textView.isEditable = true + textView.isEditable = isEditable textView.isSelectable = true textView.allowsUndo = true textView.isHorizontallyResizable = false @@ -32,6 +36,7 @@ struct NativeEditorTextInputView: NSViewRepresentable { textView.textContainer?.heightTracksTextView = false textView.setAccessibilityLabel(accessibilityLabel) context.coordinator.applySource(to: textView) + context.coordinator.configure(textView) context.coordinator.updateFocus(textView) textView.updateRemotePresence(remotePresenceSegments) return textView @@ -39,9 +44,9 @@ struct NativeEditorTextInputView: NSViewRepresentable { func updateNSView(_ textView: NativeEditorNSTextView, context: Context) { context.coordinator.parent = self - context.coordinator.configure(textView) - + textView.isEditable = isEditable context.coordinator.updateFromBoundBlock(textView) + context.coordinator.configure(textView) context.coordinator.updateFocus(textView) textView.updateRemotePresence(remotePresenceSegments) } @@ -89,6 +94,7 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { private var renderedPlainText: String private var isApplyingSource = false private var pendingTextDelta: NativeEditorTextDelta? + private var textDrivenSelection: Range? private var pendingSelectionCorrection: Range? private var handledFocusRequestID: UUID? private var bindingEchoReconciler = NativeEditorTextBindingEchoReconciler() @@ -101,6 +107,8 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { super.init() } + // AppKit resets typing attributes when the selection changes, so call this + // after synchronizing both the text storage and selection. func configure(_ textView: NSTextView) { let font = platformFont(for: parent.block.kind) let paragraphStyle = NSMutableParagraphStyle() @@ -108,11 +116,13 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { textView.font = font textView.alignment = paragraphStyle.alignment - textView.typingAttributes = [ - .font: font, - .foregroundColor: NSColor.labelColor, - .paragraphStyle: paragraphStyle - ] + textView.defaultParagraphStyle = paragraphStyle + textView.typingAttributes = NativeEditorPlatformTypingAttributes.attributes( + baseFont: font, + marks: parent.typingInlineMarks, + kind: parent.block.kind, + paragraphStyle: paragraphStyle + ) textView.setAccessibilityLabel(parent.accessibilityLabel) } @@ -197,8 +207,10 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { } let safeDelta = delta.adjustedForAtomicInlineContent(in: sourceText) - let updatedSource = safeDelta.applying(to: sourceText) + let updatedSource = safeDelta.applying(to: sourceText, typingInlineMarks: parent.typingInlineMarks) let updatedSourcePlainText = String(updatedSource.characters) + let insertionOffset = min(safeDelta.insertionCharacterOffset, updatedSource.characters.count) + textDrivenSelection = insertionOffset.. AttributedString { + func applying( + to source: AttributedString, + typingInlineMarks: Set? = nil + ) -> AttributedString { let safeDelta = adjustedForAtomicInlineContent(in: source) var result = source let clampedRange = NativeEditorCharacterRange.clamped( @@ -42,15 +45,18 @@ nonisolated struct NativeEditorTextDelta: Equatable, Sendable { let attributedRange = NativeEditorCharacterRange.attributedRange(for: clampedRange, in: source) var replacementText = AttributedString(safeDelta.replacement) - if replacementText.characters.isEmpty == false, - var inheritedAttributes = Self.inheritedAttributes( - at: attributedRange.lowerBound, - replacedRange: attributedRange, - in: source - ) { + if replacementText.characters.isEmpty == false { + var inheritedAttributes = Self.inheritedAttributes( + at: attributedRange.lowerBound, + replacedRange: attributedRange, + in: source + ) ?? AttributeContainer() inheritedAttributes[NativeEditorMentionAttribute.self] = nil inheritedAttributes[NativeEditorStatusAttribute.self] = nil inheritedAttributes[NativeEditorMathInlineAttribute.self] = nil + if let typingInlineMarks { + NativeEditorInlineMark.setActiveMarks(typingInlineMarks, in: &inheritedAttributes) + } replacementText.mergeAttributes(inheritedAttributes) } @@ -259,6 +265,7 @@ nonisolated struct NativeEditorFocusBindingEchoReconciler { enum Disposition: Equatable { case activate case preserveLocalActivation + case preserveDuringHandoff case deactivate } @@ -274,13 +281,19 @@ nonisolated struct NativeEditorFocusBindingEchoReconciler { mutating func disposition( for boundIsFocused: Bool, - platformIsFocused: Bool + platformIsFocused: Bool, + preservesPlatformFocusDuringHandoff: Bool = false ) -> Disposition { if boundIsFocused { isAwaitingLocalActivationEcho = false return .activate } + if preservesPlatformFocusDuringHandoff, platformIsFocused { + isAwaitingLocalActivationEcho = false + return .preserveDuringHandoff + } + if isAwaitingLocalActivationEcho, platformIsFocused { return .preserveLocalActivation } diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift index a606bcce..a038829d 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel+BlockEditing.swift @@ -207,19 +207,59 @@ extension NativeRichEditorViewModel { } func toggleInlineMark(_ mark: NativeEditorInlineMark) { - performUndoableEdit { - guard let index = activeBlockIndex else { return } + guard let index = activeBlockIndex else { return } - if document.blocks[index].selection.hasSelectedRanges(in: document.blocks[index].text) { - var selection = document.blocks[index].selection - document.blocks[index].text.transformAttributes(in: &selection) { attributes in - mark.toggle(in: &attributes) - } - document.blocks[index].selection = selection + let block = document.blocks[index] + guard block.selection.hasSelectedRanges(in: block.text) else { + var marks = inlineTypingContext?.blockID == block.id + ? inlineTypingContext?.marks ?? [] + : NativeEditorInlineMark.activeMarks(for: block.selection, in: block.text) + if marks.contains(mark) { + marks.remove(mark) } else { - mark.toggle(in: &document.blocks[index].text) + if mark == .subscript { + marks.remove(.superscript) + } else if mark == .superscript { + marks.remove(.subscript) + } + marks.insert(mark) } + inlineTypingContext = NativeEditorInlineTypingContext(blockID: block.id, marks: marks) + return } + + inlineTypingContext = nil + performUndoableEdit { + var selection = document.blocks[index].selection + document.blocks[index].text.transformAttributes(in: &selection) { attributes in + mark.toggle(in: &attributes) + } + document.blocks[index].selection = selection + } + } + + func isInlineMarkActive(_ mark: NativeEditorInlineMark) -> Bool { + guard let index = activeBlockIndex else { return false } + let block = document.blocks[index] + if block.selection.hasSelectedRanges(in: block.text) == false, + inlineTypingContext?.blockID == block.id { + return inlineTypingContext?.marks.contains(mark) == true + } + return NativeEditorInlineMark.activeMarks(for: block.selection, in: block.text).contains(mark) + } + + func typingInlineMarks(for blockID: UUID) -> Set { + guard let block = document.blocks.first(where: { $0.id == blockID }) else { return [] } + if block.selection.hasSelectedRanges(in: block.text) == false, + inlineTypingContext?.blockID == block.id { + return inlineTypingContext?.marks ?? [] + } + return NativeEditorInlineMark.activeMarks(for: block.selection, in: block.text) + } + + func invalidateInlineTypingContext(for blockID: UUID) { + guard inlineTypingContext?.blockID == blockID else { return } + inlineTypingContext = nil } func applyLink(_ urlString: String) { diff --git a/docmostly/Features/Editor/NativeRichEditorViewModel.swift b/docmostly/Features/Editor/NativeRichEditorViewModel.swift index 25482255..adae5ef8 100644 --- a/docmostly/Features/Editor/NativeRichEditorViewModel.swift +++ b/docmostly/Features/Editor/NativeRichEditorViewModel.swift @@ -25,8 +25,15 @@ final class NativeRichEditorViewModel { var hasPageRestriction = false var errorMessage: String? var saveErrorMessage: String? - var activeBlockID: UUID? + var activeBlockID: UUID? { + didSet { + if activeBlockID != oldValue { + inlineTypingContext = nil + } + } + } var focusedTextInputBlockID: UUID? + var inlineTypingContext: NativeEditorInlineTypingContext? var selectedBlockID: UUID? var visibleBlockControlsID: UUID? var isTitleFocused = false diff --git a/docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift b/docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift index a9d44bdf..dc9e5d3e 100644 --- a/docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift +++ b/docmostlyTests/Editor/NativeEditorBlockRowPolicyTests.swift @@ -10,17 +10,17 @@ struct NativeEditorBlockRowPolicyTests { alignment: .left ) - #expect(NativeEditorBlockRowPolicy.showsEditableTextEditor(block: block, isReadOnly: false)) + #expect(NativeEditorBlockRowPolicy.usesTextInputSurface(block: block)) } - @Test func readModeDoesNotShowTextEditorForEditableBlocks() { + @Test func readModePreservesTextEditorLayoutForEditableBlocks() { let block = NativeEditorBlock( kind: .paragraph, text: AttributedString("Read-only body"), alignment: .left ) - #expect(NativeEditorBlockRowPolicy.showsEditableTextEditor(block: block, isReadOnly: true) == false) + #expect(NativeEditorBlockRowPolicy.usesTextInputSurface(block: block)) } @Test func readModeDisablesTaskListToggles() { diff --git a/docmostlyTests/Editor/NativeEditorTextMutationTests.swift b/docmostlyTests/Editor/NativeEditorTextMutationTests.swift index acb64437..6563cbb6 100644 --- a/docmostlyTests/Editor/NativeEditorTextMutationTests.swift +++ b/docmostlyTests/Editor/NativeEditorTextMutationTests.swift @@ -92,6 +92,33 @@ struct NativeEditorTextMutationTests { #expect(try attributes(for: "!", in: mentionInsertion)[NativeEditorMentionAttribute.self] == nil) } + @Test func explicitTypingMarksOnlyApplyToInsertedText() throws { + let text = AttributedString("Plain") + let updated = NativeEditorTextDelta( + replacedCharacterRange: text.characters.count..