Skip to content

Commit 710e60e

Browse files
isaacclaude
andcommitted
feat(richtext): attachment-editor List/Text format menus + text-only selection state
Add a List action to the attachment/article editor's selection toolbar, gated on a new EditorState.selectionIsTextOnly (true when the selection covers no media/table block). Make the add-menu "Text" item a normalize-to-plain-body action that strips the enclosing block container (quote/code/list/pull-quote) or heading, shown only when there is no selection or the selection is not already plain body. Extract the shared list-marker picker into presentListMenu. Adds the RichTextEditorCoreDocument typealias used by the screen. Covered by EditorStateTests + EditorFormatFacadeTests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5b59160 commit 710e60e

6 files changed

Lines changed: 502 additions & 281 deletions

File tree

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

Lines changed: 347 additions & 281 deletions
Large diffs are not rendered by default.

‎submodules/TelegramUI/Components/RichTextEditor/Sources/RichTextEditorCore/Model/Document.swift‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,5 @@ public struct Document: Codable, Equatable {
4444
layoutDirection = try c.decodeIfPresent(DocumentLayoutDirection.self, forKey: .layoutDirection) ?? .auto
4545
}
4646
}
47+
48+
public typealias RichTextEditorCoreDocument = Document

‎submodules/TelegramUI/Components/RichTextEditor/Sources/RichTextEditorUIKit/Canvas/DocumentCanvasView+State.swift‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ extension DocumentCanvasView {
3131
return (ca.bold, ca.italic, ca.underline, ca.strikethrough, ca.inlineCode, ca.spoiler)
3232
}
3333

34+
/// Whether the current (non-empty) selection covers only paragraph text — no media or table block.
35+
/// `isInsideTable` handles a selection whose endpoint sits inside a cell; the box scan also rejects a
36+
/// top-level selection that spans a media/table block while both endpoints stay in paragraphs (where
37+
/// neither endpoint is "in table"). Code blocks and quotes are left in scope — `setList` no-ops on a
38+
/// code block and applies to quoted paragraphs, matching the caret-case List action.
39+
private func selectionIsTextOnly() -> Bool {
40+
guard selFrom < selTo else { return false }
41+
if isInsideTable(head) || isInsideTable(anchor) { return false }
42+
for box in boxes where box is MediaBlockBox || box is TableBlockBox {
43+
let lo = box.nodeStart, hi = box.nodeStart + box.nodeSize
44+
if selFrom < hi && selTo > lo { return false }
45+
}
46+
return true
47+
}
48+
3449
func currentState() -> RichTextEditorView.EditorState {
3550
let topBlock = headTopLevelBlock()
3651
let fmt = currentInlineFormats()
@@ -46,6 +61,7 @@ extension DocumentCanvasView {
4661
// "in table" for toolbar purposes (so table-structural commands can enable).
4762
hasSelection: selFrom < selTo,
4863
isInTable: isInsideTable(head) || isInsideTable(anchor),
64+
selectionIsTextOnly: selectionIsTextOnly(),
4965
canUndo: effectiveUndoManager?.canUndo ?? false,
5066
canRedo: effectiveUndoManager?.canRedo ?? false,
5167
blockQuoteDepth: blockQuoteDepth(at: head)

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,11 @@ public final class RichTextEditorView: UIView, UIScrollViewDelegate {
198198
public let link: String?
199199
public let hasSelection: Bool
200200
public let isInTable: Bool
201+
/// True when a non-empty selection touches only paragraph text — no media or table block, and
202+
/// neither endpoint is inside a table cell. A list marker can only be meaningfully applied to
203+
/// paragraph blocks, so a host toolbar uses this to gate a per-selection List action. False for
204+
/// a collapsed caret.
205+
public let selectionIsTextOnly: Bool
201206
public let canUndo: Bool
202207
public let canRedo: Bool
203208
/// Number of `Block.blockQuote` containers enclosing the caret (0 = not in a quote; N = nested N levels

‎submodules/TelegramUI/Components/RichTextEditor/Tests/RichTextEditorUIKitTests/EditorFormatFacadeTests.swift‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,86 @@ final class EditorFormatFacadeTests: XCTestCase {
6262
guard case .paragraph(let p)? = e.document.blocks.first else { return XCTFail("expected paragraph") }
6363
XCTAssertEqual(p.text, "Bye")
6464
}
65+
66+
// MARK: convertToBodyText composition
67+
//
68+
// The `RichTextAttachmentScreen` "Text" menu item normalizes the caret's / selection's paragraph(s)
69+
// to a plain body paragraph, stripping whatever block container they sit in. The helper is app-side,
70+
// so these tests reproduce its exact façade composition and assert each container type collapses to
71+
// plain body — the load-bearing behavior the menu item relies on.
72+
private func convertToBodyText(_ e: RichTextEditorView) {
73+
let live = e.currentState()
74+
if live.isCodeBlock { e.makeCodeBlock(); return }
75+
if live.isPullQuote { e.makePullQuote(); return }
76+
// Unwrap quotes first so a quoted list item becomes a top-level paragraph `setList` can reach.
77+
var guardCount = 0
78+
while e.currentState().blockQuoteDepth > 0 && guardCount < 32 { e.unwrapBlockQuoteLevel(); guardCount += 1 }
79+
if e.currentState().listMarker != nil { e.setList(nil) }
80+
e.setParagraphStyle(.body)
81+
}
82+
private func firstParagraph(_ e: RichTextEditorView) -> ParagraphBlock? {
83+
for b in e.document.blocks { if case .paragraph(let p) = b { return p } }
84+
return nil
85+
}
86+
private func assertPlainBody(_ e: RichTextEditorView, _ message: String) {
87+
let paras = e.document.blocks.compactMap { block -> ParagraphBlock? in
88+
if case .paragraph(let p) = block { return p }; return nil
89+
}
90+
XCTAssertEqual(paras.count, e.document.blocks.count, "\(message): every block is a paragraph (no container survives)")
91+
XCTAssertTrue(paras.allSatisfy { $0.style == .body }, "\(message): every paragraph is body style")
92+
XCTAssertTrue(paras.allSatisfy { $0.list == nil }, "\(message): no list membership survives")
93+
XCTAssertEqual(paras.map { $0.text }.joined(), "Hello", "\(message): the text is preserved")
94+
}
95+
96+
func test_convertToBodyText_stripsHeading() {
97+
let e = editor()
98+
e.selectAll(); e.setParagraphStyle(.heading1)
99+
XCTAssertEqual(firstParagraph(e)?.style, .heading1)
100+
e.selectAll(); convertToBodyText(e)
101+
assertPlainBody(e, "heading → body")
102+
}
103+
104+
func test_convertToBodyText_stripsList() {
105+
let e = editor()
106+
e.selectAll(); e.setList(.bullet)
107+
XCTAssertNotNil(firstParagraph(e)?.list)
108+
e.selectAll(); convertToBodyText(e)
109+
assertPlainBody(e, "list → body")
110+
}
111+
112+
func test_convertToBodyText_stripsBlockQuote() {
113+
let e = editor()
114+
e.selectAll(); e.wrapInBlockQuote()
115+
// The caret is left inside the quote (depth > 0), mirroring a menu tap with the selection/caret in
116+
// the container — `blockQuoteDepth` is head-based, exactly like the existing Quote toggle.
117+
XCTAssertGreaterThan(e.currentState().blockQuoteDepth, 0)
118+
convertToBodyText(e)
119+
assertPlainBody(e, "block quote → body")
120+
}
121+
122+
func test_convertToBodyText_stripsQuotedList() {
123+
let e = editor()
124+
e.selectAll(); e.setList(.bullet); e.selectAll(); e.wrapInBlockQuote()
125+
XCTAssertGreaterThan(e.currentState().blockQuoteDepth, 0)
126+
XCTAssertEqual(e.currentState().listMarker, .bullet)
127+
convertToBodyText(e)
128+
assertPlainBody(e, "quoted list → body")
129+
}
130+
131+
func test_convertToBodyText_stripsCodeBlock() {
132+
let e = editor()
133+
e.selectAll(); e.makeCodeBlock()
134+
XCTAssertTrue(e.currentState().isCodeBlock)
135+
convertToBodyText(e)
136+
assertPlainBody(e, "code block → body")
137+
}
138+
139+
func test_convertToBodyText_stripsPullQuote() {
140+
let e = editor()
141+
e.selectAll(); e.makePullQuote()
142+
XCTAssertTrue(e.currentState().isPullQuote)
143+
convertToBodyText(e)
144+
assertPlainBody(e, "pull quote → body")
145+
}
65146
}
66147
#endif

‎submodules/TelegramUI/Components/RichTextEditor/Tests/RichTextEditorUIKitTests/EditorStateTests.swift‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,57 @@ final class EditorStateTests: XCTestCase {
6969
XCTAssertFalse(e.currentState().isCodeBlock, "a body paragraph is not a code block")
7070
}
7171

72+
func test_currentState_selectionWithinParagraph_isTextOnly() {
73+
let e = editor([.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "Hello")]))])
74+
let lo = e.canvas.boxes[0].textStart, hi = lo + 3
75+
e.canvas.selectedTextRange = DocumentTextRange(DocumentTextPosition(lo), DocumentTextPosition(hi))
76+
XCTAssertTrue(e.currentState().selectionIsTextOnly, "a selection over plain paragraph text is text-only")
77+
}
78+
79+
func test_currentState_collapsedCaret_isNotTextOnly() {
80+
let e = editor([.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "Hello")]))])
81+
let pos = DocumentTextPosition(e.canvas.boxes[0].textStart + 1)
82+
e.canvas.selectedTextRange = DocumentTextRange(pos, pos)
83+
XCTAssertFalse(e.currentState().selectionIsTextOnly, "a collapsed caret has no selection, so it is not text-only")
84+
}
85+
86+
func test_currentState_selectionAcrossParagraphs_isTextOnly() {
87+
let e = editor([
88+
.paragraph(ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "First")])),
89+
.paragraph(ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "Second")])),
90+
])
91+
let lo = DocumentTextPosition(e.canvas.boxes[0].textStart)
92+
let hi = DocumentTextPosition(e.canvas.boxes[1].textStart + 2)
93+
e.canvas.selectedTextRange = DocumentTextRange(lo, hi)
94+
XCTAssertTrue(e.currentState().selectionIsTextOnly, "a multi-paragraph text selection is text-only")
95+
}
96+
97+
func test_currentState_selectionSpanningMedia_isNotTextOnly() {
98+
let e = editor([
99+
.paragraph(ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "First")])),
100+
.media(MediaBlock(id: BlockID("img"), mediaID: "x",
101+
naturalSize: Size2D(width: 100, height: 60), caption: [])),
102+
.paragraph(ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "Second")])),
103+
])
104+
let lo = DocumentTextPosition(e.canvas.boxes[0].textStart)
105+
let hi = DocumentTextPosition(e.canvas.boxes[2].textStart + 2)
106+
e.canvas.selectedTextRange = DocumentTextRange(lo, hi)
107+
XCTAssertFalse(e.currentState().selectionIsTextOnly,
108+
"a selection spanning a media block (both endpoints in paragraphs) is not text-only")
109+
}
110+
111+
func test_currentState_selectionInsideTableCell_isNotTextOnly() {
112+
let table = TableBlock(id: BlockID("t"), columns: [ColumnSpec(width: 120), ColumnSpec(width: 120)],
113+
rows: [Row(id: BlockID("r0"), isHeader: true, cells: [
114+
Cell(id: BlockID("a"), blocks: [.paragraph(ParagraphBlock(id: BlockID("ap"), runs: [TextRun(text: "AB")]))]),
115+
Cell(id: BlockID("b"), blocks: [.paragraph(ParagraphBlock(id: BlockID("bp"), runs: [TextRun(text: "CD")]))])])])
116+
let e = editor([.table(table)])
117+
let t = e.canvas.boxes[0] as! TableBlockBox
118+
let cellStart = t.cellTextStart(row: 0, column: 0)!
119+
e.canvas.selectedTextRange = DocumentTextRange(DocumentTextPosition(cellStart), DocumentTextPosition(cellStart + 2))
120+
XCTAssertFalse(e.currentState().selectionIsTextOnly, "a selection inside a table cell is not text-only")
121+
}
122+
72123
func test_currentState_boldOverSelection() {
73124
let e = editor([.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "Hello")]))])
74125
let lo = e.canvas.boxes[0].textStart, hi = lo + 5

0 commit comments

Comments
 (0)