Skip to content

Commit bb2ad03

Browse files
isaacclaude
andcommitted
feat(richtext): table per-cell header/highlight + colspan/rowspan
Per-cell header (highlight) flag replacing the row-level header, plus colspan/rowspan cell merging in the RichText composer — create + view (InstantPage V2 already renders received merged/header tables). Covers the Core span model + covering map + transforms, TableBlockBox span-aware geometry, the cell-rectangle selection + Merge/Split UI, and the ChatInputContent/InstantPage round-trips. Squashed from the feature/richtext-table-header topic branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 53dc1e6 commit bb2ad03

40 files changed

Lines changed: 4340 additions & 253 deletions

File tree

‎Telegram/Telegram-iOS/en.lproj/Localizable.strings‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16563,6 +16563,8 @@ Error: %8$@";
1656316563
"RichText.Menu.Table.Copy" = "Copy Table";
1656416564
"RichText.Menu.Table.ConvertToText" = "Convert to Text";
1656516565
"RichText.Menu.Table.Delete" = "Delete Table";
16566+
"RichText.Menu.Table.HighlightOn" = "Highlight";
16567+
"RichText.Menu.Table.HighlightOff" = "Remove Highlight";
1656616568

1656716569
"TextProcessing.TitleAICompose" = "Create with AI";
1656816570
"TextProcessing.ActionAddToPage" = "Add to Page";

‎submodules/InstantPageUI/Sources/InstantPageLayoutSpacings.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,9 @@ func spacingBetweenBlocks(upper: InstantPageBlock?, lower: InstantPageBlock?, fi
150150
} else if case .thinking = upper {
151151
return 2.0
152152
} else if case .image = upper, fitToWidth {
153-
return 2.0
153+
return 0.0
154154
} else if case .video = upper, fitToWidth {
155-
return 2.0
155+
return 0.0
156156
} else if case .collage = upper, fitToWidth {
157157
return 0.0
158158
} else if case .slideshow = upper, fitToWidth {

‎submodules/TelegramCore/Sources/ChatInputContent/ChatInputContentInstantPage.swift‎

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,13 @@ func instantPageBlocks(from content: ChatInputContent, collectingMediaInto media
133133
}
134134
}
135135
case let .table(t):
136-
// `naturalSize`/`displayWidth`/`alignment` of media and `width`/`colspan`/`rowspan`/cell-background
137-
// of a table are NOT representable in the InstantPage projection (see the reverse for the documented
138-
// defaults the round-trip restores). Forward each cell's own per-cell H+V alignment (the editor's
139-
// `justified` has no InstantPage equivalent → CANONICALIZED to `.left`), header flag, and runs.
136+
// `naturalSize`/`displayWidth`/`alignment` of media and `width`/cell-background of a table are NOT
137+
// representable in the InstantPage projection (see the reverse for the documented defaults the
138+
// round-trip restores). Forward each cell's own per-cell H+V alignment (the editor's `justified` has
139+
// no InstantPage equivalent → CANONICALIZED to `.left`), per-cell header flag, colspan/rowspan, and
140+
// runs. `header` is carried PER CELL (not derived from the row), so a partial-header row (some
141+
// cells header, some not) round-trips exactly. `colspan`/`rowspan` are `Int` (chat currency, default
142+
// 1) forwarded as `Int32` (InstantPage's `0`/`1` = no span, `>1` = real span).
140143
let rows = t.rows.map { row -> InstantPageTableRow in
141144
let cells = row.cells.map { cell -> InstantPageTableCell in
142145
let alignment: TableHorizontalAlignment
@@ -157,7 +160,7 @@ func instantPageBlocks(from content: ChatInputContent, collectingMediaInto media
157160
case .bottom:
158161
vAlignment = .bottom
159162
}
160-
return InstantPageTableCell(text: richText(from: cell.runs), header: row.isHeader, alignment: alignment, verticalAlignment: vAlignment, colspan: 1, rowspan: 1)
163+
return InstantPageTableCell(text: richText(from: cell.runs), header: cell.isHeader, alignment: alignment, verticalAlignment: vAlignment, colspan: Int32(cell.colspan), rowspan: Int32(cell.rowspan))
161164
}
162165
return InstantPageTableRow(cells: cells)
163166
}
@@ -346,18 +349,20 @@ func chatInputBlocks(fromInstantPageBlocks blocks: [InstantPageBlock], media: [M
346349
let map = TelegramMediaMap(latitude: latitude, longitude: longitude, heading: nil, accuracyRadius: nil, venue: nil)
347350
result.append(.media(ChatInputMedia(media: map, kind: .location, naturalSize: ChatInputSize(width: 0.0, height: 0.0), displayWidth: nil, alignment: .center, caption: chatInputRuns(fromRichText: caption.text))))
348351
case let .table(_, rows, _, _):
349-
// Rebuild the `ChatInputTable`. Columns are inferred from the first row's cell count; column
352+
// Rebuild the `ChatInputTable`. Columns are inferred from the widest row's SPANNED cell count (each
353+
// cell occupies `max(1, colspan)` grid columns, so a colspanning first cell no longer under-counts
354+
// — a plain, no-span table still infers from the raw cell count, matching the prior behavior); column
350355
// `width` is NOT representable in InstantPage cells → restored as the DEFAULT `0.0`. The table
351-
// `title`, per-cell `colspan`/`rowspan`, and cell `background` are likewise not in the editor
352-
// model → dropped/fixed (title unused; background `nil`). Per-cell H+V alignment DOES round-trip
353-
// (restored onto each `ChatInputTableCell` below). Identity therefore holds for a table whose
354-
// columns use width `0.0` and whose cells use `background: nil` (the values the reverse yields).
355-
var columns: [ChatInputColumnSpec] = []
356-
if let firstRow = rows.first {
357-
columns = firstRow.cells.map { _ in ChatInputColumnSpec(width: 0.0) }
358-
}
356+
// `title` and cell `background` are likewise not in the editor model → dropped/fixed (title unused;
357+
// background `nil`). Per-cell H+V alignment, per-cell `header`, AND per-cell `colspan`/`rowspan` DO
358+
// round-trip (restored onto each `ChatInputTableCell` below — no row-level derivation). `colspan`/
359+
// `rowspan` are InstantPage `Int32` (`0`/`1` = no span) normalized back to the chat currency's `Int`
360+
// (default 1): a span `> 1` is preserved, anything else canonicalizes to `1`. Identity therefore
361+
// holds for a table whose columns use width `0.0` and whose cells use `background: nil` (the values
362+
// the reverse yields).
363+
let columnCount = rows.map { row in row.cells.reduce(0) { $0 + max(1, Int($1.colspan)) } }.max() ?? 0
364+
let columns: [ChatInputColumnSpec] = Array(repeating: ChatInputColumnSpec(width: 0.0), count: columnCount)
359365
let outRows = rows.map { row -> ChatInputTableRow in
360-
let isHeader = row.cells.first?.header ?? false
361366
let cells = row.cells.map { cell -> ChatInputTableCell in
362367
let alignment: ChatInputTextAlignment
363368
switch cell.alignment {
@@ -371,9 +376,9 @@ func chatInputBlocks(fromInstantPageBlocks blocks: [InstantPageBlock], media: [M
371376
case .middle: vAlignment = .middle
372377
case .bottom: vAlignment = .bottom
373378
}
374-
return ChatInputTableCell(runs: chatInputRuns(fromRichText: cell.text ?? .empty), background: nil, horizontalAlignment: alignment, verticalAlignment: vAlignment)
379+
return ChatInputTableCell(runs: chatInputRuns(fromRichText: cell.text ?? .empty), background: nil, horizontalAlignment: alignment, verticalAlignment: vAlignment, isHeader: cell.header, colspan: cell.colspan > 1 ? Int(cell.colspan) : 1, rowspan: cell.rowspan > 1 ? Int(cell.rowspan) : 1)
375380
}
376-
return ChatInputTableRow(height: nil, isHeader: isHeader, cells: cells)
381+
return ChatInputTableRow(height: nil, cells: cells)
377382
}
378383
result.append(.table(ChatInputTable(columns: columns, rows: outRows)))
379384
default:

‎submodules/TelegramCore/Sources/ChatInputContent/ChatInputContentModel.swift‎

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -879,32 +879,78 @@ public struct ChatInputTableCell: Equatable, Codable {
879879
public var background: ChatInputColor?
880880
public var horizontalAlignment: ChatInputTextAlignment
881881
public var verticalAlignment: ChatInputTableVerticalAlignment
882+
/// Per-cell header/highlight flag (replaces the old whole-row header).
883+
public var isHeader: Bool
884+
/// Number of grid columns this cell spans (default 1). Mirrors the editor `Cell.colspan`.
885+
public var colspan: Int
886+
/// Number of grid rows this cell spans (default 1). Mirrors the editor `Cell.rowspan`.
887+
public var rowspan: Int
882888
public init(runs: [ChatInputRun] = [], background: ChatInputColor? = nil,
883-
horizontalAlignment: ChatInputTextAlignment = .center, verticalAlignment: ChatInputTableVerticalAlignment = .top) {
889+
horizontalAlignment: ChatInputTextAlignment = .center, verticalAlignment: ChatInputTableVerticalAlignment = .top,
890+
isHeader: Bool = false, colspan: Int = 1, rowspan: Int = 1) {
884891
self.runs = runs
885892
self.background = background
886893
self.horizontalAlignment = horizontalAlignment
887894
self.verticalAlignment = verticalAlignment
895+
self.isHeader = isHeader
896+
self.colspan = colspan
897+
self.rowspan = rowspan
888898
}
889-
private enum CodingKeys: String, CodingKey { case runs, background, horizontalAlignment, verticalAlignment }
899+
private enum CodingKeys: String, CodingKey { case runs, background, horizontalAlignment, verticalAlignment, isHeader, colspan, rowspan }
890900
public init(from decoder: Decoder) throws {
891901
let c = try decoder.container(keyedBy: CodingKeys.self)
892902
runs = try c.decodeIfPresent([ChatInputRun].self, forKey: .runs) ?? []
893903
background = try c.decodeIfPresent(ChatInputColor.self, forKey: .background)
894904
horizontalAlignment = try c.decodeIfPresent(ChatInputTextAlignment.self, forKey: .horizontalAlignment) ?? .center
895905
verticalAlignment = try c.decodeIfPresent(ChatInputTableVerticalAlignment.self, forKey: .verticalAlignment) ?? .top
906+
isHeader = try c.decodeIfPresent(Bool.self, forKey: .isHeader) ?? false
907+
// Decoded (and encoded, below) as `Int32`, NOT `Int`: the Postbox `AdaptedPostbox*coder` has no
908+
// `Int`-typed encode/decode overload (only Int32/Int64) — its keyed containers either assert (encode)
909+
// or silently mismatch (decode) on a bare `Int`. `colspan`/`rowspan` stay `Int` in the public model
910+
// (mirroring the editor `Cell.colspan`/`rowspan`); only the wire representation is `Int32`.
911+
colspan = try c.decodeIfPresent(Int32.self, forKey: .colspan).map(Int.init) ?? 1
912+
rowspan = try c.decodeIfPresent(Int32.self, forKey: .rowspan).map(Int.init) ?? 1
913+
}
914+
public func encode(to encoder: Encoder) throws {
915+
var c = encoder.container(keyedBy: CodingKeys.self)
916+
try c.encode(runs, forKey: .runs)
917+
try c.encodeIfPresent(background, forKey: .background)
918+
try c.encode(horizontalAlignment, forKey: .horizontalAlignment)
919+
try c.encode(verticalAlignment, forKey: .verticalAlignment)
920+
try c.encode(isHeader, forKey: .isHeader)
921+
try c.encode(Int32(colspan), forKey: .colspan)
922+
try c.encode(Int32(rowspan), forKey: .rowspan)
896923
}
897924
}
898925

899-
/// A table row. Synthesized `Codable`.
900-
public struct ChatInputTableRow: Equatable, Codable {
926+
/// A table row. `isHeader` is derived from the cells (per-cell is the source of truth).
927+
public struct ChatInputTableRow: Equatable {
901928
public var height: Double?
902-
public var isHeader: Bool
903929
public var cells: [ChatInputTableCell]
930+
public var isHeader: Bool { !cells.isEmpty && cells.allSatisfy { $0.isHeader } }
931+
/// `isHeader: true` seeds every cell as a header cell; `false` leaves each cell's own flag untouched.
904932
public init(height: Double? = nil, isHeader: Bool = false, cells: [ChatInputTableCell] = []) {
905933
self.height = height
906-
self.isHeader = isHeader
907-
self.cells = cells
934+
self.cells = isHeader ? cells.map { var c = $0; c.isHeader = true; return c } : cells
935+
}
936+
}
937+
938+
extension ChatInputTableRow: Codable {
939+
private enum CodingKeys: String, CodingKey { case height, cells; case legacyIsHeader = "isHeader" }
940+
public init(from decoder: Decoder) throws {
941+
let c = try decoder.container(keyedBy: CodingKeys.self)
942+
height = try c.decodeIfPresent(Double.self, forKey: .height)
943+
var decodedCells = try c.decodeIfPresent([ChatInputTableCell].self, forKey: .cells) ?? []
944+
if (try c.decodeIfPresent(Bool.self, forKey: .legacyIsHeader)) == true {
945+
decodedCells = decodedCells.map { var cell = $0; cell.isHeader = true; return cell }
946+
}
947+
cells = decodedCells
948+
}
949+
public func encode(to encoder: Encoder) throws {
950+
var c = encoder.container(keyedBy: CodingKeys.self)
951+
try c.encodeIfPresent(height, forKey: .height)
952+
try c.encode(cells, forKey: .cells)
953+
// Deliberately omits `isHeader` — derived from cells.
908954
}
909955
}
910956

‎submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -547,7 +547,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
547547
effectiveSize = pageLayout.contentSize
548548
}
549549
boundingSize.width = effectiveSize.width
550-
boundingSize.height = effectiveSize.height + 2.0
550+
boundingSize.height = effectiveSize.height
551551
}
552552

553553
// Authoritative detector: the bottom-most laid-out item is full-width visual media,
@@ -835,7 +835,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
835835
}
836836
self.appliedShowMoreExpanded = showMoreExpanded
837837

838-
animation.animator.updateFrame(layer: self.containerNode.layer, frame: CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: CGSize(width: boundingWidth - 2.0, height: boundingSize.height)), completion: nil)
838+
animation.animator.updateFrame(layer: self.containerNode.layer, frame: CGRect(origin: CGPoint(x: 1.0, y: 0.0), size: CGSize(width: boundingWidth - 2.0, height: boundingSize.height)), completion: nil)
839839
self.containerNode.cornerRadius = layoutConstants.image.defaultCornerRadius
840840

841841
if let statusSizeAndApply {

‎submodules/TelegramUI/Components/Chat/ChatRichTextEditorComposer/Sources/DocumentChatInputContentBridge.swift‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,13 @@ private func chatInputTable(
194194
runs: chatInputRuns(fromRuns: cellRuns(fromBlocks: cell.blocks), resolveEmoji: resolveEmoji),
195195
background: cell.background.map(chatInputColor(fromColor:)),
196196
horizontalAlignment: chatInputTextAlignment(fromAlignment: cell.horizontalAlignment),
197-
verticalAlignment: chatInputTableVerticalAlignment(fromCore: cell.verticalAlignment)
197+
verticalAlignment: chatInputTableVerticalAlignment(fromCore: cell.verticalAlignment),
198+
isHeader: cell.isHeader,
199+
colspan: cell.colspan,
200+
rowspan: cell.rowspan
198201
)
199202
}
200-
return ChatInputTableRow(height: row.height, isHeader: row.isHeader, cells: cells)
203+
return ChatInputTableRow(height: row.height, cells: cells)
201204
}
202205
return ChatInputTable(columns: columns, rows: rows)
203206
}
@@ -453,10 +456,13 @@ private func tableBlock(
453456
))],
454457
background: cell.background.map(color(fromChatInputColor:)),
455458
horizontalAlignment: textAlignment(fromChatInputAlignment: cell.horizontalAlignment),
456-
verticalAlignment: verticalAlignment(fromChatInput: cell.verticalAlignment)
459+
verticalAlignment: verticalAlignment(fromChatInput: cell.verticalAlignment),
460+
isHeader: cell.isHeader,
461+
colspan: cell.colspan,
462+
rowspan: cell.rowspan
457463
)
458464
}
459-
return Row(id: BlockID.generate(), height: row.height, isHeader: row.isHeader, cells: cells)
465+
return Row(id: BlockID.generate(), height: row.height, cells: cells)
460466
}
461467
return TableBlock(id: BlockID.generate(), columns: columns, rows: rows)
462468
}

‎submodules/TelegramUI/Components/Chat/ChatRichTextEditorComposer/Sources/TableStructuralMenuPresentation.swift‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,31 @@ public func presentTableStructuralMenu(
2424
anchor.isUserInteractionEnabled = false
2525
anchorView.addSubview(anchor)
2626

27-
var items: [ContextMenuItem] = []
27+
// The top "attributes" group (alignment + header) applies to the selected cells directly, as opposed
28+
// to the Add/Delete structural actions below. Built up front so the separator logic stays coherent
29+
// regardless of which of the two (if either) is present.
30+
var attributeItems: [ContextMenuItem] = []
2831
if let alignment = request.alignment {
29-
items.append(.custom(TableStructuralMenuAlignmentItem(
32+
attributeItems.append(.custom(TableStructuralMenuAlignmentItem(
3033
initialHorizontal: alignment.horizontal.map(tableHAlign(fromCore:)),
3134
initialVertical: alignment.vertical.map(tableVAlign(fromCore:)),
3235
action: { h, v in
3336
alignment.apply(h.map(coreHAlign(from:)), v.map(coreVAlign(from:)))
3437
}), false))
38+
}
3539

36-
if !request.actions.isEmpty {
37-
items.append(.separator)
38-
}
40+
var items: [ContextMenuItem] = attributeItems
41+
if !attributeItems.isEmpty && (!request.actions.isEmpty || request.header != nil) {
42+
items.append(.separator)
43+
}
44+
if let header = request.header {
45+
items.append(.action(ContextMenuActionItem(
46+
text: header.isHeader == true ? presentationData.strings.RichText_Menu_Table_HighlightOff : presentationData.strings.RichText_Menu_Table_HighlightOn,
47+
icon: { theme in
48+
return generateTintedImage(image: UIImage(bundleImageName: header.isHeader == true ? "Chat/Context Menu/CellHighlightRemove" : "Chat/Context Menu/CellHighlightAdd"), color: theme.contextMenu.primaryColor)
49+
},
50+
action: { _, f in f(.default); header.apply() }
51+
)))
3952
}
4053
items.append(contentsOf: request.actions.map { action in
4154
let (title, icon) = tableStructuralMenuTitleAndIcon(action.kind)
@@ -67,6 +80,8 @@ private func tableStructuralMenuTitleAndIcon(_ kind: TableStructuralMenuRequest.
6780
case .addRowAbove: return ("Add Row Above", "Chat/Context Menu/CellAddTop")
6881
case .addRowBelow: return ("Add Row Below", "Chat/Context Menu/CellAddBottom")
6982
case .deleteRow: return ("Delete Row", "Chat/Context Menu/CellDelete")
83+
case .mergeCells: return ("Merge Cells", "Chat/Context Menu/CellMergeH")
84+
case .splitCell: return ("Split Cell", "Chat/Context Menu/CellSplitH")
7085
}
7186
}
7287

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ private func regeneratingIDs(_ blocks: [Block]) -> [Block] {
3333
// source table's IDs verbatim, and block views are keyed by BlockID, so a duplicate-ID paste would
3434
// steal the original table's view and make the original disappear.
3535
return .table(TableBlock(id: .generate(), columns: t.columns, rows: t.rows.map { row in
36-
Row(id: .generate(), height: row.height, isHeader: row.isHeader, cells: row.cells.map { cell in
37-
Cell(id: .generate(), blocks: regeneratingIDs(cell.blocks), background: cell.background)
36+
Row(id: .generate(), height: row.height, cells: row.cells.map { cell in
37+
Cell(id: .generate(), blocks: regeneratingIDs(cell.blocks), background: cell.background,
38+
horizontalAlignment: cell.horizontalAlignment, verticalAlignment: cell.verticalAlignment,
39+
isHeader: cell.isHeader)
3840
})
3941
}))
4042
case .media(let m):

0 commit comments

Comments
 (0)