mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
Copy rich messages as markdown (whole message + partial selection)
Rich messages (RichTextMessageAttribute, text == "") are copyable as markdown two ways: the context-menu Copy action copies the whole message, and a text selection inside the rich-data bubble copies just the selected range. Both reconstruct markdown mirroring the edit round-trip (markdownStringFromInstantPage). Implements the full RichText entity case set (mention/hashtag/cashtag/bot-command/bank-card/auto url/email/phone) with tap interaction, the InstantPage -> markdown inverse converter and edit round-trip, markdown-context stamping during V2 layout (InstantPageMarkdownBlockContext: heading level, list/code/table/quote depth), partial-selection markdown emission (InstantPageMultiTextAdapter.markdownForRange), and numerous converter edge-case fixes (tables, links, fenced code, blockquote line coalescing, compact nested >> markers). CLAUDE.md documents the feature; the spec/plan scratch docs generated during development are not committed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
65eac2fa94
commit
471d11df16
7 changed files with 383 additions and 5 deletions
25
CLAUDE.md
25
CLAUDE.md
|
|
@ -186,6 +186,31 @@ Rich messages (`RichTextMessageAttribute`, `text == ""`) are made editable by re
|
|||
- **Known limitation:** a rich→plain edit that leaves only inline-formatted text loses `*italic*` (the entity path recognizes only `__…__`). Rare edge; the rich round-trip contract holds.
|
||||
- **`previewText()` lives in TelegramStringFormatting, not TextFormat/TelegramCore.** It will gain a `strings: PresentationStrings` param (to localize the `"Photo"`/`"Video"`/`"Table"` placeholders), so it must sit in a UI-string module — `messageContentKind`/`descriptionStringForMessage` (same module) already take `strings:`. Teaching `messageContentKind` about rich cascades the preview to the edit accessory panel, reply/pinned panels, and forward preview in one place (those surfaces need no individual change).
|
||||
|
||||
## Copying rich messages as markdown (whole message + partial selection)
|
||||
|
||||
Rich messages (`RichTextMessageAttribute`, `text == ""`) are copyable as markdown two ways: the context-menu **Copy** action copies the whole message; a **text selection** inside the rich-data bubble copies just the selected range. Both reconstruct markdown that mirrors the edit round-trip (`markdownStringFromInstantPage`). Always-on.
|
||||
|
||||
### Where things live
|
||||
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift` | Whole-message Copy. Computes `richMessageMarkdown` from the message's `RichTextMessageAttribute.instantPage` (after `let message = messages[0]`), opens the Copy gate with `richMessageMarkdown != nil`, and short-circuits `copyTextWithEntities` to `storeMessageTextInPasteboard(markdown, entities: nil)`. |
|
||||
| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | `markdownStringFromInstantPage` — the block-tree → markdown converter (also used by the edit round-trip). Blocks joined by `\n\n`; nested blockquotes via recursive `> ` wrapping. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageTextItem.swift` | `InstantPageMarkdownBlockContext` (`kind` + `quoteDepth`) and the `markdownContext: InstantPageMarkdownBlockContext?` field on `InstantPageTextItem`. |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | `stampMarkdownContext`/`bumpQuoteDepth`; stamps `markdownContext` during layout (heading/title/code/list/blockQuote/`layoutQuoteText`/table-cell). |
|
||||
| `submodules/InstantPageUI/Sources/InstantPageMultiTextAdapter.swift` | `markdownForRange(_ range: NSRange)` + the private attributed-substring→inline-markdown converter `inlineMarkdown(from:)`. |
|
||||
| `submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/.../ChatMessageRichDataBubbleContentNode.swift` | Intercepts `.copy` in the `TextSelectionNode` `performAction` closure: `textSelectionNode.getSelection()` → `adapter.markdownForRange(range)` → stores as plain `NSAttributedString(string:)`. |
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **The V2 layout discards block role.** A `.text` layout item from an `H2` heading is byte-identical to a body paragraph — heading level and the title category are dropped with no back-reference to the source `InstantPageBlock`. Precise structural markdown for a *selection* therefore requires stamping `markdownContext` at layout time (lists/code/tables/details are structurally recoverable; **heading level and `.title` are not**, so they MUST be stamped). Plain paragraphs stay `nil` (≡ plain).
|
||||
- **`quoteDepth` is orthogonal to `kind`** so a heading/list/code line inside a blockquote round-trips (e.g. `> ## Title`). `bumpQuoteDepth` lifts a quote's children by 1; nested quotes accumulate. `layoutQuoteText` (single-paragraph blockquote fast path AND `.pullQuote`) bumps once — it is never reached by the multi-block recursion, so no double-count.
|
||||
- **A blockquote is exploded into one text item per line.** `markdownForRange` must re-coalesce a run of consecutive `quoteDepth > 0` segments into ONE `\n`-joined block (each line prefixed at its own depth); otherwise every quote line becomes its own block separated by a blank line. Code/table/list runs are likewise coalesced (one fence; one pipe table; one tight list).
|
||||
- **Both converters emit compact nested-quote markers (`>>`, not `> >`).** Selection: `String(repeating: ">", count: depth) + " "`. Whole-message: when wrapping a line that already starts with `>`, prepend a bare `>`. Keep the two in sync.
|
||||
- **Inline markdown is read from display attributes, not the RichText tree.** `inlineMarkdown` inspects the slice's `UIFont` (bold/italic/mono — font-based, no symbolic-trait flag for named fonts), `.strikethroughStyle`, and `TelegramTextAttributes.URL` (→ `InstantPageUrlItem.url`, angle-bracketed if it contains `(`/`)`/space). Custom-emoji placeholders are dropped (no `alt` on the display attribute).
|
||||
- **`.copy` stores plain text.** Passing `NSAttributedString(string: markdown)` through the existing `performTextSelectionAction(.copy)` path (`storeAttributedTextInPasteboard`) generates no entities, so the literal `**`/`#`/`>`/`|` survive. The whole-message Copy uses `storeMessageTextInPasteboard(_, entities: nil)` directly.
|
||||
- **Fidelity caveats (intentional):** custom emoji omitted; ordered list + checkbox loses the ordinal (`-` wins); a partial table selection emits touched cells as rows (no forced header `---` separator); block prefixes apply to the whole touched line on a mid-line selection (correct markdown).
|
||||
|
||||
## Formulas trigger rich messages (strict math detection)
|
||||
|
||||
`$…$`/`$$…$$` (and `\(…\)`/`\[…\]`) math triggers a rich message, gated by a
|
||||
|
|
|
|||
|
|
@ -76,7 +76,10 @@ private func markdownBlockQuoteBlocks(_ blocks: [InstantPageBlock]) -> String {
|
|||
continue
|
||||
}
|
||||
for line in body.split(separator: "\n", omittingEmptySubsequences: false) {
|
||||
lines.append("> \(String(line))")
|
||||
// Stack nested-quote markers without internal spaces (`>>` not `> >`):
|
||||
// a line already starting with `>` (a nested quote) gets a bare `>`.
|
||||
let text = String(line)
|
||||
lines.append(text.hasPrefix(">") ? ">\(text)" : "> \(text)")
|
||||
}
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import UIKit
|
|||
import AsyncDisplayKit
|
||||
import Display
|
||||
import TelegramCore
|
||||
import TextFormat
|
||||
|
||||
public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol {
|
||||
public struct Entry {
|
||||
|
|
@ -110,4 +111,234 @@ public final class InstantPageMultiTextAdapter: ASDisplayNode, TextNodeProtocol
|
|||
}
|
||||
return (allRects, start, end)
|
||||
}
|
||||
|
||||
public func markdownForRange(_ range: NSRange) -> String {
|
||||
struct Segment {
|
||||
let context: InstantPageMarkdownBlockContext?
|
||||
let inline: String
|
||||
}
|
||||
|
||||
var segments: [Segment] = []
|
||||
for entry in self.entries {
|
||||
let entryRange = NSRange(location: entry.charOffset, length: entry.item.attributedString.length)
|
||||
let intersection = NSIntersectionRange(range, entryRange)
|
||||
if intersection.length == 0 {
|
||||
continue
|
||||
}
|
||||
let localRange = NSRange(location: intersection.location - entry.charOffset, length: intersection.length)
|
||||
let slice = entry.item.attributedString.attributedSubstring(from: localRange)
|
||||
let inline = inlineMarkdown(from: slice)
|
||||
if inline.isEmpty {
|
||||
continue
|
||||
}
|
||||
segments.append(Segment(context: entry.item.markdownContext, inline: inline))
|
||||
}
|
||||
if segments.isEmpty {
|
||||
return ""
|
||||
}
|
||||
|
||||
func quotePrefixed(_ text: String, depth: Int) -> String {
|
||||
guard depth > 0 else { return text }
|
||||
let q = String(repeating: ">", count: depth) + " "
|
||||
return text.split(separator: "\n", omittingEmptySubsequences: false).map { q + String($0) }.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func taskMarker(_ c: Bool?) -> String {
|
||||
switch c {
|
||||
case .some(false): return "[ ] "
|
||||
case .some(true): return "[x] "
|
||||
case .none: return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Renders one segment's block content WITHOUT the quote prefix and without
|
||||
// cross-segment coalescing. Used for segments inside a quoted run, where each
|
||||
// line carries its own depth and the whole quote is one `\n`-joined block.
|
||||
func renderContent(_ seg: Segment) -> String {
|
||||
switch seg.context?.kind {
|
||||
case let .code(language):
|
||||
return "```\(language ?? "")\n\(seg.inline)\n```"
|
||||
case .tableCell:
|
||||
return "| " + escapeSelectionTableCell(seg.inline) + " |"
|
||||
case let .listItem(_, marker, checked):
|
||||
return "\(marker) \(taskMarker(checked))" + seg.inline.replacingOccurrences(of: "\n", with: " ")
|
||||
case let .heading(level):
|
||||
let hashes = String(repeating: "#", count: max(1, min(6, level)))
|
||||
return "\(hashes) \(seg.inline)"
|
||||
case .title:
|
||||
return "# \(seg.inline)"
|
||||
case .paragraph, .none:
|
||||
return seg.inline
|
||||
}
|
||||
}
|
||||
|
||||
var groups: [String] = []
|
||||
var index = 0
|
||||
while index < segments.count {
|
||||
let seg = segments[index]
|
||||
let depth = seg.context?.quoteDepth ?? 0
|
||||
|
||||
// A blockquote is exploded by the layout into one text item per child line,
|
||||
// each stamped with quoteDepth > 0. Re-coalesce a run of consecutive quoted
|
||||
// segments into a single block whose lines are joined by `\n` (each carrying
|
||||
// its own `> ` depth), matching the whole-message converter — otherwise every
|
||||
// quote line would become its own block and be separated by a blank line.
|
||||
if depth > 0 {
|
||||
var lines: [String] = []
|
||||
var j = index
|
||||
while j < segments.count, let d = segments[j].context?.quoteDepth, d > 0 {
|
||||
lines.append(quotePrefixed(renderContent(segments[j]), depth: d))
|
||||
j += 1
|
||||
}
|
||||
groups.append(lines.joined(separator: "\n"))
|
||||
index = j
|
||||
continue
|
||||
}
|
||||
|
||||
switch seg.context?.kind {
|
||||
case let .code(language):
|
||||
var body = [seg.inline]
|
||||
var j = index + 1
|
||||
while j < segments.count,
|
||||
case let .code(lang2)? = segments[j].context?.kind,
|
||||
lang2 == language,
|
||||
(segments[j].context?.quoteDepth ?? 0) == depth {
|
||||
body.append(segments[j].inline)
|
||||
j += 1
|
||||
}
|
||||
let fence = "```\(language ?? "")\n" + body.joined(separator: "\n") + "\n```"
|
||||
groups.append(quotePrefixed(fence, depth: depth))
|
||||
index = j
|
||||
case let .tableCell(row, _, _):
|
||||
var rows: [[String]] = [[escapeSelectionTableCell(seg.inline)]]
|
||||
var currentRow = row
|
||||
var j = index + 1
|
||||
while j < segments.count,
|
||||
case let .tableCell(row2, _, _)? = segments[j].context?.kind,
|
||||
(segments[j].context?.quoteDepth ?? 0) == depth {
|
||||
if row2 != currentRow {
|
||||
rows.append([])
|
||||
currentRow = row2
|
||||
}
|
||||
rows[rows.count - 1].append(escapeSelectionTableCell(segments[j].inline))
|
||||
j += 1
|
||||
}
|
||||
let tableBlock = rows.map { "| " + $0.joined(separator: " | ") + " |" }.joined(separator: "\n")
|
||||
groups.append(quotePrefixed(tableBlock, depth: depth))
|
||||
index = j
|
||||
case let .listItem(_, marker, checked):
|
||||
let firstLine = "\(marker) \(taskMarker(checked))" + seg.inline.replacingOccurrences(of: "\n", with: " ")
|
||||
var lines = [firstLine]
|
||||
var j = index + 1
|
||||
while j < segments.count, case let .listItem(_, marker2, checked2)? = segments[j].context?.kind, (segments[j].context?.quoteDepth ?? 0) == depth {
|
||||
lines.append("\(marker2) \(taskMarker(checked2))" + segments[j].inline.replacingOccurrences(of: "\n", with: " "))
|
||||
j += 1
|
||||
}
|
||||
groups.append(quotePrefixed(lines.joined(separator: "\n"), depth: depth))
|
||||
index = j
|
||||
case let .heading(level):
|
||||
let hashes = String(repeating: "#", count: max(1, min(6, level)))
|
||||
groups.append(quotePrefixed("\(hashes) \(seg.inline)", depth: depth))
|
||||
index += 1
|
||||
case .title:
|
||||
groups.append(quotePrefixed("# \(seg.inline)", depth: depth))
|
||||
index += 1
|
||||
case .paragraph, .none:
|
||||
groups.append(quotePrefixed(seg.inline, depth: depth))
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
|
||||
return groups.joined(separator: "\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
private func escapeSelectionMarkdown(_ string: String) -> String {
|
||||
var result = ""
|
||||
result.reserveCapacity(string.count)
|
||||
for character in string {
|
||||
switch character {
|
||||
case "\\", "*", "_", "`", "[", "]", "~", "|":
|
||||
result.append("\\")
|
||||
result.append(character)
|
||||
default:
|
||||
result.append(character)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func escapeSelectionTableCell(_ string: String) -> String {
|
||||
return string.replacingOccurrences(of: "\n", with: " ")
|
||||
}
|
||||
|
||||
/// Converts a styled slice of an InstantPage text item into inline markdown,
|
||||
/// reading the same attributes the renderer wrote (font-based bold/italic/mono,
|
||||
/// strikethrough style, the TelegramTextAttributes.URL link item, custom emoji).
|
||||
private func inlineMarkdown(from slice: NSAttributedString) -> String {
|
||||
let fullRange = NSRange(location: 0, length: slice.length)
|
||||
var result = ""
|
||||
|
||||
slice.enumerateAttributes(in: fullRange, options: []) { attributes, range, _ in
|
||||
let substring = (slice.string as NSString).substring(with: range)
|
||||
|
||||
// Custom emoji: single-space placeholder with no alt available — drop it.
|
||||
if attributes[ChatTextInputAttributes.customEmoji] != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var bold = false
|
||||
var italic = false
|
||||
var mono = false
|
||||
if let font = attributes[.font] as? UIFont {
|
||||
let name = font.fontName.lowercased()
|
||||
if name.hasPrefix(".sfui") || name.hasPrefix(".applesystemui") {
|
||||
let traits = font.fontDescriptor.symbolicTraits
|
||||
if traits.contains(.traitMonoSpace) {
|
||||
mono = true
|
||||
} else {
|
||||
bold = traits.contains(.traitBold)
|
||||
italic = traits.contains(.traitItalic)
|
||||
}
|
||||
} else if name.contains("menlo") || name.contains("courier") || name.contains("sfmono") {
|
||||
mono = true
|
||||
} else {
|
||||
if name.contains("bolditalic") {
|
||||
bold = true; italic = true
|
||||
} else if name.contains("bold") {
|
||||
bold = true
|
||||
} else if name.contains("italic") {
|
||||
italic = true
|
||||
}
|
||||
}
|
||||
}
|
||||
let strike = attributes[.strikethroughStyle] != nil
|
||||
|
||||
var inner: String
|
||||
if mono {
|
||||
// Inline code takes no nested emphasis; emit the raw text in backticks.
|
||||
inner = "`\(substring)`"
|
||||
} else {
|
||||
inner = escapeSelectionMarkdown(substring)
|
||||
if strike { inner = "~~\(inner)~~" }
|
||||
if bold && italic {
|
||||
inner = "***\(inner)***"
|
||||
} else if bold {
|
||||
inner = "**\(inner)**"
|
||||
} else if italic {
|
||||
inner = "*\(inner)*"
|
||||
}
|
||||
}
|
||||
|
||||
if let urlItem = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? InstantPageUrlItem {
|
||||
let url = urlItem.url
|
||||
let needsBrackets = url.contains("(") || url.contains(")") || url.contains(" ")
|
||||
let destination = needsBrackets ? "<\(url)>" : url
|
||||
inner = "[\(inner)](\(destination))"
|
||||
}
|
||||
|
||||
result += inner
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,29 @@ private func attachmentBoundsForRange(_ range: NSRange, line: InstantPageTextLin
|
|||
return alignedAttachmentFrame(localBounds, line: line, boundingWidth: boundingWidth, alignment: alignment)
|
||||
}
|
||||
|
||||
/// Block-level role of a text item, used to reconstruct markdown from a
|
||||
/// selection. `kind` is the primary role; `quoteDepth` (0 = not quoted) is
|
||||
/// orthogonal so a heading/list/code line inside a blockquote can be emitted
|
||||
/// as e.g. `> ## Title`.
|
||||
public struct InstantPageMarkdownBlockContext: Equatable {
|
||||
public enum Kind: Equatable {
|
||||
case paragraph
|
||||
case heading(level: Int) // 1...6
|
||||
case title // InstantPageBlock.title → "# "
|
||||
case listItem(ordered: Bool, marker: String, checked: Bool?)
|
||||
case code(language: String?)
|
||||
case tableCell(row: Int, column: Int, isHeader: Bool)
|
||||
}
|
||||
|
||||
public var kind: Kind
|
||||
public var quoteDepth: Int
|
||||
|
||||
public init(kind: Kind, quoteDepth: Int = 0) {
|
||||
self.kind = kind
|
||||
self.quoteDepth = quoteDepth
|
||||
}
|
||||
}
|
||||
|
||||
public final class InstantPageTextItem: InstantPageItem {
|
||||
public let attributedString: NSAttributedString
|
||||
public let lines: [InstantPageTextLine]
|
||||
|
|
@ -203,7 +226,8 @@ public final class InstantPageTextItem: InstantPageItem {
|
|||
public let wantsNode: Bool = false
|
||||
public let separatesTiles: Bool = false
|
||||
public var selectable: Bool = true
|
||||
|
||||
public var markdownContext: InstantPageMarkdownBlockContext? = nil
|
||||
|
||||
var containsRTL: Bool {
|
||||
return !self.rtlLineIndices.isEmpty
|
||||
}
|
||||
|
|
|
|||
|
|
@ -540,6 +540,45 @@ private func layoutBlockSequence(
|
|||
return InstantPageV2Layout(contentSize: contentSize, items: items, detailsIndices: detailsIndices, media: context.media, webpage: context.webpage)
|
||||
}
|
||||
|
||||
// MARK: - Markdown block context stamping helpers
|
||||
|
||||
private func stampMarkdownContext(_ items: [InstantPageV2LaidOutItem], kind: InstantPageMarkdownBlockContext.Kind) {
|
||||
for item in items {
|
||||
switch item {
|
||||
case let .text(textItem):
|
||||
if textItem.textItem.markdownContext == nil {
|
||||
textItem.textItem.markdownContext = InstantPageMarkdownBlockContext(kind: kind)
|
||||
}
|
||||
case let .codeBlock(block):
|
||||
if block.textItem.markdownContext == nil {
|
||||
block.textItem.markdownContext = InstantPageMarkdownBlockContext(kind: kind)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks every text item produced by a blockquote's children as quoted (depth + 1),
|
||||
/// preserving each child's own kind (heading/list/paragraph/…).
|
||||
private func bumpQuoteDepth(_ items: [InstantPageV2LaidOutItem]) {
|
||||
for item in items {
|
||||
let target: InstantPageTextItem?
|
||||
switch item {
|
||||
case let .text(textItem): target = textItem.textItem
|
||||
case let .codeBlock(block): target = block.textItem
|
||||
default: target = nil
|
||||
}
|
||||
guard let target else { continue }
|
||||
if var ctx = target.markdownContext {
|
||||
ctx.quoteDepth += 1
|
||||
target.markdownContext = ctx
|
||||
} else {
|
||||
target.markdownContext = InstantPageMarkdownBlockContext(kind: .paragraph, quoteDepth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func layoutBlock(
|
||||
_ block: InstantPageBlock,
|
||||
boundingWidth: CGFloat,
|
||||
|
|
@ -555,8 +594,10 @@ private func layoutBlock(
|
|||
return layoutBlock(inner, boundingWidth: boundingWidth, horizontalInset: horizontalInset,
|
||||
isCover: true, previousItems: previousItems, isLast: isLast, context: &context)
|
||||
case let .title(text):
|
||||
return layoutSimpleText(text, category: .header, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
let titleItems = layoutSimpleText(text, category: .header, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
stampMarkdownContext(titleItems, kind: .title)
|
||||
return titleItems
|
||||
case let .subtitle(text):
|
||||
return layoutSimpleText(text, category: .subheader, boundingWidth: boundingWidth,
|
||||
horizontalInset: horizontalInset, context: &context)
|
||||
|
|
@ -1238,6 +1279,7 @@ private func layoutTable(
|
|||
horizontalInset: 0.0,
|
||||
context: &context
|
||||
)
|
||||
stampMarkdownContext(cellLayout.items, kind: .tableCell(row: i, column: k, isHeader: cell.header))
|
||||
subLayout = cellLayout
|
||||
subLayoutHeight = cellLayout.contentSize.height
|
||||
isEmptyRow = false
|
||||
|
|
@ -1713,6 +1755,7 @@ private func layoutHeading(
|
|||
fitToWidth: context.fitToWidth,
|
||||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
stampMarkdownContext(items, kind: .heading(level: max(1, min(6, Int(level)))))
|
||||
return items
|
||||
}
|
||||
|
||||
|
|
@ -1870,6 +1913,7 @@ private func layoutCodeBlock(
|
|||
computeRevealCharacterRects: context.computeRevealCharacterRects
|
||||
)
|
||||
guard let textItem = textItem else { return [] }
|
||||
textItem.markdownContext = InstantPageMarkdownBlockContext(kind: .code(language: language))
|
||||
|
||||
// Position text within the block's content area.
|
||||
// V1 line 342: x=17.0, y=backgroundInset (block-local).
|
||||
|
|
@ -1977,6 +2021,10 @@ private func layoutBlockQuote(
|
|||
)
|
||||
result.append(.blockQuoteBar(bar))
|
||||
|
||||
// Caption items (appended above) are also bumped to quoteDepth 1 and will render with a
|
||||
// `>` prefix. The whole-message markdown converter drops blockquote captions entirely, and
|
||||
// markdown-sent quotes carry empty captions, so this is benign.
|
||||
bumpQuoteDepth(result)
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -2080,6 +2128,13 @@ private func layoutQuoteText(
|
|||
result.append(.blockQuoteBar(bar))
|
||||
}
|
||||
|
||||
// Tag this quote's produced text items at quote depth 1 so the markdown
|
||||
// converter renders them with a `> ` prefix. Applies to BOTH block quotes
|
||||
// (single-paragraph fast path) and pull quotes — the whole-message markdown
|
||||
// converter renders both flavors as `> `. Nested quotes are lifted further
|
||||
// by the outer multi-block path's own bumpQuoteDepth(result) call.
|
||||
bumpQuoteDepth(result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
@ -2200,6 +2255,14 @@ private func layoutList(
|
|||
effectiveItem = .text(.plain(" "), num, checked)
|
||||
}
|
||||
|
||||
// Derive the markdown marker string and checked state from the original item.
|
||||
let markdownMarker: String
|
||||
switch markerInfo.kind {
|
||||
case let .number(value): markdownMarker = value
|
||||
default: markdownMarker = "-"
|
||||
}
|
||||
let markdownChecked: Bool? = item.checked
|
||||
|
||||
switch effectiveItem {
|
||||
case let .text(text, _, _):
|
||||
// Layout text content.
|
||||
|
|
@ -2243,6 +2306,7 @@ private func layoutList(
|
|||
kind: markerInfo.kind,
|
||||
color: context.theme.textCategories.paragraph.color
|
||||
)))
|
||||
stampMarkdownContext(textLaidOutItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked))
|
||||
result.append(contentsOf: textLaidOutItems)
|
||||
contentHeight += textSize.height
|
||||
|
||||
|
|
@ -2289,6 +2353,11 @@ private func layoutList(
|
|||
blockMaxY = max(blockMaxY, ti.frame.maxY)
|
||||
}
|
||||
|
||||
// Nil-guard in stampMarkdownContext preserves any richer kind (e.g. .heading)
|
||||
// already stamped by a child block's own layout. So a heading nested inside a
|
||||
// .blocks list item keeps .heading, not .listItem — multi-block list items are
|
||||
// a documented best-effort case for markdown reconstruction.
|
||||
stampMarkdownContext(translatedItems, kind: .listItem(ordered: ordered, marker: markdownMarker, checked: markdownChecked))
|
||||
result.append(contentsOf: translatedItems)
|
||||
contentHeight = blockMaxY
|
||||
previousBlock = subBlock
|
||||
|
|
|
|||
|
|
@ -1259,6 +1259,16 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode
|
|||
guard let self, let item = self.item else {
|
||||
return
|
||||
}
|
||||
if case .copy = action,
|
||||
let range = self.textSelectionNode?.getSelection(),
|
||||
range.length > 0,
|
||||
let adapter = self.textSelectionAdapter {
|
||||
let markdown = adapter.markdownForRange(range)
|
||||
if !markdown.isEmpty {
|
||||
item.controllerInteraction.performTextSelectionAction(item.message, true, NSAttributedString(string: markdown), nil, .copy)
|
||||
return
|
||||
}
|
||||
}
|
||||
item.controllerInteraction.performTextSelectionAction(item.message, true, text, nil, action)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import TranslateUI
|
|||
import DebugSettingsUI
|
||||
import ChatPresentationInterfaceState
|
||||
import Pasteboard
|
||||
import BrowserUI
|
||||
import SettingsUI
|
||||
import TextNodeWithEntities
|
||||
import ChatControllerInteraction
|
||||
|
|
@ -1254,6 +1255,13 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
|
|||
}
|
||||
|
||||
let message = messages[0]
|
||||
var richMessageMarkdown: String?
|
||||
if let richTextAttribute = message.attributes.first(where: { $0 is RichTextMessageAttribute }) as? RichTextMessageAttribute {
|
||||
let markdown = markdownStringFromInstantPage(richTextAttribute.instantPage)
|
||||
if !markdown.isEmpty {
|
||||
richMessageMarkdown = markdown
|
||||
}
|
||||
}
|
||||
var isExpired = false
|
||||
var isImage = false
|
||||
for media in message.media {
|
||||
|
|
@ -1266,7 +1274,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
|
|||
}
|
||||
|
||||
let isCopyProtected = chatPresentationInterfaceState.copyProtectionEnabled || message.isCopyProtected()
|
||||
if !messageText.isEmpty || (resourceAvailable && isImage) || diceEmoji != nil {
|
||||
if !messageText.isEmpty || richMessageMarkdown != nil || (resourceAvailable && isImage) || diceEmoji != nil {
|
||||
if !isExpired {
|
||||
if !isPoll {
|
||||
if !isCopyProtected {
|
||||
|
|
@ -1277,6 +1285,14 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState
|
|||
UIPasteboard.general.string = diceEmoji
|
||||
} else {
|
||||
let copyTextWithEntities = {
|
||||
if let richMessageMarkdown {
|
||||
storeMessageTextInPasteboard(richMessageMarkdown, entities: nil)
|
||||
Queue.mainQueue().after(0.2, {
|
||||
let content: UndoOverlayContent = .copy(text: chatPresentationInterfaceState.strings.Conversation_MessageCopied)
|
||||
controllerInteraction.displayUndo(content)
|
||||
})
|
||||
return
|
||||
}
|
||||
var messageText = message.text
|
||||
var messageEntities: [MessageTextEntity]?
|
||||
var restrictedText: String?
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue