diff --git a/Telegram/Resources/icons/menu/rich/blockquote.svg b/Telegram/Resources/icons/menu/rich/blockquote.svg new file mode 100644 index 0000000000..7b99a19145 --- /dev/null +++ b/Telegram/Resources/icons/menu/rich/blockquote.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/icons/menu/rich/bullet_list.svg b/Telegram/Resources/icons/menu/rich/bullet_list.svg new file mode 100644 index 0000000000..36fb9d099c --- /dev/null +++ b/Telegram/Resources/icons/menu/rich/bullet_list.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/icons/menu/rich/details.svg b/Telegram/Resources/icons/menu/rich/details.svg new file mode 100644 index 0000000000..2b5bc0c80c --- /dev/null +++ b/Telegram/Resources/icons/menu/rich/details.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/icons/menu/rich/divider.svg b/Telegram/Resources/icons/menu/rich/divider.svg new file mode 100644 index 0000000000..7e392c330c --- /dev/null +++ b/Telegram/Resources/icons/menu/rich/divider.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/icons/menu/rich/heading.svg b/Telegram/Resources/icons/menu/rich/heading.svg new file mode 100644 index 0000000000..a8f848b4f5 --- /dev/null +++ b/Telegram/Resources/icons/menu/rich/heading.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/icons/menu/rich/math.svg b/Telegram/Resources/icons/menu/rich/math.svg new file mode 100644 index 0000000000..064f32eed3 --- /dev/null +++ b/Telegram/Resources/icons/menu/rich/math.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/icons/menu/rich/ordered_list.svg b/Telegram/Resources/icons/menu/rich/ordered_list.svg new file mode 100644 index 0000000000..5f9d9aa880 --- /dev/null +++ b/Telegram/Resources/icons/menu/rich/ordered_list.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/icons/menu/rich/pullquote.svg b/Telegram/Resources/icons/menu/rich/pullquote.svg new file mode 100644 index 0000000000..e6ee26c9cc --- /dev/null +++ b/Telegram/Resources/icons/menu/rich/pullquote.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/icons/menu/rich/table.svg b/Telegram/Resources/icons/menu/rich/table.svg new file mode 100644 index 0000000000..ec34938fd8 --- /dev/null +++ b/Telegram/Resources/icons/menu/rich/table.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/icons/menu/rich/task_list.svg b/Telegram/Resources/icons/menu/rich/task_list.svg new file mode 100644 index 0000000000..776fef8606 --- /dev/null +++ b/Telegram/Resources/icons/menu/rich/task_list.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index c4e2b4fb51..828578f798 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -7166,8 +7166,24 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_polls_stop_sure" = "Stop"; "lng_article_menu_item" = "Article"; "lng_article_editor_title" = "Article"; +"lng_article_insert_heading" = "Heading"; "lng_article_insert_heading1" = "Heading 1"; +"lng_article_insert_heading2" = "Heading 2"; +"lng_article_insert_heading3" = "Heading 3"; +"lng_article_insert_heading4" = "Heading 4"; +"lng_article_insert_heading5" = "Heading 5"; +"lng_article_insert_heading6" = "Heading 6"; "lng_article_insert_blockquote" = "Blockquote"; +"lng_article_insert_math" = "Math"; +"lng_article_insert_divider" = "Divider"; +"lng_article_insert_ordered_list" = "Ordered List"; +"lng_article_insert_bullet_list" = "Bullet List"; +"lng_article_insert_task_list" = "Task List"; +"lng_article_insert_pullquote" = "Pullquote"; +"lng_article_insert_media" = "Attach Media"; +"lng_article_insert_details" = "Details"; +"lng_article_insert_table" = "Table"; +"lng_article_insert_map" = "Map"; "lng_polls_menu_item" = "Poll"; "lng_polls_create" = "Create poll"; "lng_polls_create_title" = "New poll"; diff --git a/Telegram/SourceFiles/iv/iv.style b/Telegram/SourceFiles/iv/iv.style index fe88bac174..69c48d8152 100644 --- a/Telegram/SourceFiles/iv/iv.style +++ b/Telegram/SourceFiles/iv/iv.style @@ -79,11 +79,30 @@ ivWrongLayoutLink: LinkButton(defaultLinkButton) { overColor: windowSubTextFg; padding: margins(5px, 5px, 5px, 5px); } -ivEditorBodyPadding: margins(0px, 0px, 0px, 0px); -ivEditorToolbarPadding: margins(12px, 24px, 12px, 24px); -ivEditorToolbarButtonsSkip: 8px; -ivEditorToolbarButtonMinWidth: 132px; -ivEditorToolbarButtonHeight: 42px; +ivEditorBodyPadding: margins(0px, 8px, 0px, 8px); +ivEditorToolbarPadding: margins(8px, defaultVerticalListSkip, 8px, defaultVerticalListSkip); +ivEditorToolbarButtonSize: 36px; +ivEditorToolbarButton: IconButton(defaultIconButton) { + width: ivEditorToolbarButtonSize; + height: ivEditorToolbarButtonSize; + iconPosition: point(-1px, -1px); + rippleAreaPosition: point(0px, 0px); + rippleAreaSize: ivEditorToolbarButtonSize; + ripple: defaultRippleAnimationBgOver; +} +ivEditorToolbarButtonSkip: 6px; +ivEditorToolbarRowSkip: 0px; +ivEditorToolbarHeadingIcon: icon {{ "menu/rich/heading-24x24", menuIconColor }}; +ivEditorToolbarBlockquoteIcon: icon {{ "menu/rich/blockquote-24x24", menuIconColor }}; +ivEditorToolbarMathIcon: icon {{ "menu/rich/math-24x24", menuIconColor }}; +ivEditorToolbarDividerIcon: icon {{ "menu/rich/divider-24x24", menuIconColor }}; +ivEditorToolbarOrderedListIcon: icon {{ "menu/rich/ordered_list-24x24", menuIconColor }}; +ivEditorToolbarBulletListIcon: icon {{ "menu/rich/bullet_list-24x24", menuIconColor }}; +ivEditorToolbarTaskListIcon: icon {{ "menu/rich/task_list-24x24", menuIconColor }}; +ivEditorToolbarPullquoteIcon: icon {{ "menu/rich/pullquote-24x24", menuIconColor }}; +ivEditorToolbarAttachIcon: icon {{ "chat/input_attach", menuIconColor }}; +ivEditorToolbarDetailsIcon: icon {{ "menu/rich/details-24x24", menuIconColor }}; +ivEditorToolbarTableIcon: icon {{ "menu/rich/table-24x24", menuIconColor }}; ivEditorInlineFieldMargins: margins(4px, 0px, 6px, 0px); ivEditorMinHeight: boxWidth; ivEditorInputField: InputField(defaultInputField) { diff --git a/Telegram/SourceFiles/iv/iv_editor_box.cpp b/Telegram/SourceFiles/iv/iv_editor_box.cpp index 46496527da..d221c524e8 100644 --- a/Telegram/SourceFiles/iv/iv_editor_box.cpp +++ b/Telegram/SourceFiles/iv/iv_editor_box.cpp @@ -7,32 +7,412 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "iv/iv_editor_box.h" -#include "data/data_msg_id.h" +#include +#include +#include +#include +#include + +#include "base/flat_map.h" +#include "base/const_string.h" +#include "base/unique_qptr.h" +#include "base/weak_ptr.h" +#include "core/file_utilities.h" +#include "core/mime_type.h" +#include "core/shortcuts.h" #include "ui/image/image_location.h" +#include "data/data_location.h" #include "data/data_types.h" #include "iv/iv_editor_state.h" #include "iv/iv_editor_widget.h" #include "lang/lang_keys.h" +#include "main/main_app_config.h" +#include "main/main_session.h" +#include "settings.h" +#include "ui/emoji_config.h" +#include "storage/storage_account.h" +#include "ui/controls/location_picker.h" #include "ui/layers/generic_box.h" +#include "ui/rect_part.h" +#include "ui/ui_utility.h" #include "ui/widgets/buttons.h" -#include "ui/wrap/vertical_layout.h" -#include "styles/style_layers.h" -#include "styles/style_settings.h" - -#include +#include "ui/widgets/popup_menu.h" +#include "ui/widgets/tooltip.h" #include "window/window_session_controller.h" +#include "mainwindow.h" +#include "mainwidget.h" +#include #include +#include +#include + +#include "styles/style_iv.h" +#include "styles/style_layers.h" +#include "styles/style_menu_icons.h" +#include "styles/style_widgets.h" namespace Iv::Editor { namespace { +struct ToolbarButton { + object_ptr widget; + Fn()> tooltipFactory; +}; + +class Toolbar final : public Ui::RpWidget { +public: + Toolbar( + QWidget *parent, + not_null controller, + not_null peer, + not_null editor, + QPointer tooltipParent); + + int resizeGetHeight(int width) override; + +protected: + bool eventFilter(QObject *object, QEvent *event) override; + +private: + not_null addButton( + QString label, + Fn()> tooltipFactory, + const style::icon *icon, + Fn callback); + void addInsertButtons(); + void showHeadingMenu(not_null button); + void chooseMedia(); + void applyMediaResult(FileDialog::OpenResult &&result); + void chooseMap(); + void showTooltip(not_null button); + void hideTooltip(); + void updateTooltipGeometry(); + [[nodiscard]] ToolbarButton *buttonData(not_null button); + + const not_null _controller; + const not_null _peer; + const QPointer _editor; + const QPointer _tooltipParent; + const Ui::LocationPickerConfig _mapsConfig; + std::vector _buttons; + base::unique_qptr _tooltip; + base::unique_qptr _menu; + Ui::IconButton *_hovered = nullptr; + +}; + +[[nodiscard]] Ui::LocationPickerConfig ResolveMapsConfig( + not_null session) { + const auto &appConfig = session->appConfig(); + auto map = appConfig.get>( + u"tdesktop_config_map"_q, + base::flat_map()); + return { + .mapsToken = map[u"maps"_q], + .geoToken = map[u"geo"_q], + }; +} + +[[nodiscard]] QString HeadingLabel(int level) { + switch (level) { + case 1: return tr::lng_article_insert_heading1(tr::now); + case 2: return tr::lng_article_insert_heading2(tr::now); + case 3: return tr::lng_article_insert_heading3(tr::now); + case 4: return tr::lng_article_insert_heading4(tr::now); + case 5: return tr::lng_article_insert_heading5(tr::now); + case 6: return tr::lng_article_insert_heading6(tr::now); + } + return tr::lng_article_insert_heading1(tr::now); +} + +[[nodiscard]] std::optional MediaTypeForPath( + const QString &path) { + const auto mime = Core::MimeTypeForFile(QFileInfo(path)).name(); + if (Core::FileIsImage(path, mime)) { + return State::InsertBlockType::Photo; + } else if (mime.startsWith(u"video/"_q)) { + return State::InsertBlockType::Video; + } else if (mime.startsWith(u"audio/"_q)) { + return State::InsertBlockType::Audio; + } + return std::nullopt; +} + +Toolbar::Toolbar( + QWidget *parent, + not_null controller, + not_null peer, + not_null editor, + QPointer tooltipParent) +: Ui::RpWidget(parent) +, _controller(controller) +, _peer(peer) +, _editor(editor.get()) +, _tooltipParent(std::move(tooltipParent)) +, _mapsConfig(ResolveMapsConfig(&controller->session())) { + setMouseTracking(true); + addInsertButtons(); +} + +not_null Toolbar::addButton( + QString label, + Fn()> tooltipFactory, + const style::icon *icon, + Fn callback) { + auto button = object_ptr(this, st::ivEditorToolbarButton); + const auto raw = button.data(); + raw->setAccessibleName(label); + raw->setIconOverride(icon, icon); + raw->setClickedCallback([=, callback = std::move(callback)] { + hideTooltip(); + callback(); + }); + raw->installEventFilter(this); + _buttons.push_back({ + std::move(button), + std::move(tooltipFactory), + }); + return raw; +} + +void Toolbar::addInsertButtons() { + const auto insert = [=](State::InsertAction action) { + if (_editor) { + _editor->insertBlock(action); + } + }; + const auto insertType = [=](State::InsertBlockType type) { + insert({ .type = type }); + }; + + const auto heading = addButton( + tr::lng_article_insert_heading(tr::now), + [] { return tr::lng_article_insert_heading(tr::marked); }, + &st::ivEditorToolbarHeadingIcon, + [] {}); + heading->setClickedCallback([=] { + hideTooltip(); + showHeadingMenu(heading); + }); + addButton( + tr::lng_article_insert_blockquote(tr::now), + [] { return tr::lng_article_insert_blockquote(tr::marked); }, + &st::ivEditorToolbarBlockquoteIcon, + [=] { insertType(State::InsertBlockType::Blockquote); }); + addButton( + tr::lng_article_insert_math(tr::now), + [] { return tr::lng_article_insert_math(tr::marked); }, + &st::ivEditorToolbarMathIcon, + [=] { insertType(State::InsertBlockType::Math); }); + addButton( + tr::lng_article_insert_divider(tr::now), + [] { return tr::lng_article_insert_divider(tr::marked); }, + &st::ivEditorToolbarDividerIcon, + [=] { insertType(State::InsertBlockType::Divider); }); + addButton( + tr::lng_article_insert_ordered_list(tr::now), + [] { return tr::lng_article_insert_ordered_list(tr::marked); }, + &st::ivEditorToolbarOrderedListIcon, + [=] { insertType(State::InsertBlockType::OrderedList); }); + addButton( + tr::lng_article_insert_bullet_list(tr::now), + [] { return tr::lng_article_insert_bullet_list(tr::marked); }, + &st::ivEditorToolbarBulletListIcon, + [=] { insertType(State::InsertBlockType::BulletList); }); + addButton( + tr::lng_article_insert_task_list(tr::now), + [] { return tr::lng_article_insert_task_list(tr::marked); }, + &st::ivEditorToolbarTaskListIcon, + [=] { insertType(State::InsertBlockType::TaskList); }); + addButton( + tr::lng_article_insert_pullquote(tr::now), + [] { return tr::lng_article_insert_pullquote(tr::marked); }, + &st::ivEditorToolbarPullquoteIcon, + [=] { insertType(State::InsertBlockType::Pullquote); }); + addButton( + tr::lng_article_insert_media(tr::now), + [] { return tr::lng_article_insert_media(tr::marked); }, + &st::ivEditorToolbarAttachIcon, + [=] { chooseMedia(); }); + addButton( + tr::lng_article_insert_details(tr::now), + [] { return tr::lng_article_insert_details(tr::marked); }, + &st::ivEditorToolbarDetailsIcon, + [=] { insertType(State::InsertBlockType::Details); }); + addButton( + tr::lng_article_insert_table(tr::now), + [] { return tr::lng_article_insert_table(tr::marked); }, + &st::ivEditorToolbarTableIcon, + [=] { insertType(State::InsertBlockType::Table); }); + + if (Ui::LocationPicker::Available(_mapsConfig)) { + addButton( + tr::lng_article_insert_map(tr::now), + [] { return tr::lng_article_insert_map(tr::marked); }, + &st::menuIconAddress, + [=] { chooseMap(); }); + } +} + +void Toolbar::showHeadingMenu(not_null button) { + _menu = base::make_unique_q(this); + for (const auto level : std::array{ 1, 2, 3, 4, 5, 6 }) { + _menu->addAction( + HeadingLabel(level), + [=] { + if (_editor) { + _editor->insertBlock({ + .type = State::InsertBlockType::Heading, + .headingLevel = level, + }); + } + }); + } + _menu->popup(button->mapToGlobal(QPoint(0, button->height()))); +} + +void Toolbar::chooseMedia() { + const auto weak = QPointer(this); + FileDialog::GetOpenPath( + QPointer(this), + tr::lng_article_insert_media(tr::now), + FileDialog::AllFilesFilter(), + [=](FileDialog::OpenResult &&result) { + if (weak) { + weak->applyMediaResult(std::move(result)); + } + }); +} + +void Toolbar::applyMediaResult(FileDialog::OpenResult &&result) { + if (result.paths.isEmpty()) { + return; + } + const auto type = MediaTypeForPath(result.paths.front()); + if (!type) { + _controller->showToast(tr::lng_edit_media_invalid_file(tr::now)); + return; + } + if (_editor) { + _editor->insertMedia(*type); + } +} + +void Toolbar::chooseMap() { + const auto weak = QPointer(this); + const auto session = &_controller->session(); + Ui::LocationPicker::Show({ + .parent = _controller->widget().get(), + .config = _mapsConfig, + .chooseLabel = tr::lng_maps_point_send(), + .recipient = _peer, + .session = session, + .callback = [=](Data::InputVenue venue) { + if (weak && weak->_editor) { + weak->_editor->insertMap(venue.lat, venue.lon); + } + }, + .quit = [] { Shortcuts::Launch(Shortcuts::Command::Quit); }, + .storageId = session->local().resolveStorageIdBots(), + .closeRequests = _controller->content()->death(), + }); +} + +int Toolbar::resizeGetHeight(int width) { + const auto padding = st::ivEditorToolbarPadding; + const auto buttonWidth = st::ivEditorToolbarButton.width; + const auto buttonHeight = st::ivEditorToolbarButton.height; + const auto right = width - padding.right(); + auto left = padding.left(); + auto top = padding.top(); + for (const auto &button : _buttons) { + if (left > padding.left() && left + buttonWidth > right) { + left = padding.left(); + top += buttonHeight + st::ivEditorToolbarRowSkip; + } + button.widget->moveToLeft(left, top, width); + left += buttonWidth + st::ivEditorToolbarButtonSkip; + } + updateTooltipGeometry(); + return top + buttonHeight + padding.bottom(); +} + +bool Toolbar::eventFilter(QObject *object, QEvent *event) { + for (const auto &data : _buttons) { + if (data.widget.data() != object) { + continue; + } + const auto button = data.widget.data(); + if (event->type() == QEvent::Enter) { + showTooltip(not_null(button)); + } else if (event->type() == QEvent::Leave && _hovered == button) { + hideTooltip(); + } + break; + } + return Ui::RpWidget::eventFilter(object, event); +} + +void Toolbar::showTooltip(not_null button) { + hideTooltip(); + const auto data = buttonData(button); + if (!data) { + return; + } + _hovered = button; + const auto tooltipParent = _tooltipParent + ? _tooltipParent.data() + : (parentWidget() ? parentWidget() : this); + _tooltip.reset(Ui::CreateChild( + tooltipParent, + Ui::MakeNiceTooltipLabel( + tooltipParent, + data->tooltipFactory(), + st::boxWideWidth, + st::defaultImportantTooltipLabel), + st::defaultImportantTooltip)); + _tooltip->setAttribute(Qt::WA_TransparentForMouseEvents); + _tooltip->toggleFast(false); + updateTooltipGeometry(); + _tooltip->raise(); + _tooltip->toggleAnimated(true); +} + +void Toolbar::hideTooltip() { + _hovered = nullptr; + if (_tooltip) { + _tooltip->toggleFast(false); + _tooltip = nullptr; + } +} + +void Toolbar::updateTooltipGeometry() { + if (!_tooltip || !_hovered) { + return; + } + const auto tooltipParent = _tooltip->parentWidget(); + const auto geometry = Ui::MapFrom( + tooltipParent, + _hovered, + _hovered->rect()); + _tooltip->pointAt(geometry, RectPart::Top | RectPart::Center); +} + +ToolbarButton *Toolbar::buttonData(not_null button) { + for (auto &data : _buttons) { + if (data.widget.data() == button.get()) { + return &data; + } + } + return nullptr; +} + void SetupBox( not_null box, not_null controller, not_null peer) { - box->setTitle(tr::lng_article_editor_title()); box->setWidth(st::boxWideWidth); box->setNoContentMargin(true); @@ -43,21 +423,13 @@ void SetupBox( peer, state), style::margins()); - const auto toolbar = box->setPinnedToTopContent( - object_ptr(box)); - - toolbar->add(object_ptr( - toolbar, - tr::lng_article_insert_heading1(), - st::settingsButtonNoIcon))->setClickedCallback([=] { - editor->insertHeading1(); - }); - toolbar->add(object_ptr( - toolbar, - tr::lng_article_insert_blockquote(), - st::settingsButtonNoIcon))->setClickedCallback([=] { - editor->insertBlockquote(); - }); + const auto tooltipParent = box->getDelegate()->outerContainer(); + box->setPinnedToTopContent(object_ptr( + box, + controller, + peer, + editor, + tooltipParent)); box->setFocusCallback([=] { editor->activateInitialNode(); diff --git a/Telegram/SourceFiles/iv/iv_editor_state.cpp b/Telegram/SourceFiles/iv/iv_editor_state.cpp index fb47015fc3..5ff7e19e7a 100644 --- a/Telegram/SourceFiles/iv/iv_editor_state.cpp +++ b/Telegram/SourceFiles/iv/iv_editor_state.cpp @@ -8,21 +8,72 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "iv/iv_editor_state.h" #include +#include namespace Iv::Editor { namespace { using Block = RichPage::Block; -using RichText = RichPage::RichText; +using BlockContainerKind = State::BlockContainerKind; +using BlockContainerPath = State::BlockContainerPath; using BlockKind = RichPage::BlockKind; -using TextSlot = State::TextSlot; +using BlockPath = State::BlockPath; +using FieldMode = State::FieldMode; +using InsertBlockType = State::InsertBlockType; +using InsertionAnchor = State::InsertionAnchor; +using LeafKind = State::LeafKind; +using LeafPath = State::LeafPath; +using ListItem = RichPage::ListItem; +using ListKind = RichPage::ListKind; +using RemovalKind = State::RemovalKind; +using RemovalTarget = State::RemovalTarget; +using RichText = RichPage::RichText; +using TableCell = RichPage::TableCell; +using TaskState = RichPage::TaskState; using TextNodeDescriptor = State::TextNodeDescriptor; +[[nodiscard]] BlockContainerPath BlockChildrenContainer(BlockPath path) { + auto result = std::move(path.container); + result.steps.push_back({ + .kind = BlockContainerKind::BlockChildren, + .blockIndex = path.index, + }); + return result; +} + +[[nodiscard]] BlockContainerPath ListItemChildrenContainer( + BlockPath path, + int itemIndex) { + auto result = std::move(path.container); + result.steps.push_back({ + .kind = BlockContainerKind::ListItemChildren, + .blockIndex = path.index, + .listItemIndex = itemIndex, + }); + return result; +} + +[[nodiscard]] bool ContainerHasPrefix( + const BlockContainerPath &path, + const BlockContainerPath &prefix) { + if (path.steps.size() < prefix.steps.size()) { + return false; + } + return std::equal( + prefix.steps.begin(), + prefix.steps.end(), + path.steps.begin()); +} + +[[nodiscard]] bool StringIsEmpty(const QString &text) { + return text.trimmed().isEmpty(); +} + } // namespace State::State() : _richPage(std::make_shared()) { - _richPage->blocks.push_back(makeParagraphBlock()); + _richPage->blocks.push_back(MakeParagraphBlock()); rebuild(); } @@ -56,91 +107,403 @@ bool State::setActiveTextByOrdinal(int ordinal) { TextWithEntities State::activeText() const { const auto descriptor = textNode(_activeTextOrdinal); - const auto current = descriptor ? richText(*descriptor) : nullptr; - return current - ? stripEditModeWrapperEntities(current->text) - : TextWithEntities(); + if (!descriptor) { + return TextWithEntities(); + } + if (const auto current = richText(descriptor->leaf)) { + return StripEditModeWrapperEntities(current->text); + } + if (const auto current = rawText(descriptor->leaf)) { + return MakeText(*current); + } + return TextWithEntities(); } void State::applyActiveText(TextWithEntities text) { const auto descriptor = textNode(_activeTextOrdinal); - const auto current = descriptor ? richText(*descriptor) : nullptr; - if (!current) { + if (!descriptor) { return; } - current->text = std::move(text); + if (auto current = richText(descriptor->leaf)) { + current->text = std::move(text); + rebuild(); + return; + } + if (auto current = rawText(descriptor->leaf)) { + *current = std::move(text.text); + rebuild(); + } +} + +FieldMode State::activeFieldMode() const { + const auto descriptor = textNode(_activeTextOrdinal); + return descriptor ? descriptor->mode : FieldMode::Rich; +} + +QString State::activeRawText() const { + const auto descriptor = textNode(_activeTextOrdinal); + if (!descriptor) { + return QString(); + } + if (const auto current = rawText(descriptor->leaf)) { + return *current; + } + if (const auto current = richText(descriptor->leaf)) { + return StripEditModeWrapperEntities(current->text).text; + } + return QString(); +} + +void State::applyActiveRawText(QString text) { + const auto descriptor = textNode(_activeTextOrdinal); + if (!descriptor) { + return; + } + if (auto current = rawText(descriptor->leaf)) { + *current = std::move(text); + rebuild(); + return; + } + if (auto current = richText(descriptor->leaf)) { + current->text = MakeText(std::move(text)); + rebuild(); + } +} + +int State::activeTextLength() const { + return activeRawText().size(); +} + +std::optional State::previousEditableOrdinal() const { + return adjacentEditableOrdinal(false); +} + +std::optional State::nextEditableOrdinal() const { + return adjacentEditableOrdinal(true); +} + +bool State::isActiveTopLevelParagraph() const { + const auto descriptor = textNode(_activeTextOrdinal); + if (!descriptor || !descriptor->leaf.block.container.steps.empty()) { + return false; + } + const auto owner = block(descriptor->leaf.block); + return owner && owner->kind == BlockKind::Paragraph; +} + +bool State::activeOwnerIsEmpty() const { + const auto descriptor = textNode(_activeTextOrdinal); + return descriptor && removalTargetIsEmpty(descriptor->removalTarget); +} + +std::optional State::removeActiveOwnerAndSelectAdjacent(bool forward) { + const auto descriptor = textNode(_activeTextOrdinal); + if (!descriptor || !removalTargetIsEmpty(descriptor->removalTarget)) { + return std::nullopt; + } + const auto target = descriptor->removalTarget; + auto first = _activeTextOrdinal; + auto last = _activeTextOrdinal; + while (first > 0 && _textNodes[first - 1].removalTarget == target) { + --first; + } + while (last + 1 < textNodeCount() + && _textNodes[last + 1].removalTarget == target) { + ++last; + } + auto adjacent = std::optional(); + if (forward) { + for (auto i = last + 1, count = textNodeCount(); i != count; ++i) { + if (!(_textNodes[i].removalTarget == target)) { + adjacent = _textNodes[i].leaf; + break; + } + } + } else { + for (auto i = first; i != 0; --i) { + if (!(_textNodes[i - 1].removalTarget == target)) { + adjacent = _textNodes[i - 1].leaf; + break; + } + } + } + if (!removeTarget(target)) { + return std::nullopt; + } rebuild(); + if (adjacent && (!forward || target.kind == RemovalKind::TableCell)) { + const auto ordinal = textNodeOrdinal(*adjacent); + if (setActiveTextByOrdinal(ordinal)) { + return _activeTextOrdinal; + } + } + if (!textNodeCount()) { + return std::nullopt; + } + const auto fallback = forward + ? std::min(first, textNodeCount() - 1) + : std::max(std::min(first - 1, textNodeCount() - 1), 0); + if (!setActiveTextByOrdinal(fallback)) { + ensureActiveTextOrdinal(); + } + return _activeTextOrdinal; } int State::ensureTrailingParagraphActive() { if (_richPage->blocks.empty() || _richPage->blocks.back().kind != BlockKind::Paragraph) { - _richPage->blocks.push_back(makeParagraphBlock()); + _richPage->blocks.push_back(MakeParagraphBlock()); } - const auto path = std::vector{ int(_richPage->blocks.size()) - 1 }; + const auto path = BlockPath{ + .container = BlockContainerPath(), + .index = int(_richPage->blocks.size()) - 1, + }; rebuild(); - if (!setActiveTextByOrdinal(textNodeOrdinal(path, TextSlot::Text))) { + const auto ordinal = textNodeOrdinal({ + .kind = LeafKind::BlockText, + .block = path, + }); + if (!setActiveTextByOrdinal(ordinal)) { ensureActiveTextOrdinal(); } return _activeTextOrdinal; } void State::insertHeading1AfterActive() { - insertBlockAfterActive(makeHeading1Block(), TextSlot::Text); + insertBlockAfterActive({ + .type = InsertBlockType::Heading, + .headingLevel = 1, + }); } void State::insertBlockquoteAfterActive() { - insertBlockAfterActive(makeBlockquoteBlock(), TextSlot::Text); + insertBlockAfterActive({ + .type = InsertBlockType::Blockquote, + }); } -Block *State::blockFromPath(const std::vector &path) { +void State::insertBlockAfterActive(InsertAction action) { + auto anchor = InsertionAnchor{ + .container = BlockContainerPath(), + .blockIndex = int(_richPage->blocks.size()) - 1, + }; + const auto descriptor = textNode(_activeTextOrdinal); + if (descriptor) { + anchor = descriptor->insertionAnchor; + } + auto *blocks = blockContainer(anchor.container); + if (!blocks) { + anchor = { + .container = BlockContainerPath(), + .blockIndex = int(_richPage->blocks.size()) - 1, + }; + blocks = &_richPage->blocks; + } + const auto insertAt = std::clamp( + anchor.blockIndex + 1, + 0, + int(blocks->size())); + blocks->insert(blocks->begin() + insertAt, makeBlock(action)); + rebuild(); + focusInsertedBlock({ + .container = anchor.container, + .index = insertAt, + }); +} + +std::vector *State::blockContainer(const BlockContainerPath &path) { auto *blocks = &_richPage->blocks; - auto *result = static_cast(nullptr); - for (const auto index : path) { - if (index < 0 || index >= int(blocks->size())) { + for (const auto &step : path.steps) { + if (step.blockIndex < 0 || step.blockIndex >= int(blocks->size())) { + return nullptr; + } + auto &parent = (*blocks)[step.blockIndex]; + if (step.kind == BlockContainerKind::BlockChildren) { + blocks = &parent.blocks; + } else if (step.kind == BlockContainerKind::ListItemChildren) { + if (step.listItemIndex < 0 + || step.listItemIndex >= int(parent.listItems.size())) { + return nullptr; + } + blocks = &parent.listItems[step.listItemIndex].blocks; + } else { return nullptr; } - result = &(*blocks)[index]; - blocks = &result->blocks; } - return result; + return blocks; } -const Block *State::blockFromPath(const std::vector &path) const { +const std::vector *State::blockContainer( + const BlockContainerPath &path) const { const auto *blocks = &_richPage->blocks; - auto *result = static_cast(nullptr); - for (const auto index : path) { - if (index < 0 || index >= int(blocks->size())) { + for (const auto &step : path.steps) { + if (step.blockIndex < 0 || step.blockIndex >= int(blocks->size())) { + return nullptr; + } + const auto &parent = (*blocks)[step.blockIndex]; + if (step.kind == BlockContainerKind::BlockChildren) { + blocks = &parent.blocks; + } else if (step.kind == BlockContainerKind::ListItemChildren) { + if (step.listItemIndex < 0 + || step.listItemIndex >= int(parent.listItems.size())) { + return nullptr; + } + blocks = &parent.listItems[step.listItemIndex].blocks; + } else { return nullptr; } - result = &(*blocks)[index]; - blocks = &result->blocks; } - return result; + return blocks; } -std::vector *State::blockContainerFromPath(const std::vector &path) { - if (path.empty()) { - return &_richPage->blocks; - } - const auto parent = blockFromPath(path); - return parent ? &parent->blocks : nullptr; -} - -RichText *State::richText(const TextNodeDescriptor &descriptor) { - const auto block = blockFromPath(descriptor.blockPath); - if (!block) { +Block *State::block(const BlockPath &path) { + const auto blocks = blockContainer(path.container); + if (!blocks || path.index < 0 || path.index >= int(blocks->size())) { return nullptr; } - return (descriptor.slot == TextSlot::Caption) ? &block->caption : &block->text; + return &(*blocks)[path.index]; } -const RichText *State::richText(const TextNodeDescriptor &descriptor) const { - const auto block = blockFromPath(descriptor.blockPath); - if (!block) { +const Block *State::block(const BlockPath &path) const { + const auto blocks = blockContainer(path.container); + if (!blocks || path.index < 0 || path.index >= int(blocks->size())) { return nullptr; } - return (descriptor.slot == TextSlot::Caption) ? &block->caption : &block->text; + return &(*blocks)[path.index]; +} + +ListItem *State::listItem(const BlockPath &blockPath, int itemIndex) { + const auto list = block(blockPath); + if (!list + || itemIndex < 0 + || itemIndex >= int(list->listItems.size())) { + return nullptr; + } + return &list->listItems[itemIndex]; +} + +const ListItem *State::listItem( + const BlockPath &blockPath, + int itemIndex) const { + const auto list = block(blockPath); + if (!list + || itemIndex < 0 + || itemIndex >= int(list->listItems.size())) { + return nullptr; + } + return &list->listItems[itemIndex]; +} + +TableCell *State::tableCell( + const BlockPath &blockPath, + int rowIndex, + int cellIndex) { + const auto table = block(blockPath); + if (!table + || rowIndex < 0 + || rowIndex >= int(table->tableRows.size())) { + return nullptr; + } + auto &row = table->tableRows[rowIndex]; + if (cellIndex < 0 || cellIndex >= int(row.cells.size())) { + return nullptr; + } + return &row.cells[cellIndex]; +} + +const TableCell *State::tableCell( + const BlockPath &blockPath, + int rowIndex, + int cellIndex) const { + const auto table = block(blockPath); + if (!table + || rowIndex < 0 + || rowIndex >= int(table->tableRows.size())) { + return nullptr; + } + const auto &row = table->tableRows[rowIndex]; + if (cellIndex < 0 || cellIndex >= int(row.cells.size())) { + return nullptr; + } + return &row.cells[cellIndex]; +} + +RichText *State::richText(const LeafPath &path) { + switch (path.kind) { + case LeafKind::BlockText: + if (const auto owner = block(path.block)) { + return &owner->text; + } + return nullptr; + case LeafKind::BlockCaption: + if (const auto owner = block(path.block)) { + return &owner->caption; + } + return nullptr; + case LeafKind::ListItemText: + if (const auto item = listItem(path.block, path.listItemIndex)) { + return &item->text; + } + return nullptr; + case LeafKind::TableCellText: + if (const auto cell = tableCell( + path.block, + path.tableRowIndex, + path.tableCellIndex)) { + return &cell->text; + } + return nullptr; + case LeafKind::MathFormula: + return nullptr; + } + return nullptr; +} + +const RichText *State::richText(const LeafPath &path) const { + switch (path.kind) { + case LeafKind::BlockText: + if (const auto owner = block(path.block)) { + return &owner->text; + } + return nullptr; + case LeafKind::BlockCaption: + if (const auto owner = block(path.block)) { + return &owner->caption; + } + return nullptr; + case LeafKind::ListItemText: + if (const auto item = listItem(path.block, path.listItemIndex)) { + return &item->text; + } + return nullptr; + case LeafKind::TableCellText: + if (const auto cell = tableCell( + path.block, + path.tableRowIndex, + path.tableCellIndex)) { + return &cell->text; + } + return nullptr; + case LeafKind::MathFormula: + return nullptr; + } + return nullptr; +} + +QString *State::rawText(const LeafPath &path) { + if (path.kind != LeafKind::MathFormula) { + return nullptr; + } + const auto owner = block(path.block); + return owner ? &owner->formula : nullptr; +} + +const QString *State::rawText(const LeafPath &path) const { + if (path.kind != LeafKind::MathFormula) { + return nullptr; + } + const auto owner = block(path.block); + return owner ? &owner->formula : nullptr; } const TextNodeDescriptor *State::textNode(int ordinal) const { @@ -149,12 +512,9 @@ const TextNodeDescriptor *State::textNode(int ordinal) const { : nullptr; } -int State::textNodeOrdinal( - const std::vector &blockPath, - TextSlot slot) const { +int State::textNodeOrdinal(const LeafPath &path) const { for (auto i = 0, count = textNodeCount(); i != count; ++i) { - const auto &descriptor = _textNodes[i]; - if (descriptor.slot == slot && descriptor.blockPath == blockPath) { + if (_textNodes[i].leaf == path) { return i; } } @@ -163,6 +523,7 @@ int State::textNodeOrdinal( void State::rebuild() { rebuildTextNodes(); + ensureEditableNodes(); ensureActiveTextOrdinal(); _prepared = Markdown::TryPrepareNativeInstantView({ .richPage = _richPage, @@ -173,34 +534,167 @@ void State::rebuild() { void State::rebuildTextNodes() { _textNodes.clear(); - _textNodes.reserve(_richPage->blocks.size() * 2); - for (auto i = 0, count = int(_richPage->blocks.size()); i != count; ++i) { - const auto path = std::vector{ i }; - const auto &block = _richPage->blocks[i]; + _textNodes.reserve(_richPage->blocks.size() * 3); + rebuildTextNodes(_richPage->blocks, BlockContainerPath()); +} + +void State::rebuildTextNodes( + const std::vector &blocks, + const BlockContainerPath &container) { + for (auto i = 0, count = int(blocks.size()); i != count; ++i) { + const auto path = BlockPath{ + .container = container, + .index = i, + }; + const auto &block = blocks[i]; switch (block.kind) { case BlockKind::Heading: case BlockKind::Paragraph: - _textNodes.push_back({ - .blockPath = path, - .slot = TextSlot::Text, - }); + appendBlockTextNode(path, LeafKind::BlockText); break; case BlockKind::Quote: - _textNodes.push_back({ - .blockPath = path, - .slot = TextSlot::Text, - }); - _textNodes.push_back({ - .blockPath = path, - .slot = TextSlot::Caption, - }); + appendBlockTextNode( + path, + LeafKind::BlockText, + FieldMode::Rich, + InsertionAnchor{ + .container = BlockChildrenContainer(path), + .blockIndex = -1, + }); + appendBlockTextNode( + path, + LeafKind::BlockCaption, + FieldMode::Rich, + InsertionAnchor{ + .container = BlockChildrenContainer(path), + .blockIndex = -1, + }); + rebuildTextNodes(block.blocks, BlockChildrenContainer(path)); + break; + case BlockKind::List: + for (auto j = 0, itemCount = int(block.listItems.size()); + j != itemCount; + ++j) { + const auto &item = block.listItems[j]; + if (!RichTextIsEmpty(item.text) || item.blocks.empty()) { + appendListItemTextNode(path, j); + } + if (!item.blocks.empty()) { + rebuildTextNodes( + item.blocks, + ListItemChildrenContainer(path, j)); + } + } + break; + case BlockKind::Photo: + case BlockKind::Video: + case BlockKind::Audio: + case BlockKind::Map: + appendBlockTextNode(path, LeafKind::BlockCaption); + break; + case BlockKind::Math: + appendBlockTextNode(path, LeafKind::MathFormula, FieldMode::Raw); + break; + case BlockKind::Table: + if (!block.text.text.text.isEmpty()) { + appendBlockTextNode(path, LeafKind::BlockText); + } + for (auto j = 0, rowCount = int(block.tableRows.size()); + j != rowCount; + ++j) { + const auto &row = block.tableRows[j]; + for (auto k = 0, cellCount = int(row.cells.size()); + k != cellCount; + ++k) { + appendTableCellTextNode(path, j, k); + } + } + break; + case BlockKind::Details: + appendBlockTextNode( + path, + LeafKind::BlockText, + FieldMode::Rich, + InsertionAnchor{ + .container = BlockChildrenContainer(path), + .blockIndex = -1, + }); + rebuildTextNodes(block.blocks, BlockChildrenContainer(path)); break; default: + rebuildTextNodes(block.blocks, BlockChildrenContainer(path)); break; } } } +void State::appendBlockTextNode( + const BlockPath &path, + LeafKind kind, + FieldMode mode, + std::optional insertionAnchor) { + _textNodes.push_back({ + .leaf = { + .kind = kind, + .block = path, + }, + .insertionAnchor = insertionAnchor.value_or(InsertionAnchor{ + .container = path.container, + .blockIndex = path.index, + }), + .removalTarget = { + .kind = RemovalKind::Block, + .block = path, + }, + .mode = mode, + }); +} + +void State::appendListItemTextNode(const BlockPath &path, int itemIndex) { + _textNodes.push_back({ + .leaf = { + .kind = LeafKind::ListItemText, + .block = path, + .listItemIndex = itemIndex, + }, + .insertionAnchor = { + .container = ListItemChildrenContainer(path, itemIndex), + .blockIndex = -1, + }, + .removalTarget = { + .kind = RemovalKind::ListItem, + .block = path, + .listItemIndex = itemIndex, + }, + .mode = FieldMode::Rich, + }); +} + +void State::appendTableCellTextNode( + const BlockPath &path, + int rowIndex, + int cellIndex) { + _textNodes.push_back({ + .leaf = { + .kind = LeafKind::TableCellText, + .block = path, + .tableRowIndex = rowIndex, + .tableCellIndex = cellIndex, + }, + .insertionAnchor = { + .container = path.container, + .blockIndex = path.index, + }, + .removalTarget = { + .kind = RemovalKind::TableCell, + .block = path, + .tableRowIndex = rowIndex, + .tableCellIndex = cellIndex, + }, + .mode = FieldMode::Rich, + }); +} + void State::ensureActiveTextOrdinal() { if (_textNodes.empty()) { _activeTextOrdinal = -1; @@ -209,54 +703,365 @@ void State::ensureActiveTextOrdinal() { } } -void State::insertBlockAfterActive(Block block, TextSlot slotToFocus) { - auto parentPath = std::vector(); - auto insertionIndex = int(_richPage->blocks.size()); - if (const auto descriptor = textNode(_activeTextOrdinal)) { - parentPath = descriptor->blockPath; - if (!parentPath.empty()) { - insertionIndex = parentPath.back() + 1; - parentPath.pop_back(); +void State::ensureEditableNodes() { + if (!_textNodes.empty()) { + return; + } + _richPage->blocks.push_back(MakeParagraphBlock()); + rebuildTextNodes(); +} + +void State::focusInsertedBlock(const BlockPath &path) { + for (auto i = 0, count = textNodeCount(); i != count; ++i) { + if (descriptorBelongsToBlock(_textNodes[i], path)) { + if (setActiveTextByOrdinal(i)) { + return; + } } } - auto *blocks = blockContainerFromPath(parentPath); - if (!blocks) { - parentPath.clear(); - blocks = &_richPage->blocks; - insertionIndex = int(blocks->size()); - } else { - insertionIndex = std::clamp(insertionIndex, 0, int(blocks->size())); + ensureActiveTextOrdinal(); +} + +std::optional State::adjacentEditableOrdinal(bool forward) const { + if (_activeTextOrdinal < 0) { + return std::nullopt; } - blocks->insert(blocks->begin() + insertionIndex, std::move(block)); - auto insertedPath = parentPath; - insertedPath.push_back(insertionIndex); - rebuild(); - if (!setActiveTextByOrdinal(textNodeOrdinal(insertedPath, slotToFocus))) { - ensureActiveTextOrdinal(); + const auto ordinal = _activeTextOrdinal + (forward ? 1 : -1); + return (ordinal >= 0 && ordinal < textNodeCount()) + ? std::make_optional(ordinal) + : std::nullopt; +} + +bool State::descriptorBelongsToBlock( + const TextNodeDescriptor &descriptor, + const BlockPath &path) const { + const auto &owner = descriptor.leaf.block; + if (owner == path) { + return true; + } + if (!ContainerHasPrefix(owner.container, path.container)) { + return false; + } + if (owner.container.steps.size() <= path.container.steps.size()) { + return false; + } + const auto &step = owner.container.steps[path.container.steps.size()]; + return step.blockIndex == path.index + && (step.kind == BlockContainerKind::BlockChildren + || step.kind == BlockContainerKind::ListItemChildren); +} + +bool State::removalTargetIsEmpty(const RemovalTarget &target) const { + switch (target.kind) { + case RemovalKind::Block: + if (const auto owner = block(target.block)) { + return BlockIsEmpty(*owner); + } + return false; + case RemovalKind::ListItem: + if (const auto item = listItem(target.block, target.listItemIndex)) { + return ListItemIsEmpty(*item); + } + return false; + case RemovalKind::TableCell: + if (const auto cell = tableCell( + target.block, + target.tableRowIndex, + target.tableCellIndex)) { + return RichTextIsEmpty(cell->text); + } + return false; + } + return false; +} + +bool State::removeTarget(const RemovalTarget &target) { + switch (target.kind) { + case RemovalKind::Block: { + const auto blocks = blockContainer(target.block.container); + if (!blocks + || target.block.index < 0 + || target.block.index >= int(blocks->size())) { + return false; + } + blocks->erase(blocks->begin() + target.block.index); + return true; + } + case RemovalKind::ListItem: { + const auto owner = block(target.block); + if (!owner + || target.listItemIndex < 0 + || target.listItemIndex >= int(owner->listItems.size())) { + return false; + } + owner->listItems.erase(owner->listItems.begin() + target.listItemIndex); + return true; + } + case RemovalKind::TableCell: { + const auto cell = tableCell( + target.block, + target.tableRowIndex, + target.tableCellIndex); + if (!cell) { + return false; + } + cell->text = RichText(); + return true; + } + } + return false; +} + +bool State::anchorIdExists(const QString &id) const { + return anchorIdExists(_richPage->blocks, id); +} + +bool State::anchorIdExists( + const std::vector &blocks, + const QString &id) const { + for (const auto &block : blocks) { + if (block.anchorId == id + || block.text.anchorId == id + || block.caption.anchorId == id) { + return true; + } + if (anchorIdExists(block.blocks, id)) { + return true; + } + for (const auto &item : block.listItems) { + if (item.anchorId == id || item.text.anchorId == id) { + return true; + } + if (anchorIdExists(item.blocks, id)) { + return true; + } + } + for (const auto &row : block.tableRows) { + for (const auto &cell : row.cells) { + if (cell.text.anchorId == id) { + return true; + } + } + } + } + return false; +} + +QString State::nextAnchorId() const { + for (auto i = 1;; ++i) { + auto result = u"anchor-%1"_q.arg(i); + if (!anchorIdExists(result)) { + return result; + } } } -Block State::makeParagraphBlock() { +Block State::makeBlock(InsertAction action) const { + switch (action.type) { + case InsertBlockType::Heading: + return MakeHeadingBlock(action.headingLevel); + case InsertBlockType::Blockquote: + return MakeQuoteBlock(false); + case InsertBlockType::Math: + return MakeMathBlock(); + case InsertBlockType::Footer: + return MakeParagraphBlock(); + case InsertBlockType::Divider: + return MakeDividerBlock(); + case InsertBlockType::Anchor: + return MakeAnchorBlock(nextAnchorId()); + case InsertBlockType::OrderedList: + return MakeListBlock(ListKind::Ordered); + case InsertBlockType::BulletList: + return MakeListBlock(ListKind::Bullet); + case InsertBlockType::TaskList: + return MakeListBlock(ListKind::Bullet, TaskState::Unchecked); + case InsertBlockType::Pullquote: + return MakeQuoteBlock(true); + case InsertBlockType::Photo: + return MakeMediaBlock(BlockKind::Photo); + case InsertBlockType::Video: + return MakeMediaBlock(BlockKind::Video); + case InsertBlockType::Audio: + return MakeMediaBlock(BlockKind::Audio); + case InsertBlockType::Details: + return MakeDetailsBlock(); + case InsertBlockType::Table: + return MakeTableBlock(); + case InsertBlockType::Map: + return MakeMapBlock(action.latitude, action.longitude); + } + return MakeParagraphBlock(); +} + +TextWithEntities State::MakeText(QString text) { + auto result = TextWithEntities(); + result.text = std::move(text); + return result; +} + +Block State::MakeParagraphBlock() { auto block = Block(); block.kind = BlockKind::Paragraph; + block.text.text = MakeText(u"Text"_q); return block; } -Block State::makeHeading1Block() { +Block State::MakeHeadingBlock(int level) { auto block = Block(); block.kind = BlockKind::Heading; - block.headingLevel = 1; + block.headingLevel = std::clamp(level, 1, 6); + block.text.text = MakeText(u"Header"_q); return block; } -Block State::makeBlockquoteBlock() { +Block State::MakeQuoteBlock(bool pullquote) { auto block = Block(); block.kind = BlockKind::Quote; - block.pullquote = false; + block.pullquote = pullquote; + block.text.text = MakeText(u"Text"_q); + block.caption.text = MakeText(u"Caption"_q); return block; } -bool State::stripWrapperEntityInEditMode(EntityType type) { +Block State::MakeMathBlock() { + auto block = Block(); + block.kind = BlockKind::Math; + block.formula = u"x^2 + y^2"_q; + return block; +} + +Block State::MakeDividerBlock() { + auto block = Block(); + block.kind = BlockKind::Divider; + return block; +} + +Block State::MakeAnchorBlock(QString anchorId) { + auto block = Block(); + block.kind = BlockKind::Anchor; + block.anchorId = std::move(anchorId); + return block; +} + +Block State::MakeListBlock(ListKind kind, TaskState taskState) { + auto block = Block(); + block.kind = BlockKind::List; + block.listKind = kind; + block.listItems.reserve(3); + for (auto i = 0; i != 3; ++i) { + auto item = ListItem(); + item.taskState = taskState; + item.text.text = MakeText(u"Text"_q); + block.listItems.push_back(std::move(item)); + } + return block; +} + +Block State::MakeDetailsBlock() { + auto block = Block(); + block.kind = BlockKind::Details; + block.open = true; + block.text.text = MakeText(u"Header"_q); + block.blocks.push_back(MakeParagraphBlock()); + return block; +} + +Block State::MakeTableBlock() { + auto block = Block(); + block.kind = BlockKind::Table; + block.tableRows.reserve(3); + for (auto rowIndex = 0; rowIndex != 3; ++rowIndex) { + auto row = RichPage::TableRow(); + row.cells.reserve(3); + for (auto cellIndex = 0; cellIndex != 3; ++cellIndex) { + auto cell = TableCell(); + cell.header = (rowIndex == 0); + cell.text.text = MakeText(u"Cell"_q); + row.cells.push_back(std::move(cell)); + } + block.tableRows.push_back(std::move(row)); + } + return block; +} + +Block State::MakeMediaBlock(BlockKind kind) { + auto block = Block(); + block.kind = kind; + block.caption.text = MakeText(u"Caption"_q); + return block; +} + +Block State::MakeMapBlock(double latitude, double longitude) { + auto block = Block(); + block.kind = BlockKind::Map; + block.latitude = latitude; + block.longitude = longitude; + block.caption.text = MakeText(u"Caption"_q); + return block; +} + +bool State::RichTextIsEmpty(const RichText &text) { + return StringIsEmpty(text.text.text); +} + +bool State::ListItemIsEmpty(const ListItem &item) { + if (!RichTextIsEmpty(item.text) || !item.anchorId.isEmpty()) { + return false; + } + for (const auto &block : item.blocks) { + if (!BlockIsEmpty(block)) { + return false; + } + } + return true; +} + +bool State::BlockIsEmpty(const Block &block) { + if (!RichTextIsEmpty(block.text) + || !RichTextIsEmpty(block.caption) + || !StringIsEmpty(block.formula) + || !block.anchorId.isEmpty() + || !block.url.isEmpty() + || !block.html.isEmpty() + || !block.author.isEmpty() + || !block.username.isEmpty() + || !block.channelTitle.isEmpty() + || !block.audioTitle.isEmpty() + || !block.audioPerformer.isEmpty() + || !block.audioFileName.isEmpty() + || block.photo + || block.document + || block.peer + || block.photoId + || block.documentId + || block.channelId + || block.accessHash + || block.latitude != 0. + || block.longitude != 0.) { + return false; + } + for (const auto &child : block.blocks) { + if (!BlockIsEmpty(child)) { + return false; + } + } + for (const auto &item : block.listItems) { + if (!ListItemIsEmpty(item)) { + return false; + } + } + for (const auto &row : block.tableRows) { + for (const auto &cell : row.cells) { + if (!RichTextIsEmpty(cell.text)) { + return false; + } + } + } + return true; +} + +bool State::StripWrapperEntityInEditMode(EntityType type) { switch (type) { case EntityType::Url: case EntityType::Email: @@ -272,11 +1077,11 @@ bool State::stripWrapperEntityInEditMode(EntityType type) { } } -TextWithEntities State::stripEditModeWrapperEntities(TextWithEntities text) { +TextWithEntities State::StripEditModeWrapperEntities(TextWithEntities text) { auto filtered = EntitiesInText(); filtered.reserve(text.entities.size()); for (const auto &entity : text.entities) { - if (!stripWrapperEntityInEditMode(entity.type())) { + if (!StripWrapperEntityInEditMode(entity.type())) { filtered.push_back(entity); } } diff --git a/Telegram/SourceFiles/iv/iv_editor_state.h b/Telegram/SourceFiles/iv/iv_editor_state.h index d68904bf41..4b0b765e77 100644 --- a/Telegram/SourceFiles/iv/iv_editor_state.h +++ b/Telegram/SourceFiles/iv/iv_editor_state.h @@ -11,20 +11,146 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "iv/markdown/iv_markdown_prepare.h" #include +#include #include namespace Iv::Editor { class State final { public: - enum class TextSlot : uchar { - Text, - Caption, + enum class FieldMode : uchar { + Rich, + Raw, + }; + + enum class InsertBlockType : uchar { + Heading, + Blockquote, + Math, + Footer, + Divider, + Anchor, + OrderedList, + BulletList, + TaskList, + Pullquote, + Photo, + Video, + Audio, + Details, + Table, + Map, + }; + + struct InsertAction { + InsertBlockType type; + int headingLevel = 1; + double latitude = 0.; + double longitude = 0.; + }; + + enum class BlockContainerKind : uchar { + Root, + BlockChildren, + ListItemChildren, + }; + + struct BlockContainerStep { + BlockContainerKind kind = BlockContainerKind::BlockChildren; + int blockIndex = -1; + int listItemIndex = -1; + + friend inline bool operator==( + const BlockContainerStep &a, + const BlockContainerStep &b) { + return (a.kind == b.kind) + && (a.blockIndex == b.blockIndex) + && (a.listItemIndex == b.listItemIndex); + } + }; + + struct BlockContainerPath { + std::vector steps; + + friend inline bool operator==( + const BlockContainerPath &a, + const BlockContainerPath &b) { + return (a.steps == b.steps); + } + }; + + struct BlockPath { + BlockContainerPath container; + int index = -1; + + friend inline bool operator==( + const BlockPath &a, + const BlockPath &b) { + return (a.container == b.container) + && (a.index == b.index); + } + }; + + enum class LeafKind : uchar { + BlockText, + BlockCaption, + ListItemText, + TableCellText, + MathFormula, + }; + + struct LeafPath { + LeafKind kind = LeafKind::BlockText; + BlockPath block; + int listItemIndex = -1; + int tableRowIndex = -1; + int tableCellIndex = -1; + + friend inline bool operator==( + const LeafPath &a, + const LeafPath &b) { + return (a.kind == b.kind) + && (a.block == b.block) + && (a.listItemIndex == b.listItemIndex) + && (a.tableRowIndex == b.tableRowIndex) + && (a.tableCellIndex == b.tableCellIndex); + } + }; + + struct InsertionAnchor { + BlockContainerPath container; + int blockIndex = -1; + }; + + enum class RemovalKind : uchar { + Block, + ListItem, + TableCell, + }; + + struct RemovalTarget { + RemovalKind kind = RemovalKind::Block; + BlockPath block; + int listItemIndex = -1; + int tableRowIndex = -1; + int tableCellIndex = -1; + + friend inline bool operator==( + const RemovalTarget &a, + const RemovalTarget &b) { + return (a.kind == b.kind) + && (a.block == b.block) + && (a.listItemIndex == b.listItemIndex) + && (a.tableRowIndex == b.tableRowIndex) + && (a.tableCellIndex == b.tableCellIndex); + } }; struct TextNodeDescriptor { - std::vector blockPath; - TextSlot slot = TextSlot::Text; + LeafPath leaf; + InsertionAnchor insertionAnchor; + RemovalTarget removalTarget; + FieldMode mode = FieldMode::Rich; }; State(); @@ -37,41 +163,113 @@ public: [[nodiscard]] bool setActiveTextByOrdinal(int ordinal); [[nodiscard]] TextWithEntities activeText() const; void applyActiveText(TextWithEntities text); + [[nodiscard]] FieldMode activeFieldMode() const; + [[nodiscard]] QString activeRawText() const; + void applyActiveRawText(QString text); + [[nodiscard]] int activeTextLength() const; + [[nodiscard]] std::optional previousEditableOrdinal() const; + [[nodiscard]] std::optional nextEditableOrdinal() const; + [[nodiscard]] bool isActiveTopLevelParagraph() const; + [[nodiscard]] bool activeOwnerIsEmpty() const; + [[nodiscard]] std::optional removeActiveOwnerAndSelectAdjacent( + bool forward); [[nodiscard]] int ensureTrailingParagraphActive(); void insertHeading1AfterActive(); void insertBlockquoteAfterActive(); + void insertBlockAfterActive(InsertAction action); private: - [[nodiscard]] RichPage::Block *blockFromPath(const std::vector &path); - [[nodiscard]] const RichPage::Block *blockFromPath( - const std::vector &path) const; - [[nodiscard]] std::vector *blockContainerFromPath( - const std::vector &path); - [[nodiscard]] RichPage::RichText *richText( - const TextNodeDescriptor &descriptor); + [[nodiscard]] std::vector *blockContainer( + const BlockContainerPath &path); + [[nodiscard]] const std::vector *blockContainer( + const BlockContainerPath &path) const; + [[nodiscard]] RichPage::Block *block(const BlockPath &path); + [[nodiscard]] const RichPage::Block *block(const BlockPath &path) const; + [[nodiscard]] RichPage::ListItem *listItem( + const BlockPath &blockPath, + int itemIndex); + [[nodiscard]] const RichPage::ListItem *listItem( + const BlockPath &blockPath, + int itemIndex) const; + [[nodiscard]] RichPage::TableCell *tableCell( + const BlockPath &blockPath, + int rowIndex, + int cellIndex); + [[nodiscard]] const RichPage::TableCell *tableCell( + const BlockPath &blockPath, + int rowIndex, + int cellIndex) const; + [[nodiscard]] RichPage::RichText *richText(const LeafPath &path); [[nodiscard]] const RichPage::RichText *richText( - const TextNodeDescriptor &descriptor) const; + const LeafPath &path) const; + [[nodiscard]] QString *rawText(const LeafPath &path); + [[nodiscard]] const QString *rawText(const LeafPath &path) const; [[nodiscard]] const TextNodeDescriptor *textNode(int ordinal) const; - [[nodiscard]] int textNodeOrdinal( - const std::vector &blockPath, - TextSlot slot) const; + [[nodiscard]] int textNodeOrdinal(const LeafPath &path) const; void rebuild(); void rebuildTextNodes(); + void rebuildTextNodes( + const std::vector &blocks, + const BlockContainerPath &container); + void appendBlockTextNode( + const BlockPath &path, + LeafKind kind, + FieldMode mode = FieldMode::Rich, + std::optional insertionAnchor = std::nullopt); + void appendListItemTextNode(const BlockPath &path, int itemIndex); + void appendTableCellTextNode( + const BlockPath &path, + int rowIndex, + int cellIndex); void ensureActiveTextOrdinal(); - void insertBlockAfterActive(RichPage::Block block, TextSlot slotToFocus); + void ensureEditableNodes(); + void focusInsertedBlock(const BlockPath &path); + [[nodiscard]] std::optional adjacentEditableOrdinal( + bool forward) const; + [[nodiscard]] bool descriptorBelongsToBlock( + const TextNodeDescriptor &descriptor, + const BlockPath &path) const; + [[nodiscard]] bool removalTargetIsEmpty( + const RemovalTarget &target) const; + [[nodiscard]] bool removeTarget(const RemovalTarget &target); + [[nodiscard]] bool anchorIdExists(const QString &id) const; + [[nodiscard]] bool anchorIdExists( + const std::vector &blocks, + const QString &id) const; + [[nodiscard]] QString nextAnchorId() const; + [[nodiscard]] RichPage::Block makeBlock(InsertAction action) const; - [[nodiscard]] static RichPage::Block makeParagraphBlock(); - [[nodiscard]] static RichPage::Block makeHeading1Block(); - [[nodiscard]] static RichPage::Block makeBlockquoteBlock(); - [[nodiscard]] static bool stripWrapperEntityInEditMode(EntityType type); - [[nodiscard]] static TextWithEntities stripEditModeWrapperEntities( + [[nodiscard]] static TextWithEntities MakeText(QString text); + [[nodiscard]] static RichPage::Block MakeParagraphBlock(); + [[nodiscard]] static RichPage::Block MakeHeadingBlock(int level); + [[nodiscard]] static RichPage::Block MakeQuoteBlock(bool pullquote); + [[nodiscard]] static RichPage::Block MakeMathBlock(); + [[nodiscard]] static RichPage::Block MakeDividerBlock(); + [[nodiscard]] static RichPage::Block MakeAnchorBlock(QString anchorId); + [[nodiscard]] static RichPage::Block MakeListBlock( + RichPage::ListKind kind, + RichPage::TaskState taskState = RichPage::TaskState::None); + [[nodiscard]] static RichPage::Block MakeDetailsBlock(); + [[nodiscard]] static RichPage::Block MakeTableBlock(); + [[nodiscard]] static RichPage::Block MakeMediaBlock( + RichPage::BlockKind kind); + [[nodiscard]] static RichPage::Block MakeMapBlock( + double latitude, + double longitude); + [[nodiscard]] static bool RichTextIsEmpty(const RichPage::RichText &text); + [[nodiscard]] static bool ListItemIsEmpty( + const RichPage::ListItem &item); + [[nodiscard]] static bool BlockIsEmpty(const RichPage::Block &block); + [[nodiscard]] static bool StripWrapperEntityInEditMode(EntityType type); + [[nodiscard]] static TextWithEntities StripEditModeWrapperEntities( TextWithEntities text); std::shared_ptr _richPage; Markdown::MarkdownArticleContent _prepared; std::vector _textNodes; int _activeTextOrdinal = -1; + }; } // namespace Iv::Editor diff --git a/Telegram/SourceFiles/iv/iv_editor_widget.cpp b/Telegram/SourceFiles/iv/iv_editor_widget.cpp index ce9f45333a..a89b0771cd 100644 --- a/Telegram/SourceFiles/iv/iv_editor_widget.cpp +++ b/Telegram/SourceFiles/iv/iv_editor_widget.cpp @@ -23,8 +23,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "styles/style_iv.h" #include +#include #include #include +#include #include #include #include @@ -161,8 +163,8 @@ void Widget::activateInitialNode() { ? _activeOrdinal : _state->activeTextOrdinal(); if (ordinal < 0) { - const auto first = _article->firstTextSegmentIndex(); - const auto fallback = textOrdinalForSegment(first); + const auto first = _article->firstEditableSegmentIndex(); + const auto fallback = editableOrdinalForSegment(first); if (fallback < 0) { return; } @@ -173,7 +175,7 @@ void Widget::activateInitialNode() { } void Widget::activateSegment(int segmentIndex, int cursorOffset) { - const auto ordinal = textOrdinalForSegment(segmentIndex); + const auto ordinal = editableOrdinalForSegment(segmentIndex); if (ordinal < 0) { return; } @@ -184,18 +186,22 @@ void Widget::commitInlineField() { applyFieldTextToState(); } -void Widget::acceptInlineField() { +void Widget::hideInlineFieldAndRefresh() { if (_field->isHidden()) { return; } commitInlineField(); _pendingOrdinal = -1; _pendingCursorOffset = 0; - _field->hide(); + hideInlineField(); _article->clearTextLeafHeightOverride(); refreshPreparedContent(); } +void Widget::acceptInlineField() { + hideInlineFieldAndRefresh(); +} + void Widget::refreshPreparedContent() { setDocument(_state->prepared()); const auto width = std::max( @@ -212,18 +218,42 @@ void Widget::syncInlineFieldGeometry() { syncInlineFieldGeometry(widthNoMargins()); } -void Widget::insertHeading1() { +void Widget::insertBlock(State::InsertAction action) { commitInlineField(); - _state->insertHeading1AfterActive(); + _state->insertBlockAfterActive(action); refreshPreparedContent(); activateTextOrdinal(_state->activeTextOrdinal(), 0); } +void Widget::insertMedia(State::InsertBlockType type) { + switch (type) { + case State::InsertBlockType::Photo: + case State::InsertBlockType::Video: + case State::InsertBlockType::Audio: + insertBlock({ .type = type }); + break; + default: + break; + } +} + +void Widget::insertMap(double latitude, double longitude) { + insertBlock({ + .type = State::InsertBlockType::Map, + .latitude = latitude, + .longitude = longitude, + }); +} + +void Widget::insertHeading1() { + insertBlock({ + .type = State::InsertBlockType::Heading, + .headingLevel = 1, + }); +} + void Widget::insertBlockquote() { - commitInlineField(); - _state->insertBlockquoteAfterActive(); - refreshPreparedContent(); - activateTextOrdinal(_state->activeTextOrdinal(), 0); + insertBlock({ .type = State::InsertBlockType::Blockquote }); } int Widget::resizeGetHeight(int newWidth) { @@ -245,9 +275,19 @@ int Widget::resizeGetHeight(int newWidth) { st::ivEditorMinHeight); } +bool Widget::eventFilter(QObject *object, QEvent *event) { + if (_field + && object == _field->rawTextEdit().get() + && event->type() == QEvent::KeyPress + && handleFieldKey(static_cast(event))) { + return true; + } + return Ui::RpWidget::eventFilter(object, event); +} + void Widget::focusInEvent(QFocusEvent *e) { Ui::RpWidget::focusInEvent(e); - if (!_field->isHidden()) { + if (!_settingField && !_field->isHidden()) { _field->setFocusFast(); } } @@ -321,6 +361,11 @@ void Widget::mouseReleaseEvent(QMouseEvent *e) { && hit.direct && _article->segmentIsText(hit.segmentIndex); }; + const auto directEditableHit = [&] { + return hit.valid() + && hit.direct + && _article->segmentIsEditable(hit.segmentIndex); + }; const auto commitVisibleInlineField = [&] { if (_field->isHidden()) { return; @@ -328,7 +373,7 @@ void Widget::mouseReleaseEvent(QMouseEvent *e) { commitInlineField(); _pendingOrdinal = -1; _pendingCursorOffset = 0; - _field->hide(); + hideInlineField(); _article->clearTextLeafHeightOverride(); refreshPreparedContent(); }; @@ -346,7 +391,7 @@ void Widget::mouseReleaseEvent(QMouseEvent *e) { && (selection.from.segment == selection.to.segment) && _article->segmentIsText(selection.from.segment); const auto selectionOrdinal = sameSegmentSelection - ? textOrdinalForSegment(selection.from.segment) + ? editableOrdinalForSegment(selection.from.segment) : -1; if (selectionOrdinal >= 0) { const auto selectionFrom = selection.from.offset; @@ -360,7 +405,8 @@ void Widget::mouseReleaseEvent(QMouseEvent *e) { e->accept(); return; } else if (selection.empty() && directTextHit()) { - const auto targetOrdinal = textOrdinalForSegment(hit.segmentIndex); + const auto targetOrdinal = editableOrdinalForSegment( + hit.segmentIndex); const auto targetOffset = _article->selectionOffsetFromHit( hit, TextSelectType::Letters); @@ -381,8 +427,8 @@ void Widget::mouseReleaseEvent(QMouseEvent *e) { e->accept(); return; } - if (directTextHit()) { - const auto targetOrdinal = textOrdinalForSegment(hit.segmentIndex); + if (directEditableHit()) { + const auto targetOrdinal = editableOrdinalForSegment(hit.segmentIndex); const auto offset = _article->selectionOffsetFromHit( hit, TextSelectType::Letters); @@ -433,7 +479,7 @@ void Widget::setDocument(const Markdown::MarkdownArticleContent &prepared) { Markdown::MarkdownArticleTextLeafStyle Widget::inlineFieldStyleForSegment( int segmentIndex) const { return _article - ? _article->textLeafStyleForSegment(segmentIndex) + ? _article->editableStyleForSegment(segmentIndex) : Markdown::MarkdownArticleTextLeafStyle(); } @@ -453,6 +499,9 @@ const Widget::CachedInlineFieldStyle &Widget::inlineFieldStyleFor( auto fieldStyle = std::make_shared( st::ivEditorInputField); fieldStyle->style = *data.textStyle; + fieldStyle->style.font = data.italic + ? data.textStyle->font->italic() + : data.textStyle->font; fieldStyle->style.lineHeight = data.lineHeight; fieldStyle->textFg = data.textFg; fieldStyle->textAlign = data.align; @@ -477,6 +526,7 @@ Widget::InlineFieldStyleData Widget::normalizedInlineFieldStyle( .lineHeight = lineHeight, .textFg = valid ? leafStyle.textColor : st::messageMarkdown.textColor, .align = valid ? leafStyle.align : style::al_left, + .italic = valid ? leafStyle.italic : false, }; } @@ -486,46 +536,65 @@ Widget::InlineFieldStyleKey Widget::inlineFieldStyleKey( ? data.textStyle : &st::messageMarkdown.body; return { - .font = textStyle->font, + .font = data.italic + ? textStyle->font->italic() + : textStyle->font, .lineHeight = data.lineHeight, .textFg = data.textFg, .align = data.align, }; } -void Widget::ensureInlineFieldStyleForSegment(int segmentIndex) { +void Widget::ensureInlineFieldForSegment(int segmentIndex) { const auto data = normalizedInlineFieldStyle( inlineFieldStyleForSegment(segmentIndex)); const auto key = inlineFieldStyleKey(data); - if (_activeFieldStyleKey && *_activeFieldStyleKey == key) { + const auto mode = _state->activeFieldMode(); + if (_activeFieldStyleKey + && *_activeFieldStyleKey == key + && _fieldMode == mode) { return; } const auto &cached = inlineFieldStyleFor(data); _activeFieldStyleKey = key; + _fieldMode = mode; recreateInlineField(*cached.style); } void Widget::setupInlineField() { - const auto allowPremiumEmoji = [peer = _peer]( - not_null emoji) { - return Data::AllowEmojiWithoutPremium(peer, emoji); - }; - InitMessageFieldHandlers({ - .session = &_controller->session(), - .show = _controller->uiShow(), - .field = _field.get(), - .customEmojiPaused = [=] { - return _controller->isGifPausedAtLeastFor( - Window::GifPauseReason::Layer); - }, - .allowPremiumEmoji = allowPremiumEmoji, - .fieldStyle = &_field->st(), - }); - _field->setMimeDataHook(WrappedMessageFieldMimeHook( - Ui::InputField::MimeDataHook(), - _field.get())); + if (_fieldMode == State::FieldMode::Rich) { + const auto allowPremiumEmoji = [peer = _peer]( + not_null emoji) { + return Data::AllowEmojiWithoutPremium(peer, emoji); + }; + InitMessageFieldHandlers({ + .session = &_controller->session(), + .show = _controller->uiShow(), + .field = _field.get(), + .customEmojiPaused = [=] { + return _controller->isGifPausedAtLeastFor( + Window::GifPauseReason::Layer); + }, + .allowPremiumEmoji = allowPremiumEmoji, + .fieldStyle = &_field->st(), + }); + _field->setMimeDataHook(WrappedMessageFieldMimeHook( + Ui::InputField::MimeDataHook(), + _field.get())); + } else { + _field->setInstantReplacesEnabled( + rpl::single(false), + rpl::single(false)); + _field->setMarkdownReplacesEnabled( + rpl::single(Ui::MarkdownEnabledState{ + Ui::MarkdownDisabled() + })); + } + _field->setDocumentMargin(0.); + _field->setAdditionalMargins({}); _field->setSubmitSettings(Ui::InputField::SubmitSettings::None); _field->setMaxHeight(std::numeric_limits::max()); + _field->rawTextEdit()->installEventFilter(this); _field->heightChanges( ) | rpl::on_next([=] { @@ -539,7 +608,7 @@ void Widget::setupInlineField() { } }, _field->lifetime()); - _field->hide(); + hideInlineField(); } void Widget::recreateInlineField(const style::InputField &st) { @@ -592,25 +661,32 @@ void Widget::activateTextOrdinal( _pendingOrdinal = -1; _pendingCursorOffset = 0; - const auto segmentIndex = segmentIndexForTextOrdinal(ordinal); + const auto segmentIndex = segmentIndexForEditableOrdinal(ordinal); if (segmentIndex < 0) { _activeSegmentIndex = -1; _pendingOrdinal = ordinal; _pendingCursorOffset = selectionTo; - _field->hide(); + hideInlineField(); return; } _activeSegmentIndex = segmentIndex; - ensureInlineFieldStyleForSegment(segmentIndex); - const auto activeText = _state->activeText(); + ensureInlineFieldForSegment(segmentIndex); _settingField = true; - _field->setTextWithTags( - { - activeText.text, - TextUtilities::ConvertEntitiesToTextTags(activeText.entities), - }, - Ui::InputField::HistoryAction::Clear); + if (_state->activeFieldMode() == State::FieldMode::Raw) { + _field->setTextWithTags( + { _state->activeRawText(), {} }, + Ui::InputField::HistoryAction::Clear); + _article->clearTextLeafHeightOverride(); + } else { + const auto activeText = _state->activeText(); + _field->setTextWithTags( + { + activeText.text, + TextUtilities::ConvertEntitiesToTextTags(activeText.entities), + }, + Ui::InputField::HistoryAction::Clear); + } auto cursor = _field->textCursor(); const auto size = _field->getLastText().size(); const auto from = std::clamp(selectionFrom, 0, size); @@ -639,6 +715,10 @@ void Widget::applyFieldTextToState() { if (_settingField || _field->isHidden()) { return; } + if (_state->activeFieldMode() == State::FieldMode::Raw) { + _state->applyActiveRawText(_field->getLastText()); + return; + } const auto text = _field->getTextWithAppliedMarkdown(); _state->applyActiveText({ .text = text.text, @@ -646,11 +726,144 @@ void Widget::applyFieldTextToState() { }); } +void Widget::hideInlineField() { + if (_field->isHidden()) { + return; + } + const auto wasSettingField = _settingField; + _settingField = true; + const auto guard = gsl::finally([&] { + _settingField = wasSettingField; + }); + _field->hide(); +} + +void Widget::activateTextOrdinalAtEnd(int ordinal) { + if (!_state->setActiveTextByOrdinal(ordinal)) { + return; + } + activateTextOrdinal(ordinal, _state->activeTextLength()); +} + +bool Widget::handleFieldKey(QKeyEvent *e) { + if (_field->isHidden()) { + return false; + } + const auto key = e->key(); + if (key == Qt::Key_Escape) { + hideInlineFieldAndRefresh(); + e->accept(); + return true; + } + const auto modifiers = e->modifiers() + & ~(Qt::KeypadModifier | Qt::GroupSwitchModifier); + if (modifiers != Qt::NoModifier) { + return false; + } + const auto cursor = _field->textCursor(); + if (cursor.hasSelection()) { + return false; + } + const auto position = cursor.position(); + const auto length = _field->getLastText().size(); + auto handled = false; + if (position <= 0 + && (key == Qt::Key_Left + || key == Qt::Key_Up + || key == Qt::Key_PageUp)) { + handled = moveBoundary(false, false); + } else if (position >= length + && (key == Qt::Key_Right + || key == Qt::Key_Down + || key == Qt::Key_PageDown)) { + handled = moveBoundary(true, true); + } else if (position <= 0 && key == Qt::Key_Backspace) { + handled = removeBoundaryOwner(false); + } else if (position >= length && key == Qt::Key_Delete) { + handled = removeBoundaryOwner(true); + } + if (handled) { + e->accept(); + } + return handled; +} + +bool Widget::moveBoundary(bool forward, bool allowTrailing) { + const auto target = forward + ? _state->nextEditableOrdinal() + : _state->previousEditableOrdinal(); + const auto addTrailing = forward + && allowTrailing + && !target + && !_state->isActiveTopLevelParagraph(); + if (!target && !addTrailing) { + return false; + } + commitInlineField(); + if (target) { + refreshPreparedContent(); + if (forward) { + activateTextOrdinal(*target, 0); + } else { + activateTextOrdinalAtEnd(*target); + } + return true; + } + const auto ordinal = _state->ensureTrailingParagraphActive(); + refreshPreparedContent(); + activateTextOrdinal(ordinal, 0); + return true; +} + +bool Widget::moveBoundaryAfterCommit(bool forward, bool allowTrailing) { + const auto target = forward + ? _state->nextEditableOrdinal() + : _state->previousEditableOrdinal(); + if (target) { + refreshPreparedContent(); + if (forward) { + activateTextOrdinal(*target, 0); + } else { + activateTextOrdinalAtEnd(*target); + } + return true; + } + if (forward && allowTrailing && !_state->isActiveTopLevelParagraph()) { + const auto ordinal = _state->ensureTrailingParagraphActive(); + refreshPreparedContent(); + activateTextOrdinal(ordinal, 0); + return true; + } + return false; +} + +bool Widget::removeBoundaryOwner(bool forward) { + commitInlineField(); + if (_state->activeOwnerIsEmpty()) { + const auto target = _state->removeActiveOwnerAndSelectAdjacent( + forward); + hideInlineField(); + _article->clearTextLeafHeightOverride(); + refreshPreparedContent(); + if (target) { + if (forward) { + activateTextOrdinal(*target, 0); + } else { + activateTextOrdinalAtEnd(*target); + } + } else { + activateInitialNode(); + } + return true; + } + return moveBoundaryAfterCommit(forward, forward); +} + void Widget::ensurePendingActivation() { if (_pendingOrdinal < 0) { _activeSegmentIndex = (_activeOrdinal >= 0) - ? segmentIndexForTextOrdinal(_activeOrdinal) - : _article->firstTextSegmentIndex(); + ? segmentIndexForEditableOrdinal(_activeOrdinal) + : _article->firstEditableSegmentIndex(); return; } const auto ordinal = _pendingOrdinal; @@ -670,11 +883,17 @@ void Widget::updateInlineFieldHeightOverride() { _pendingHeightOverrideUpdate = true; return; } - const auto segmentRect = outerTextSegmentRect(_activeSegmentIndex); + const auto textLeafIndex = _article->textLeafIndexForSegment( + _activeSegmentIndex); + if (textLeafIndex < 0) { + _article->clearTextLeafHeightOverride(); + return; + } + const auto segmentRect = outerEditableSegmentRect(_activeSegmentIndex); const auto height = segmentRect.isEmpty() ? _field->height() : std::max(_field->geometry().bottom() + 1 - segmentRect.y(), 1); - _article->setTextLeafHeightOverride(_activeOrdinal, height); + _article->setTextLeafHeightOverride(textLeafIndex, height); resizeToWidth(std::max(widthNoMargins(), 1)); update(); } @@ -684,24 +903,27 @@ void Widget::syncInlineFieldGeometry(int width) { return; } if (_activeSegmentIndex >= 0) { - ensureInlineFieldStyleForSegment(_activeSegmentIndex); + ensureInlineFieldForSegment(_activeSegmentIndex); } - const auto segmentRect = outerTextSegmentRect(_activeSegmentIndex); + const auto segmentRect = outerEditableSegmentRect(_activeSegmentIndex); if (segmentRect.isEmpty()) { _pendingOrdinal = _activeOrdinal; _pendingCursorOffset = _field->textCursor().position(); - _field->hide(); + hideInlineField(); _article->clearTextLeafHeightOverride(); return; } + const auto margins = _field->fullTextMargins(); + const auto left = segmentRect.x() - margins.left(); + const auto top = segmentRect.y() - margins.top(); const auto fieldWidth = std::max( - std::min(segmentRect.width(), width - segmentRect.x()), + std::min( + segmentRect.width() + margins.left() + margins.right(), + width - left), 1); _syncingInlineFieldGeometry = true; _field->resizeToWidth(fieldWidth); const auto fieldHeight = FieldNaturalHeight(_field.get()); - const auto left = segmentRect.x(); - const auto top = segmentRect.y(); _field->setGeometryToLeft(left, top, fieldWidth, fieldHeight, width); _field->raise(); _syncingInlineFieldGeometry = false; @@ -766,12 +988,12 @@ void Widget::updateTextSelection( } } -int Widget::textOrdinalForSegment(int segmentIndex) const { - return _article->textLeafIndexForSegment(segmentIndex); +int Widget::editableOrdinalForSegment(int segmentIndex) const { + return _article->editableIndexForSegment(segmentIndex); } -int Widget::segmentIndexForTextOrdinal(int ordinal) const { - return _article->segmentIndexForTextLeafIndex(ordinal); +int Widget::segmentIndexForEditableOrdinal(int ordinal) const { + return _article->segmentIndexForEditableIndex(ordinal); } QPoint Widget::articleTopLeft() const { @@ -786,8 +1008,8 @@ int Widget::articleWidth(int outerWidth) const { 1); } -QRect Widget::outerTextSegmentRect(int segmentIndex) const { - const auto rect = _article->textSegmentRect(segmentIndex); +QRect Widget::outerEditableSegmentRect(int segmentIndex) const { + const auto rect = _article->segmentRect(segmentIndex); return rect.isEmpty() ? rect : rect.translated(articleTopLeft()); } @@ -823,9 +1045,11 @@ Markdown::MarkdownArticlePaintContext Widget::textPaintContext(QRect clip) { }); }, }; - context.hiddenTextSegmentIndex = _field->isHidden() + const auto hiddenSegmentIndex = _field->isHidden() ? -1 : _activeSegmentIndex; + context.hiddenTextSegmentIndex = hiddenSegmentIndex; + context.hiddenSegmentIndex = hiddenSegmentIndex; context.selectionState.selection = _selection; context.selectionState.endpoints = &_selectionEndpoints; return context; diff --git a/Telegram/SourceFiles/iv/iv_editor_widget.h b/Telegram/SourceFiles/iv/iv_editor_widget.h index 46780093a3..5ad782b2eb 100644 --- a/Telegram/SourceFiles/iv/iv_editor_widget.h +++ b/Telegram/SourceFiles/iv/iv_editor_widget.h @@ -17,6 +17,10 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include +class QEvent; +class QKeyEvent; +class QObject; + namespace Ui { class ChatStyle; class ChatTheme; @@ -48,12 +52,16 @@ public: void commitInlineField(); void refreshPreparedContent(); void syncInlineFieldGeometry(); + void insertBlock(State::InsertAction action); + void insertMedia(State::InsertBlockType type); + void insertMap(double latitude, double longitude); void insertHeading1(); void insertBlockquote(); int resizeGetHeight(int newWidth) override; protected: + bool eventFilter(QObject *object, QEvent *event) override; void focusInEvent(QFocusEvent *e) override; void mouseMoveEvent(QMouseEvent *e) override; void mousePressEvent(QMouseEvent *e) override; @@ -67,6 +75,7 @@ private: int lineHeight = 0; style::color textFg; style::align align = style::al_left; + bool italic = false; }; struct InlineFieldStyleKey { @@ -109,22 +118,31 @@ private: const Markdown::MarkdownArticleTextLeafStyle &style) const; [[nodiscard]] InlineFieldStyleKey inlineFieldStyleKey( const InlineFieldStyleData &data) const; - void ensureInlineFieldStyleForSegment(int segmentIndex); + void ensureInlineFieldForSegment(int segmentIndex); void setupInlineField(); void recreateInlineField(const style::InputField &st); void activateTrailingParagraph(); void applyFieldTextToState(); + void hideInlineField(); void acceptInlineField(); + void hideInlineFieldAndRefresh(); + void activateTextOrdinalAtEnd(int ordinal); + [[nodiscard]] bool handleFieldKey(QKeyEvent *e); + [[nodiscard]] bool moveBoundary(bool forward, bool allowTrailing); + [[nodiscard]] bool moveBoundaryAfterCommit( + bool forward, + bool allowTrailing); + [[nodiscard]] bool removeBoundaryOwner(bool forward); void ensurePendingActivation(); void updateInlineFieldHeightOverride(); void syncInlineFieldGeometry(int width); void clearTextSelection(); void updateTextSelection(const Markdown::MarkdownArticleHitTestResult &hit); - [[nodiscard]] int textOrdinalForSegment(int segmentIndex) const; - [[nodiscard]] int segmentIndexForTextOrdinal(int ordinal) const; + [[nodiscard]] int editableOrdinalForSegment(int segmentIndex) const; + [[nodiscard]] int segmentIndexForEditableOrdinal(int ordinal) const; [[nodiscard]] QPoint articleTopLeft() const; [[nodiscard]] int articleWidth(int outerWidth) const; - [[nodiscard]] QRect outerTextSegmentRect(int segmentIndex) const; + [[nodiscard]] QRect outerEditableSegmentRect(int segmentIndex) const; [[nodiscard]] Markdown::MarkdownArticlePaintContext textPaintContext( QRect clip); @@ -138,6 +156,7 @@ private: std::vector _highlightColors; std::vector _fieldStyles; std::optional _activeFieldStyleKey; + State::FieldMode _fieldMode = State::FieldMode::Rich; int _articleHeight = 0; int _activeOrdinal = -1; int _activeSegmentIndex = -1; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp index d32047cbf3..92b5663d8e 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp @@ -119,6 +119,14 @@ void RestoreRelatedArticleImageStates( } } +[[nodiscard]] bool IsDisplayMathSegment(const SelectableSegment &segment) { + return (segment.kind == SelectableSegmentKind::DisplayMath); +} + +[[nodiscard]] bool IsEditableSegment(const SelectableSegment &segment) { + return segment.isTextLeaf() || IsDisplayMathSegment(segment); +} + void CollectPlaceholderIds( const std::vector &blocks, std::unordered_set *result) { @@ -747,19 +755,34 @@ public: [[nodiscard]] bool segmentIsText(int index) const; + [[nodiscard]] bool segmentIsDisplayMath(int index) const; + + [[nodiscard]] bool segmentIsEditable(int index) const; + [[nodiscard]] int segmentLength(int index) const; [[nodiscard]] int firstTextSegmentIndex() const; + [[nodiscard]] int firstEditableSegmentIndex() const; + [[nodiscard]] int textLeafIndexForSegment(int segmentIndex) const; [[nodiscard]] int segmentIndexForTextLeafIndex(int textLeafIndex) const; + [[nodiscard]] int editableIndexForSegment(int segmentIndex) const; + + [[nodiscard]] int segmentIndexForEditableIndex(int editableIndex) const; + [[nodiscard]] QRect textSegmentRect(int segmentIndex) const; + [[nodiscard]] QRect segmentRect(int segmentIndex) const; + [[nodiscard]] MarkdownArticleTextLeafStyle textLeafStyleForSegment( int segmentIndex) const; + [[nodiscard]] MarkdownArticleTextLeafStyle editableStyleForSegment( + int segmentIndex) const; + [[nodiscard]] int selectionOffsetFromHit( const MarkdownArticleHitTestResult &result, TextSelectType selectionType) const; @@ -1087,6 +1110,16 @@ bool MarkdownArticle::Impl::segmentIsText(int index) const { return segment && segment->isTextLeaf(); } +bool MarkdownArticle::Impl::segmentIsDisplayMath(int index) const { + const auto segment = FindSegment(&_segments, index); + return segment && IsDisplayMathSegment(*segment); +} + +bool MarkdownArticle::Impl::segmentIsEditable(int index) const { + const auto segment = FindSegment(&_segments, index); + return segment && IsEditableSegment(*segment); +} + int MarkdownArticle::Impl::segmentLength(int index) const { const auto segment = FindSegment(&_segments, index); return segment ? SegmentLength(*segment) : 0; @@ -1101,6 +1134,15 @@ int MarkdownArticle::Impl::firstTextSegmentIndex() const { return -1; } +int MarkdownArticle::Impl::firstEditableSegmentIndex() const { + for (const auto &segment : _segments) { + if (IsEditableSegment(segment)) { + return segment.index; + } + } + return -1; +} + int MarkdownArticle::Impl::textLeafIndexForSegment(int segmentIndex) const { auto textLeafIndex = 0; for (const auto &segment : _segments) { @@ -1131,11 +1173,56 @@ int MarkdownArticle::Impl::segmentIndexForTextLeafIndex( return -1; } +int MarkdownArticle::Impl::editableIndexForSegment(int segmentIndex) const { + auto editableIndex = 0; + for (const auto &segment : _segments) { + if (!IsEditableSegment(segment)) { + continue; + } else if (segment.index == segmentIndex) { + return editableIndex; + } + ++editableIndex; + } + return -1; +} + +int MarkdownArticle::Impl::segmentIndexForEditableIndex( + int editableIndex) const { + if (editableIndex < 0) { + return -1; + } + auto current = 0; + for (const auto &segment : _segments) { + if (!IsEditableSegment(segment)) { + continue; + } else if (current == editableIndex) { + return segment.index; + } + ++current; + } + return -1; +} + QRect MarkdownArticle::Impl::textSegmentRect(int segmentIndex) const { const auto segment = FindSegment(&_segments, segmentIndex); return (segment && segment->isTextLeaf()) ? segment->textRect : QRect(); } +QRect MarkdownArticle::Impl::segmentRect(int segmentIndex) const { + const auto segment = FindSegment(&_segments, segmentIndex); + if (!segment) { + return QRect(); + } else if (segment->isTextLeaf()) { + return segment->textRect; + } else if (IsDisplayMathSegment(*segment)) { + if (!segment->outerRect.isEmpty()) { + return segment->outerRect; + } + return segment->block ? segment->block->formulaRect : QRect(); + } + return QRect(); +} + MarkdownArticleTextLeafStyle MarkdownArticle::Impl::textLeafStyleForSegment( int segmentIndex) const { const auto segment = FindSegment(&_segments, segmentIndex); @@ -1149,6 +1236,26 @@ MarkdownArticleTextLeafStyle MarkdownArticle::Impl::textLeafStyleForSegment( .textColor = TextColorForSegment(*segment, st), .lineHeight = TextLineHeight(textStyle), .align = segment->align, + .italic = segment->block && segment->block->pullquote, + }; +} + +MarkdownArticleTextLeafStyle MarkdownArticle::Impl::editableStyleForSegment( + int segmentIndex) const { + const auto segment = FindSegment(&_segments, segmentIndex); + if (!segment) { + return {}; + } else if (segment->isTextLeaf()) { + return textLeafStyleForSegment(segmentIndex); + } else if (!IsDisplayMathSegment(*segment)) { + return {}; + } + const auto &st = layoutStyle(); + return { + .textStyle = &st.displayMath.fallbackStyle, + .textColor = st.displayMath.fg, + .lineHeight = TextLineHeight(st.displayMath.fallbackStyle), + .align = segment->align, }; } @@ -1788,6 +1895,14 @@ bool MarkdownArticle::segmentIsText(int index) const { return _impl->segmentIsText(index); } +bool MarkdownArticle::segmentIsDisplayMath(int index) const { + return _impl->segmentIsDisplayMath(index); +} + +bool MarkdownArticle::segmentIsEditable(int index) const { + return _impl->segmentIsEditable(index); +} + int MarkdownArticle::segmentLength(int index) const { return _impl->segmentLength(index); } @@ -1796,6 +1911,10 @@ int MarkdownArticle::firstTextSegmentIndex() const { return _impl->firstTextSegmentIndex(); } +int MarkdownArticle::firstEditableSegmentIndex() const { + return _impl->firstEditableSegmentIndex(); +} + int MarkdownArticle::textLeafIndexForSegment(int segmentIndex) const { return _impl->textLeafIndexForSegment(segmentIndex); } @@ -1804,15 +1923,32 @@ int MarkdownArticle::segmentIndexForTextLeafIndex(int textLeafIndex) const { return _impl->segmentIndexForTextLeafIndex(textLeafIndex); } +int MarkdownArticle::editableIndexForSegment(int segmentIndex) const { + return _impl->editableIndexForSegment(segmentIndex); +} + +int MarkdownArticle::segmentIndexForEditableIndex(int editableIndex) const { + return _impl->segmentIndexForEditableIndex(editableIndex); +} + QRect MarkdownArticle::textSegmentRect(int segmentIndex) const { return _impl->textSegmentRect(segmentIndex); } +QRect MarkdownArticle::segmentRect(int segmentIndex) const { + return _impl->segmentRect(segmentIndex); +} + MarkdownArticleTextLeafStyle MarkdownArticle::textLeafStyleForSegment( int segmentIndex) const { return _impl->textLeafStyleForSegment(segmentIndex); } +MarkdownArticleTextLeafStyle MarkdownArticle::editableStyleForSegment( + int segmentIndex) const { + return _impl->editableStyleForSegment(segmentIndex); +} + int MarkdownArticle::selectionOffsetFromHit( const MarkdownArticleHitTestResult &result, TextSelectType selectionType) const { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h index afbea6ec82..c9ea433e98 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h @@ -157,6 +157,7 @@ struct MarkdownArticlePaintContext final : Ui::ChatPaintContext { PaintSelectionState selectionState; MarkdownArticleRevealPaintState *reveal = nullptr; int hiddenTextSegmentIndex = -1; + int hiddenSegmentIndex = -1; bool debugBlockGeometry = false; [[nodiscard]] MarkdownArticlePaintContext translated(int x, int y) const { @@ -192,6 +193,7 @@ struct MarkdownArticleTextLeafStyle { style::color textColor; int lineHeight = 0; style::align align = style::al_left; + bool italic = false; [[nodiscard]] bool valid() const { return textStyle && textColor; @@ -237,13 +239,21 @@ public: const QString &anchorId); [[nodiscard]] bool toggleDetails(const QString &anchorId); [[nodiscard]] bool segmentIsText(int index) const; + [[nodiscard]] bool segmentIsDisplayMath(int index) const; + [[nodiscard]] bool segmentIsEditable(int index) const; [[nodiscard]] int segmentLength(int index) const; [[nodiscard]] int firstTextSegmentIndex() const; + [[nodiscard]] int firstEditableSegmentIndex() const; [[nodiscard]] int textLeafIndexForSegment(int segmentIndex) const; [[nodiscard]] int segmentIndexForTextLeafIndex(int textLeafIndex) const; + [[nodiscard]] int editableIndexForSegment(int segmentIndex) const; + [[nodiscard]] int segmentIndexForEditableIndex(int editableIndex) const; [[nodiscard]] QRect textSegmentRect(int segmentIndex) const; + [[nodiscard]] QRect segmentRect(int segmentIndex) const; [[nodiscard]] MarkdownArticleTextLeafStyle textLeafStyleForSegment( int segmentIndex) const; + [[nodiscard]] MarkdownArticleTextLeafStyle editableStyleForSegment( + int segmentIndex) const; [[nodiscard]] int selectionOffsetFromHit( const MarkdownArticleHitTestResult &result, TextSelectType selectionType) const; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp index e46ab30941..a5e0505c22 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp @@ -418,7 +418,7 @@ void LayoutMediaCaption( int skip, int *bottom, LayoutContext context) { - if (prepared.text.text.isEmpty()) { + if (prepared.text.text.isEmpty() && !prepared.forceTextSegment) { return; } block->supplementary = prepared.supplementary; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp index 01994ebb33..e9eba91f71 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp @@ -687,7 +687,8 @@ void PaintSelectableTextLeaf( std::optional selection = std::nullopt, int elisionLines = 0) { if ((segmentIndex >= 0) - && (context.hiddenTextSegmentIndex == segmentIndex)) { + && ((context.hiddenTextSegmentIndex == segmentIndex) + || (context.hiddenSegmentIndex == segmentIndex))) { if (context.reveal && !elisionLines) { context.reveal->nextLine += CountTextRevealLines(leaf, rect, width); } @@ -1362,6 +1363,10 @@ void PaintDisplayMathBlock( context, block.visibleFormulaRect, [&](Painter &p, const MarkdownArticlePaintContext &formulaContext) { + if (block.segmentIndex >= 0 + && (formulaContext.hiddenSegmentIndex == block.segmentIndex)) { + return; + } const auto &paintSt = PaintStyle(formulaContext, st); const auto formula = PreparedFormulaFor(formulas, block.formulaIndex); p.setPen(paintSt.textColor->c); diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.cpp index afa470f225..363956346f 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.cpp @@ -405,13 +405,14 @@ void CollectSelectableSegments( segment.kind = SelectableSegmentKind::DisplayMath; segment.block = █ segment.outerRect = block.visibleFormulaRect; + segment.align = block.formulaAlign; segment.length = 1; block.segmentIndex = AddSelectableSegment( segments, std::move(segment)); } break; case PreparedBlockKind::Table: { - if (!block.textRect.isEmpty() && !block.leaf.isEmpty()) { + if (!block.textRect.isEmpty()) { auto textSegment = SelectableSegment(); textSegment.kind = SelectableSegmentKind::TextLeaf; textSegment.leaf = &block.leaf; @@ -477,7 +478,7 @@ void CollectSelectableSegments( block.segmentIndex = AddSelectableSegment( segments, std::move(segment)); - if (!block.textRect.isEmpty() && !block.leaf.isEmpty()) { + if (!block.textRect.isEmpty()) { auto textSegment = SelectableSegment(); textSegment.kind = SelectableSegmentKind::TextLeaf; textSegment.leaf = &block.leaf; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h index a3efd84fd4..5e0f4a49eb 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h @@ -253,6 +253,7 @@ struct PreparedBlock { bool tight = false; bool supplementary = false; bool pullquote = false; + bool forceTextSegment = false; }; struct PreparedRenderDocument { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp index 796a6eb750..93f2479720 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp @@ -144,47 +144,6 @@ void SortPreparedIvRichText(PreparedIvRichText *text) { return block; } -[[nodiscard]] bool PrepareNativeIvMathBlock( - const MTPDpageBlockMath &data, - std::vector *result, - NativeIvPrepareState *state) { - const auto source = qs(data.vsource()); - if (source.trimmed().isEmpty()) { - return true; - } - auto block = PreparedBlock(); - block.kind = PreparedBlockKind::DisplayMath; - block.formulaTex = source; - block.mathKind = MathKind::Display; - block.formulaIndex = state->rememberFormula(block); - result->push_back(std::move(block)); - return true; -} - -[[nodiscard]] bool AppendNativeIvFlowBlock( - std::vector *result, - PreparedBlockKind kind, - int headingLevel, - const MTPRichText &text, - NativeIvPrepareState *state, - bool allowEmpty = false) { - auto prepared = PreparedIvRichText(); - auto anchorId = QString(); - const auto context = NativeIvRichTextContextForTextSize( - NativeIvFlowTextSize(kind, headingLevel, state->dimensions), - state->dimensions); - if (!PrepareNativeIvRichText(text, &prepared, &anchorId, state, context)) { - return false; - } - return AppendPreparedIvRichBlock( - result, - kind, - headingLevel, - std::move(prepared), - std::move(anchorId), - allowEmpty || state->editMode); -} - void WrapPreparedIvRichTextItalic(PreparedIvRichText *prepared) { if (!prepared || prepared->text.text.isEmpty()) { return; @@ -217,6 +176,7 @@ bool AppendPreparedQuoteParagraph( } if (pullquote && (result->size() > count)) { result->back().flowAlignment = TableAlignment::Center; + result->back().pullquote = true; } return true; } @@ -513,10 +473,20 @@ void MarkNativeIvTableSlots( state->editMode)) { return false; } + if (!data.blocks.empty() + && !PrepareNativeIvBlocks( + RichPage{ + .blocks = data.blocks, + }, + &block.children, + state)) { + return false; + } if (block.children.empty()) { block.children.push_back(EmptyParagraphBlock()); if (data.pullquote) { block.children.back().flowAlignment = TableAlignment::Center; + block.children.back().pullquote = true; } } result->push_back(std::move(block)); @@ -578,7 +548,9 @@ void MarkNativeIvTableSlots( state->editMode)) { return false; } - } else if (!PrepareNativeIvBlocks( + } + if (!item.blocks.empty() + && !PrepareNativeIvBlocks( RichPage{ .blocks = item.blocks, }, @@ -767,7 +739,7 @@ void MarkNativeIvTableSlots( auto block = PreparedBlock(); block.kind = PreparedBlockKind::Details; block.anchorId = std::move(anchorId); - block.collapsed = !data.open; + block.collapsed = state->editMode ? false : !data.open; block.text = std::move(summary.text); block.links = std::move(summary.links); block.anchorIds = std::move(summary.anchorIds); diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp index ad43f6392f..d91f3eddc2 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp @@ -292,6 +292,35 @@ void AppendCanonicalNativeIvRichText( state); } +[[nodiscard]] bool PrepareNativeIvCanonicalPlaceholderBlock( + QString label, + const Iv::RichPage::RichText &caption, + QString anchorId, + std::vector *result, + NativeIvPrepareState *state) { + auto prepared = PreparedIvRichText(); + if (!PrepareNativeIvCaption(caption, &prepared, &anchorId, state)) { + return state->result.failure.failed() + ? false + : PrepareNativeIvPlainPlaceholderBlock(std::move(label), result); + } + SortPreparedIvRichText(&prepared); + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Placeholder; + block.text = std::move(prepared.text); + block.links = std::move(prepared.links); + block.anchorId = std::move(anchorId); + block.anchorIds = std::move(prepared.anchorIds); + block.supplementary = true; + block.forceTextSegment = state->editMode; + block.placeholder.label = std::move(label); + block.placeholder.copyText = block.text.text.isEmpty() + ? block.placeholder.label + : (block.placeholder.label + u"\n"_q + block.text.text); + result->push_back(std::move(block)); + return true; +} + } // namespace bool PrepareNativeIvRichText( @@ -351,7 +380,14 @@ bool PrepareNativeIvPhotoBlock( if (!CanonicalPhotoId(data) || CanonicalWidth(data) <= 0 || CanonicalHeight(data) <= 0) { - return true; + return state->editMode + ? PrepareNativeIvCanonicalPlaceholderBlock( + u"Photo"_q, + data.caption, + data.anchorId, + result, + state) + : true; } auto caption = PreparedIvRichText(); auto anchorId = QString(); @@ -366,6 +402,7 @@ bool PrepareNativeIvPhotoBlock( block.anchorId = data.anchorId.isEmpty() ? std::move(anchorId) : data.anchorId; block.anchorIds = std::move(caption.anchorIds); block.supplementary = true; + block.forceTextSegment = state->editMode; block.photo.id = GeneratePreparedMediaBlockId(state); block.photo.photoId = CanonicalPhotoId(data); block.photo.width = CanonicalWidth(data); @@ -384,7 +421,14 @@ bool PrepareNativeIvVideoBlock( if (!CanonicalDocumentId(data) || CanonicalWidth(data) <= 0 || CanonicalHeight(data) <= 0) { - return true; + return state->editMode + ? PrepareNativeIvCanonicalPlaceholderBlock( + u"Video"_q, + data.caption, + data.anchorId, + result, + state) + : true; } auto caption = PreparedIvRichText(); auto anchorId = QString(); @@ -399,6 +443,7 @@ bool PrepareNativeIvVideoBlock( block.anchorId = data.anchorId.isEmpty() ? std::move(anchorId) : data.anchorId; block.anchorIds = std::move(caption.anchorIds); block.supplementary = true; + block.forceTextSegment = state->editMode; block.video.id = GeneratePreparedMediaBlockId(state); block.video.media.kind = PreparedMediaItemKind::Document; block.video.media.id = CanonicalDocumentId(data); @@ -414,7 +459,14 @@ bool PrepareNativeIvAudioBlock( std::vector *result, NativeIvPrepareState *state) { if (!CanonicalDocumentId(data)) { - return true; + return state->editMode + ? PrepareNativeIvCanonicalPlaceholderBlock( + u"Audio"_q, + data.caption, + data.anchorId, + result, + state) + : true; } auto caption = PreparedIvRichText(); auto anchorId = QString(); @@ -429,6 +481,7 @@ bool PrepareNativeIvAudioBlock( block.anchorId = data.anchorId.isEmpty() ? std::move(anchorId) : data.anchorId; block.anchorIds = std::move(caption.anchorIds); block.supplementary = true; + block.forceTextSegment = state->editMode; block.audio.id = GeneratePreparedMediaBlockId(state); block.audio.documentId = CanonicalDocumentId(data); block.audio.title = data.audioTitle; @@ -444,7 +497,14 @@ bool PrepareNativeIvMapBlock( std::vector *result, NativeIvPrepareState *state) { if (!data.accessHash || data.width <= 0 || data.height <= 0) { - return true; + return state->editMode + ? PrepareNativeIvCanonicalPlaceholderBlock( + u"Map"_q, + data.caption, + data.anchorId, + result, + state) + : true; } auto caption = PreparedIvRichText(); auto anchorId = QString(); @@ -459,6 +519,7 @@ bool PrepareNativeIvMapBlock( block.anchorId = data.anchorId.isEmpty() ? std::move(anchorId) : data.anchorId; block.anchorIds = std::move(caption.anchorIds); block.supplementary = true; + block.forceTextSegment = state->editMode; block.map.id = GeneratePreparedMediaBlockId(state); block.map.latitude = data.latitude; block.map.longitude = data.longitude;