mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 06:14:14 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
7.8 KiB
Swift
150 lines
7.8 KiB
Swift
import Foundation
|
|
|
|
// The structural replace-range.
|
|
//
|
|
// `ChatTextInputState.inputText` is a DERIVED flat projection of `ChatInputContent`, and rebuilding
|
|
// the model from a mutated copy of it (`ChatTextInputState(inputText:selectionRange:)`) re-derives
|
|
// every block through `chatInputContent(from:)` — which has no vocabulary for headings, lists,
|
|
// quotes, tables or media and so retypes them all as body paragraphs. Ten composer sites used to
|
|
// change a few characters that way, destroying the whole composer's structure as a side effect.
|
|
//
|
|
// This is the replacement: resolve a flat range to structural positions through the mapping the model
|
|
// already exposes, and splice runs into the block(s) it lands in. Everything else is untouched by
|
|
// construction.
|
|
|
|
/// The runs of a block whose text lives directly on the flat axis, or nil for a block that has none
|
|
/// (a quote, whose text is nested, or an off-axis block).
|
|
private func chatInputFlatTextRuns(of block: ChatInputBlock) -> [ChatInputRun]? {
|
|
switch block {
|
|
case let .paragraph(paragraph):
|
|
return paragraph.runs
|
|
case let .code(code):
|
|
return code.runs
|
|
case let .pullQuote(pullQuote):
|
|
return pullQuote.runs
|
|
case .blockQuote, .media, .table, .details, .buttonRow:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Rebuilds `block` around `runs`.
|
|
///
|
|
/// A `"\n"` splits a PARAGRAPH into siblings, because on the flat axis `"\n"` is what separates two
|
|
/// paragraphs. It does NOT split a code block or a pull quote: those carry interior newlines as
|
|
/// ordinary characters (`plainText` appends their text verbatim), so splitting one would shatter it.
|
|
private func chatInputRebuilt(_ block: ChatInputBlock, withRuns runs: [ChatInputRun]) -> [ChatInputBlock] {
|
|
switch block {
|
|
case let .paragraph(paragraph):
|
|
return chatInputRunsSplitOnNewlines(runs).map { line in
|
|
.paragraph(ChatInputParagraph(style: paragraph.style, list: paragraph.list, runs: line))
|
|
}
|
|
case let .code(code):
|
|
return [.code(ChatInputCode(language: code.language, runs: runs))]
|
|
case let .pullQuote(pullQuote):
|
|
return [.pullQuote(ChatInputPullQuote(runs: runs, author: pullQuote.author))]
|
|
case .blockQuote, .media, .table, .details, .buttonRow:
|
|
return [block]
|
|
}
|
|
}
|
|
|
|
public extension ChatInputContent {
|
|
/// The inline entity at a flat offset, or nil. The emoji-suggestion sweep uses it to stop before
|
|
/// re-replacing an occurrence it has already turned into an emoji — it searches plain text, and a
|
|
/// custom emoji's `displayText` can equal the shortcode it replaced.
|
|
func entityAt(flatOffset: Int) -> ChatInputInlineEntity? {
|
|
let position = self.position(forFlatOffset: flatOffset)
|
|
guard let step = position.path.first, self.blocks.indices.contains(step.blockIndex),
|
|
let runs = chatInputFlatTextRuns(of: self.blocks[step.blockIndex]) else {
|
|
return nil
|
|
}
|
|
var cursor = 0
|
|
for run in runs {
|
|
let length = (run.text as NSString).length
|
|
if position.offset < cursor + length {
|
|
return run.attributes.entity
|
|
}
|
|
cursor += length
|
|
}
|
|
return runs.last?.attributes.entity
|
|
}
|
|
|
|
/// Replaces the flat (`plainText`) UTF-16 range with `runs`, preserving every block outside it.
|
|
/// Returns the new content and the flat offset just past the inserted runs.
|
|
///
|
|
/// Use this instead of mutating `ChatTextInputState.inputText` and reconstructing the state — that
|
|
/// round-trip flattens the whole composer (see the file comment).
|
|
///
|
|
/// Returns the receiver unchanged when the range addresses something with no editable flat text: a
|
|
/// collapsed quote's `" "` placeholder, or a cross-block range with a quote at either end (merging
|
|
/// a quote endpoint is a further increment — refusing is visible, corrupting is not).
|
|
func replacingFlatRange(_ range: NSRange, with runs: [ChatInputRun]) -> (content: ChatInputContent, caret: Int) {
|
|
let total = (self.plainText as NSString).length
|
|
let lo = min(max(range.location, 0), total)
|
|
let hi = min(max(range.location + max(range.length, 0), lo), total)
|
|
// Uniform across every mutating branch: the flat axis counts a paragraph separator as the one
|
|
// "\n" that was already in `runs`, so the inserted extent is exactly the runs' UTF-16 length.
|
|
let caret = lo + chatInputRunsUTF16Length(runs)
|
|
|
|
let startPosition = self.position(forFlatOffset: lo)
|
|
let endPosition = self.position(forFlatOffset: hi)
|
|
guard let startStep = startPosition.path.first, let endStep = endPosition.path.first,
|
|
self.blocks.indices.contains(startStep.blockIndex),
|
|
self.blocks.indices.contains(endStep.blockIndex) else {
|
|
return (self, lo)
|
|
}
|
|
let startIndex = startStep.blockIndex
|
|
let endIndex = endStep.blockIndex
|
|
|
|
if startIndex != endIndex {
|
|
let headBlock = self.blocks[startIndex]
|
|
let tailBlock = self.blocks[endIndex]
|
|
// A quote at either end is refused rather than corrupted: merging one means either
|
|
// recursing the head quote's interior or migrating the tail quote's blocks out of it, which
|
|
// is a further increment. See the accepted limitation in the design doc.
|
|
guard let headRuns = chatInputFlatTextRuns(of: headBlock),
|
|
let tailRuns = chatInputFlatTextRuns(of: tailBlock) else {
|
|
return (self, lo)
|
|
}
|
|
let merged = chatInputRunsSlice(headRuns, fromUTF16: 0, toUTF16: startPosition.offset)
|
|
+ runs
|
|
+ chatInputRunsSlice(tailRuns, fromUTF16: endPosition.offset,
|
|
toUTF16: chatInputRunsUTF16Length(tailRuns))
|
|
// Off-axis blocks contribute neither a character nor a separator, so the flat range never
|
|
// addressed them; they keep their relative order and follow the merged block.
|
|
let survivors = self.blocks[(startIndex + 1) ..< endIndex].filter { !self.blockIsFlatParticipating($0) }
|
|
var newBlocks = self.blocks
|
|
newBlocks.replaceSubrange(startIndex ... endIndex,
|
|
with: chatInputRebuilt(headBlock, withRuns: merged) + survivors)
|
|
return (ChatInputContent(schemaVersion: self.schemaVersion, blocks: newBlocks), caret)
|
|
}
|
|
|
|
let block = self.blocks[startIndex]
|
|
if case let .blockQuote(quote) = block {
|
|
// A collapsed quote's placeholder is not editable text.
|
|
guard !quote.collapsed else {
|
|
return (self, lo)
|
|
}
|
|
// An expanded quote's interior is its own flat axis.
|
|
let inner = quote.content.replacingFlatRange(
|
|
NSRange(location: startPosition.offset, length: endPosition.offset - startPosition.offset),
|
|
with: runs
|
|
)
|
|
var newBlocks = self.blocks
|
|
newBlocks[startIndex] = .blockQuote(ChatInputBlockQuote(content: inner.content, collapsed: false,
|
|
author: quote.author))
|
|
return (ChatInputContent(schemaVersion: self.schemaVersion, blocks: newBlocks), caret)
|
|
}
|
|
guard let blockRuns = chatInputFlatTextRuns(of: block) else {
|
|
return (self, lo)
|
|
}
|
|
|
|
let merged = chatInputRunsSlice(blockRuns, fromUTF16: 0, toUTF16: startPosition.offset)
|
|
+ runs
|
|
+ chatInputRunsSlice(blockRuns, fromUTF16: endPosition.offset,
|
|
toUTF16: chatInputRunsUTF16Length(blockRuns))
|
|
var newBlocks = self.blocks
|
|
newBlocks.replaceSubrange(startIndex ... startIndex, with: chatInputRebuilt(block, withRuns: merged))
|
|
return (ChatInputContent(schemaVersion: self.schemaVersion, blocks: newBlocks), caret)
|
|
}
|
|
}
|