Skip to content

Commit 4cfe577

Browse files
isaacclaude
andcommitted
feat(richtext-attachment): Add-menu actions target the document end when unfocused
When the article editor has no caret (not first responder), the Add-menu and table actions previously applied at offset 0 — a structural slot that touches no paragraph, so paragraph commands silently no-op'd (Heading did nothing) and inserts landed at the start. Now, when there is no cursor, these actions target the END of the document: capture focus state at menu-open time, and before applying, focus the editor and drop the caret at the end (`focusEditorAtDocumentEndIfNeeded`). Wired into Text / Quote / Pull-quote / Code / Formula and table insertion. Heading gets a dedicated path: with a caret it converts the current paragraph as before; with no caret it appends a NEW empty paragraph in that heading style and focuses it. Adds `RichTextEditorView.moveCaretToDocumentEnd()`, which collapses the selection to a caret at `endOfDocument` (past the closing structural token). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 21ed574 commit 4cfe577

2 files changed

Lines changed: 44 additions & 7 deletions

File tree

‎submodules/TelegramUI/Components/RichTextAttachmentScreen/Sources/RichTextAttachmentScreen.swift‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -669,13 +669,26 @@ final class RichTextAttachmentScreenComponent: Component {
669669
self.editor.setParagraphStyle(.body)
670670
}
671671

672+
/// When the editor is unfocused (no caret), an Add-menu format/insert action should target the END of
673+
/// the document, not the default offset-0 start (which touches no paragraph, so the command no-ops).
674+
/// Focuses the editor and drops the caret at the end so the following command applies there. No-op when
675+
/// a caret already exists — the user's position is preserved.
676+
private func focusEditorAtDocumentEndIfNeeded(hasCursor: Bool) {
677+
guard !hasCursor else { return }
678+
self.editor.becomeFirstResponder()
679+
self.editor.moveCaretToDocumentEnd()
680+
}
681+
672682
private func presentAddMenu(from sourceView: UIView) {
673683
guard let component = self.component, let environment = self.environment, let controller = environment.controller() as? RichTextAttachmentScreen else {
674684
return
675685
}
676-
686+
677687
let editorState = self.editor.currentState()
678-
688+
// Captured at menu-open time (before presenting can change first-responder state): whether the user
689+
// has a live caret. When false, the Add-menu actions target the document end instead of offset 0.
690+
let hasCursor = viewTreeContainsFirstResponder(view: self.editor)
691+
679692
var items: [ContextMenuItem] = []
680693

681694
if !editorState.hasSelection {
@@ -744,9 +757,16 @@ final class RichTextAttachmentScreenComponent: Component {
744757
f(.default)
745758
return
746759
}
747-
748-
self.editor.setParagraphStyle(mappedStyle)
749-
760+
761+
if hasCursor {
762+
self.editor.setParagraphStyle(mappedStyle)
763+
} else {
764+
// No caret: append a NEW empty paragraph with this heading style at the end and
765+
// focus it, rather than converting an existing paragraph (a no-op at offset 0).
766+
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
767+
self.editor.insertDocument(Document(blocks: [.paragraph(ParagraphBlock(id: BlockID.generate(), style: mappedStyle))]))
768+
}
769+
750770
f(.default)
751771
})))
752772
}
@@ -772,6 +792,7 @@ final class RichTextAttachmentScreenComponent: Component {
772792
return
773793
}
774794

795+
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
775796
self.convertToBodyText()
776797
c?.dismiss(completion: nil)
777798
})))
@@ -784,7 +805,8 @@ final class RichTextAttachmentScreenComponent: Component {
784805
c?.dismiss(completion: nil)
785806
return
786807
}
787-
808+
809+
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
788810
let live = self.editor.currentState()
789811
if live.blockQuoteDepth > 0 {
790812
self.editor.unwrapBlockQuoteLevel()
@@ -804,6 +826,7 @@ final class RichTextAttachmentScreenComponent: Component {
804826
return
805827
}
806828

829+
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
807830
let live = self.editor.currentState()
808831
if live.blockQuoteDepth > 0 {
809832
self.editor.unwrapBlockQuoteLevel()
@@ -825,6 +848,7 @@ final class RichTextAttachmentScreenComponent: Component {
825848
return
826849
}
827850

851+
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
828852
let live = self.editor.currentState()
829853
if live.blockQuoteDepth > 0 {
830854
self.editor.unwrapBlockQuoteLevel()
@@ -845,7 +869,8 @@ final class RichTextAttachmentScreenComponent: Component {
845869
c?.dismiss(completion: nil)
846870
return
847871
}
848-
872+
873+
self.focusEditorAtDocumentEndIfNeeded(hasCursor: hasCursor)
849874
self.component?.presentFormulaEditor?(nil, { [weak self] latex in
850875
guard let self else {
851876
return
@@ -1353,6 +1378,9 @@ final class RichTextAttachmentScreenComponent: Component {
13531378
f(.default); self?.editor.deleteTable()
13541379
})))
13551380
} else {
1381+
// No caret: drop the table at the document end (become FR + caret to end), not at
1382+
// the default offset-0 start. With a caret it inserts at the caret as before.
1383+
self.focusEditorAtDocumentEndIfNeeded(hasCursor: viewTreeContainsFirstResponder(view: self.editor))
13561384
self.editor.insertTable(rows: 2, cols: 2)
13571385
}
13581386
self.presentActionMenu(from: sourceView, items: items)

‎submodules/TelegramUI/Components/RichTextEditor/Sources/RichTextEditorUIKit/RichTextEditorView.swift‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,15 @@ public final class RichTextEditorView: UIView, UIScrollViewDelegate {
600600
DocumentTextPosition(canvas.documentSizeValue))
601601
}
602602

603+
/// Collapses the selection to a caret at the last renderable position (end of the document). A host uses
604+
/// this to target an UNFOCUSED editor's end with a subsequent format/insert command: the default caret
605+
/// sits at offset 0 (a structural slot that touches no paragraph), so a command applied there no-ops /
606+
/// lands at the start. `endOfDocument` already snaps past the closing structural token to a real slot.
607+
public func moveCaretToDocumentEnd() {
608+
guard let end = canvas.endOfDocument as? DocumentTextPosition else { return }
609+
canvas.selectedTextRange = DocumentTextRange(end, end)
610+
}
611+
603612
@discardableResult
604613
public override func becomeFirstResponder() -> Bool { canvas.becomeFirstResponder() }
605614

0 commit comments

Comments
 (0)