From 4453b0ee3d02aa2ef582bb033b748ae47d08e554 Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 2 Jul 2026 21:26:22 +0400 Subject: [PATCH] Edit text-span selections with the plain AI Editor --- Telegram/SourceFiles/boxes/compose_ai_box.cpp | 2 +- .../SourceFiles/iv/editor/iv_editor_box.cpp | 15 + .../SourceFiles/iv/editor/iv_editor_state.cpp | 61 ++-- .../SourceFiles/iv/editor/iv_editor_state.h | 4 +- .../iv/editor/iv_editor_widget.cpp | 262 +++++++++++------- .../SourceFiles/iv/editor/iv_editor_widget.h | 3 + 6 files changed, 195 insertions(+), 152 deletions(-) diff --git a/Telegram/SourceFiles/boxes/compose_ai_box.cpp b/Telegram/SourceFiles/boxes/compose_ai_box.cpp index e9f24a9de7..34caca2916 100644 --- a/Telegram/SourceFiles/boxes/compose_ai_box.cpp +++ b/Telegram/SourceFiles/boxes/compose_ai_box.cpp @@ -1227,7 +1227,7 @@ ComposeAiContent::ComposeAiContent( , _authorLabel(Ui::CreateChild( this, st::aiComposeAuthorLabel)) { - if (_richSource && _tones.empty()) { + if (_tones.empty()) { _session->data().aiComposeTones().refresh(); } _preview->setResizeCallback([=] { refreshLayout(); }); diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_box.cpp b/Telegram/SourceFiles/iv/editor/iv_editor_box.cpp index 9fbc3badf0..f7c5154edf 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_box.cpp +++ b/Telegram/SourceFiles/iv/editor/iv_editor_box.cpp @@ -1583,6 +1583,21 @@ void WindowHost::Impl::setupWindow(ShowWindowDescriptor &&descriptor) { } const auto editor = _editor; if (editor && editor->hasActiveSelection()) { + auto span = editor->textSpanForCurrentSelection(); + if (!span.text.isEmpty()) { + HistoryView::Controls::ShowComposeAiBox(_show, { + .session = session, + .text = std::move(span), + .apply = [editor](TextWithEntities result) { + if (!editor || result.text.isEmpty()) { + return; + } + editor->replaceCurrentSelectionWithText( + std::move(result)); + }, + }); + return; + } auto source = editor->richPageForCurrentSelection(); if (source && !source->blocks.empty()) { HistoryView::Controls::ShowComposeAiBox(_show, { diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp b/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp index 56ebc79c40..b07176e5f8 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp +++ b/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp @@ -7588,23 +7588,21 @@ State::ActiveTextBlockActionResult State::applyActiveTextBlockAction( }); } -State::ActiveTextBlockActionResult -State::replaceActiveTextSelectionWithRichPage( - std::shared_ptr page, +State::ActiveTextBlockActionResult State::replaceActiveTextSelectionWithText( + TextWithEntities text, ActiveTextInsertContext context) { return applyCheckedMutation(ActiveTextBlockActionResult{ .result = ApplyResult::Failed, - }, [page = std::move(page), context = std::move(context)]( + }, [text = std::move(text), context = std::move(context)]( State &candidate) mutable { - ActiveTextBlockActionResult result{ - .result = ApplyResult::Failed, - }; const auto failed = [&] { return CheckedMutationResult{ - .result = result, + .result = ActiveTextBlockActionResult{ + .result = ApplyResult::Failed, + }, }; }; - if (!page || page->blocks.empty()) { + if (text.text.isEmpty()) { return failed(); } const auto descriptor = candidate.textNode( @@ -7612,40 +7610,13 @@ State::replaceActiveTextSelectionWithRichPage( if (!descriptor || (descriptor->leaf.kind == LeafKind::MathFormula)) { return failed(); } - const auto singleParagraph = (page->blocks.size() == 1) - && (page->blocks.front().kind == BlockKind::Paragraph) - && page->blocks.front().blocks.empty() - && (descriptor->leaf.kind != LeafKind::TableCellText); - if (singleParagraph) { - const auto &inner = page->blocks.front().text.text; - const auto selectionFrom = int(context.before.text.size()); - const auto selectionTo = selectionFrom + int(inner.text.size()); - const auto applied = candidate.applyActiveTextUnchecked(JoinText( - context.before, - inner, - context.after)); - if (applied == ApplyResult::Failed) { - return failed(); - } - const auto updated = candidate.textNode( - candidate._activeTextOrdinal); - return CheckedMutationResult{ - .apply = true, - .result = { - .result = ApplyResult::Changed, - .destinationLeaf = (updated - ? updated->leaf - : descriptor->leaf), - .selectionFrom = selectionFrom, - .selectionTo = selectionTo, - }, - }; - } - auto blocks = page->blocks; - const auto applied = candidate.insertBlocksAfterActiveUnchecked( - std::move(blocks), - context); - if (!applied) { + const auto selectionFrom = int(context.before.text.size()); + const auto selectionTo = selectionFrom + int(text.text.size()); + const auto applied = candidate.applyActiveTextUnchecked(JoinText( + std::move(context.before), + std::move(text), + std::move(context.after))); + if (applied == ApplyResult::Failed) { return failed(); } const auto updated = candidate.textNode(candidate._activeTextOrdinal); @@ -7656,8 +7627,8 @@ State::replaceActiveTextSelectionWithRichPage( .destinationLeaf = (updated ? updated->leaf : descriptor->leaf), - .selectionFrom = 0, - .selectionTo = 0, + .selectionFrom = selectionFrom, + .selectionTo = selectionTo, }, }; }); diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_state.h b/Telegram/SourceFiles/iv/editor/iv_editor_state.h index 91de5054c9..f09f9d37ce 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_state.h +++ b/Telegram/SourceFiles/iv/editor/iv_editor_state.h @@ -413,8 +413,8 @@ public: InsertAction action, ActiveTextInsertContext context); [[nodiscard]] ActiveTextBlockActionResult - replaceActiveTextSelectionWithRichPage( - std::shared_ptr page, + replaceActiveTextSelectionWithText( + TextWithEntities text, ActiveTextInsertContext context); [[nodiscard]] bool insertPreparedBlockAfterActive(RichPage::Block block); [[nodiscard]] bool insertPreparedBlocksAfterActive( diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp b/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp index dcaf2ebaca..a2b7df3b17 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp +++ b/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp @@ -232,6 +232,18 @@ struct CommittedFieldSelectionRestore { int cursorOffset = 0; }; +void RemoveBlockLevelEntities(TextWithEntities *text) { + auto &list = text->entities; + for (auto i = list.begin(); i != list.end();) { + const auto type = i->type(); + if (type == EntityType::Blockquote || type == EntityType::Pre) { + i = list.erase(i); + } else { + ++i; + } + } +} + constexpr auto kMaxRichTextNodeLength = 16000; constexpr auto kMaxCommittedFieldLength = 256 * 1024; @@ -3570,8 +3582,54 @@ void Widget::pasteStructuredClipboardData(const ClipboardData &data) { }); } +bool Widget::hasFieldTextSpanSelection() const { + return !_settingField + && !_field->isHidden() + && (_activeSegmentIndex >= 0) + && (_state->activeFieldMode() != State::FieldMode::Raw) + && _field->textCursor().hasSelection(); +} + bool Widget::hasActiveSelection() const { - return hasStructuralSelection() || !_selection.empty(); + return hasStructuralSelection() + || !_selection.empty() + || hasFieldTextSpanSelection(); +} + +TextWithEntities Widget::textSpanForCurrentSelection() { + if (hasStructuralSelection()) { + return {}; + } + if (hasFieldTextSpanSelection()) { + if (const auto context = activeTextInsertContext()) { + return context->selected; + } + return {}; + } + const auto selection = _selection; + const auto sameSegmentSelection = !selection.empty() + && _article + && (selection.from.segment == selection.to.segment) + && _article->segmentIsText(selection.from.segment); + const auto ordinal = sameSegmentSelection + ? editableOrdinalForSegment(selection.from.segment) + : -1; + if (ordinal < 0) { + return {}; + } + const auto selectionFrom = selection.from.offset; + const auto selectionTo = selection.to.offset; + clearTextSelection(); + if (!commitAndActivateTextOrdinal( + ordinal, + selectionFrom, + selectionTo)) { + return {}; + } + if (const auto context = activeTextInsertContext()) { + return context->selected; + } + return {}; } std::shared_ptr Widget::richPageForCurrentSelection() const { @@ -3603,15 +3661,7 @@ std::shared_ptr Widget::richPageForCurrentSelection() const { } return page; } - if (_selection.empty()) { - return nullptr; - } - auto rich = currentSelectionTextForClipboard().rich; - if (rich.text.isEmpty()) { - return nullptr; - } - return std::make_shared( - Iv::SplitTextIntoRichPage(std::move(rich))); + return nullptr; } void Widget::replaceCurrentSelectionWithRichPage( @@ -3678,106 +3728,110 @@ void Widget::replaceCurrentSelectionWithRichPage( return; } } - auto data = ClipboardBlockData(); - data.blocks = page->blocks; - pasteStructuredClipboardData(ClipboardData(std::move(data))); - return; - } - if (auto context = activeTextInsertContext()) { - recordMutationTransaction([&] { - const auto restoreLeaf = _fieldLeaf; - const auto restoreStyleKey = _activeFieldStyleKey; - const auto restoreMode = _fieldMode; - const auto restoreSelection - = captureHistoryViewState().leafSelection; - _pendingOrdinal = -1; - _pendingCursorOffset = 0; - hideInlineField(); - clearInlineFieldEditSession(true); - auto restore = true; - const auto restoreInlineField = gsl::finally([&] { - if (!restore) { - return; - } - if (restoreLeaf && restoreStyleKey) { - if (auto revived = reviveRetainedLeafField( - _historyIndex, - *restoreLeaf, - restoreMode, - *restoreStyleKey)) { - _field = std::move(revived); - _activeFieldStyleKey = restoreStyleKey; - _fieldMode = restoreMode; - _fieldLeaf = *restoreLeaf; - refreshInlineFieldPlaceholder(); - _fieldUndoAvailable = _field->isUndoAvailable(); - _fieldRedoAvailable = _field->isRedoAvailable(); - clearFieldUndoRedoNoopState(); - } - } - if (!_fieldLeaf && restoreSelection) { - const auto ordinal = _state->textOrdinalForLeafPath( - restoreSelection->leaf); - if (ordinal >= 0) { - activateTextOrdinal( - ordinal, - restoreSelection->anchorOffset, - restoreSelection->cursorOffset); - return; - } - } - _field->show(); - syncInlineFieldGeometry(); - updateInlineFieldHeightOverride(); - syncArticleVisibleTopBottom(); - revealActiveInlineField(); - _field->raise(); - _field->setFocusFast(); - notifyToolbarStateChanged(); - }); - const auto applied = _state->replaceActiveTextSelectionWithRichPage( - page, - *context); - if (applied.result != ApplyResult::Changed) { - showLastLimitToast(); - return MutationTransactionResult{ - .committed = ApplyResult::Unchanged, - }; - } - restore = false; - refreshPreparedContent(); - auto restored = false; - if (applied.destinationLeaf) { - const auto ordinal = _state->textOrdinalForLeafPath( - *applied.destinationLeaf); - if (ordinal >= 0) { - activateTextOrdinal( - ordinal, - applied.selectionFrom, - applied.selectionTo); - restored = true; - } - } - if (!restored) { - const auto ordinal = _state->activeTextOrdinal(); - if (ordinal >= 0 && ordinal < _state->textNodeCount()) { - activateTextOrdinal(ordinal, 0); - } else { - activateInitialNode(); - } - } - return MutationTransactionResult{ - .committed = ApplyResult::Unchanged, - .changed = true, - }; - }); - return; } auto data = ClipboardBlockData(); data.blocks = page->blocks; pasteStructuredClipboardData(ClipboardData(std::move(data))); } +void Widget::replaceCurrentSelectionWithText(TextWithEntities text) { + RemoveBlockLevelEntities(&text); + if (text.text.isEmpty()) { + return; + } + auto context = activeTextInsertContext(); + if (!context || context->selected.text.isEmpty()) { + return; + } + recordMutationTransaction([&] { + const auto restoreLeaf = _fieldLeaf; + const auto restoreStyleKey = _activeFieldStyleKey; + const auto restoreMode = _fieldMode; + const auto restoreSelection + = captureHistoryViewState().leafSelection; + _pendingOrdinal = -1; + _pendingCursorOffset = 0; + hideInlineField(); + clearInlineFieldEditSession(true); + auto restore = true; + const auto restoreInlineField = gsl::finally([&] { + if (!restore) { + return; + } + if (restoreLeaf && restoreStyleKey) { + if (auto revived = reviveRetainedLeafField( + _historyIndex, + *restoreLeaf, + restoreMode, + *restoreStyleKey)) { + _field = std::move(revived); + _activeFieldStyleKey = restoreStyleKey; + _fieldMode = restoreMode; + _fieldLeaf = *restoreLeaf; + refreshInlineFieldPlaceholder(); + _fieldUndoAvailable = _field->isUndoAvailable(); + _fieldRedoAvailable = _field->isRedoAvailable(); + clearFieldUndoRedoNoopState(); + } + } + if (!_fieldLeaf && restoreSelection) { + const auto ordinal = _state->textOrdinalForLeafPath( + restoreSelection->leaf); + if (ordinal >= 0) { + activateTextOrdinal( + ordinal, + restoreSelection->anchorOffset, + restoreSelection->cursorOffset); + return; + } + } + _field->show(); + syncInlineFieldGeometry(); + updateInlineFieldHeightOverride(); + syncArticleVisibleTopBottom(); + revealActiveInlineField(); + _field->raise(); + _field->setFocusFast(); + notifyToolbarStateChanged(); + }); + const auto applied = _state->replaceActiveTextSelectionWithText( + std::move(text), + *context); + if (applied.result != ApplyResult::Changed) { + showLastLimitToast(); + return MutationTransactionResult{ + .committed = ApplyResult::Unchanged, + }; + } + restore = false; + refreshPreparedContent(); + auto restored = false; + if (applied.destinationLeaf) { + const auto ordinal = _state->textOrdinalForLeafPath( + *applied.destinationLeaf); + if (ordinal >= 0) { + activateTextOrdinal( + ordinal, + applied.selectionFrom, + applied.selectionTo); + restored = true; + } + } + if (!restored) { + const auto ordinal = _state->activeTextOrdinal(); + if (ordinal >= 0 && ordinal < _state->textNodeCount()) { + activateTextOrdinal(ordinal, 0); + } else { + activateInitialNode(); + } + } + return MutationTransactionResult{ + .committed = ApplyResult::Unchanged, + .changed = true, + }; + }); +} + bool Widget::handleClipboardKey(QKeyEvent *e) { if (e == QKeySequence::Copy) { if (_selection.empty() && !hasStructuralSelection()) { diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_widget.h b/Telegram/SourceFiles/iv/editor/iv_editor_widget.h index 9e8957b255..90dc9369a9 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_widget.h +++ b/Telegram/SourceFiles/iv/editor/iv_editor_widget.h @@ -126,6 +126,8 @@ public: richPageForCurrentSelection() const; void replaceCurrentSelectionWithRichPage( std::shared_ptr page); + [[nodiscard]] TextWithEntities textSpanForCurrentSelection(); + void replaceCurrentSelectionWithText(TextWithEntities text); void pastePreparedBlock( RichPage::Block block, PreparedMediaPasteTarget target); @@ -475,6 +477,7 @@ private: }; [[nodiscard]] std::optional activeTextInsertContext() const; + [[nodiscard]] bool hasFieldTextSpanSelection() const; [[nodiscard]] PreparedMediaPasteTarget preparedMediaPasteTarget() const; struct PreparedMediaPasteActivation { bool resolved = false;