Edit text-span selections with the plain AI Editor

This commit is contained in:
John Preston
2026-07-02 21:26:22 +04:00
parent 2867aece81
commit 4453b0ee3d
6 changed files with 195 additions and 152 deletions
@@ -1227,7 +1227,7 @@ ComposeAiContent::ComposeAiContent(
, _authorLabel(Ui::CreateChild<Ui::FlatLabel>(
this,
st::aiComposeAuthorLabel)) {
if (_richSource && _tones.empty()) {
if (_tones.empty()) {
_session->data().aiComposeTones().refresh();
}
_preview->setResizeCallback([=] { refreshLayout(); });
@@ -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, {
@@ -7588,23 +7588,21 @@ State::ActiveTextBlockActionResult State::applyActiveTextBlockAction(
});
}
State::ActiveTextBlockActionResult
State::replaceActiveTextSelectionWithRichPage(
std::shared_ptr<const RichPage> 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<ActiveTextBlockActionResult>{
.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<ActiveTextBlockActionResult>{
.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,
},
};
});
@@ -413,8 +413,8 @@ public:
InsertAction action,
ActiveTextInsertContext context);
[[nodiscard]] ActiveTextBlockActionResult
replaceActiveTextSelectionWithRichPage(
std::shared_ptr<const RichPage> page,
replaceActiveTextSelectionWithText(
TextWithEntities text,
ActiveTextInsertContext context);
[[nodiscard]] bool insertPreparedBlockAfterActive(RichPage::Block block);
[[nodiscard]] bool insertPreparedBlocksAfterActive(
@@ -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<const RichPage> Widget::richPageForCurrentSelection() const {
@@ -3603,15 +3661,7 @@ std::shared_ptr<const RichPage> Widget::richPageForCurrentSelection() const {
}
return page;
}
if (_selection.empty()) {
return nullptr;
}
auto rich = currentSelectionTextForClipboard().rich;
if (rich.text.isEmpty()) {
return nullptr;
}
return std::make_shared<RichPage>(
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()) {
@@ -126,6 +126,8 @@ public:
richPageForCurrentSelection() const;
void replaceCurrentSelectionWithRichPage(
std::shared_ptr<const RichPage> page);
[[nodiscard]] TextWithEntities textSpanForCurrentSelection();
void replaceCurrentSelectionWithText(TextWithEntities text);
void pastePreparedBlock(
RichPage::Block block,
PreparedMediaPasteTarget target);
@@ -475,6 +477,7 @@ private:
};
[[nodiscard]] std::optional<State::ActiveTextInsertContext>
activeTextInsertContext() const;
[[nodiscard]] bool hasFieldTextSpanSelection() const;
[[nodiscard]] PreparedMediaPasteTarget preparedMediaPasteTarget() const;
struct PreparedMediaPasteActivation {
bool resolved = false;