Skip to content
Merged
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
32 changes: 10 additions & 22 deletions docmostly/Features/Editor/NativeEditorBlockRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<NativeEditorInlineMark>
let invalidateInlineTypingContext: () -> Void
let moveBefore: (UUID) -> Void
let splitBlock: (Range<Int>) -> Bool
let insertHardBreak: (Range<Int>) -> Bool
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions docmostly/Features/Editor/NativeEditorBlockRowPolicy.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
10 changes: 6 additions & 4 deletions docmostly/Features/Editor/NativeEditorBodyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ struct NativeEditorBodyView: View {
focusRequestID: textInputFocusRequest?.blockID == block.id
? textInputFocusRequest?.id
: nil,
retainsResponderDuringFocusHandoff: viewModel.activeBlockID != nil &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Pasting or otherwise activating a non-text block leaves the previous text view as first responder, because every inactive text row treats any non-nil active block as a focus handoff. Limit responder preservation to an editable target so keyboard input cannot remain in the old block after a table/media block becomes active.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docmostly/Features/Editor/NativeEditorBodyView.swift, line 65:

<comment>Pasting or otherwise activating a non-text block leaves the previous text view as first responder, because every inactive text row treats any non-nil active block as a focus handoff. Limit responder preservation to an editable target so keyboard input cannot remain in the old block after a table/media block becomes active.</comment>

<file context>
@@ -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,
</file context>

viewModel.activeBlockID != block.id,
isSelected: viewModel.selectedBlockID == block.id,
isShowingControls: viewModel.visibleBlockControlsID == block.id,
isReadOnly: authoringIsAvailable == false,
Expand Down Expand Up @@ -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 }
Expand All @@ -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)
Expand Down
101 changes: 87 additions & 14 deletions docmostly/Features/Editor/NativeEditorInlineMark.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation
import SwiftUI

enum NativeEditorInlineMark {
nonisolated enum NativeEditorInlineMark: CaseIterable, Hashable, Sendable {
case bold
case italic
case underline
Expand All @@ -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<Self>, 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<Self> {
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<Self> {
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..<insertionIndex].runs.first?.attributes,
attributes.hasNativeEditorAtomicInlineAttribute == false {
return attributes
}
}

if insertionIndex < text.endIndex {
let nextIndex = text.characters.index(after: insertionIndex)
let attributes = text[insertionIndex..<nextIndex].runs.first?.attributes
if attributes?.hasNativeEditorAtomicInlineAttribute == false {
return attributes
}
}

return nil
}

private var intent: InlinePresentationIntent? {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

nonisolated struct NativeEditorInlineTypingContext: Equatable, Sendable {
let blockID: UUID
var marks: Set<NativeEditorInlineMark>
}
33 changes: 33 additions & 0 deletions docmostly/Features/Editor/NativeEditorNSTextView.swift
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#if os(iOS)
import UIKit

nonisolated enum NativeEditorPlatformTypingAttributes {
static func attributes(
baseFont: UIFont,
marks: Set<NativeEditorInlineMark>,
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<NativeEditorInlineMark>,
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
Loading
Loading