[feature][editor] Множественные курсоры (multi-cursor editing): MVP «выделить все вхождения + одновременный ввод» #196
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Множественные курсоры (multi-cursor editing) — анализ и подходы
0. О чём речь (и о чём НЕ речь)
Что хочется — несколько кареток в одном документе; набранный текст и
Backspace/Deleteприменяются ко всем позициям одновременно; одноCmd/Ctrl+Zоткатывает всю мульти-правку целиком. Сценарии из VS Code:Alt+ClickCtrl/Cmd+Alt+↑/↓Ctrl/Cmd+DCtrl/Cmd+Shift+LAlt+dragО чём НЕ речь — collaborative-курсоры (видеть, где сейчас находится другой соавтор). Это в Gitmost уже есть и работает отдельно:
CollaborationCaretв extensions.ts подключается черезcollabExtensions(...), а сервер Hocuspocus по умолчанию форвардит awareness. Этот документ её не касается.1. Архитектурный вердикт: почему это не «включить флаг»
Редактор Gitmost — Tiptap поверх ProseMirror (
@tiptap/core3.20.4,@tiptap/pm3.20.4). Принципиальное отличие от VS Code: Monaco/CodeMirror хранит массив selections, а ProseMirror хранит вEditorStateровно одинSelection:На этой единственной selection завязано в ProseMirror почти всё:
insertText,insertContent) работают с текущейselection;handleTextInput,handleKeyDown,handlePaste,handleDropполучают одно выделение;Доказательства из первоисточников:
Selection, по аналогии сCellSelectionизprosemirror-tables, который умеет содержать несколько отдельных диапазонов».Вывод: полноценный multi-cursor — это R&D-проект против устройства движка, а не настройка. Но самый ценный сценарий («поправить повторяющиеся одинаковые куски сразу в нескольких местах») реализуем дёшево, потому что массовая правка в одном transaction у нас уже написана.
2. Что уже есть в коде и переиспользуемо
В проекте уже есть расширение SearchAndReplace (в
editor-ext, подключено и в клиентском редакторе). Это почти готовый фундамент для главного сценария multi-cursor:processSearchesуже находит все вхождения терма и возвращает массивresults: Range[](диапазоныfrom/to).Decoration.inlineдля всех совпадений одновременно (это переи��пользуется для подсветки «активных» курсоров).replaceAllуже выполняет массовую правку в одном transaction, идя с конца, чтобы корректно учитывать сдвиг позиций после каждой вставки/удаления. Это ровно та механика, что нужна для одновременного ввода в несколько курсоров.То есть самая хитрая часть multi-cursor — применить правку к N позициям за один
trс корректным маппингом — у нас уже работает вreplaceAll.Дополнительно в клиенте уже есть инфраструктура для горячих клавиш: в page-editor.tsx:258-280 есть блок
handleDOMEvents.keydown, и используется утилитаplatformModifierKey(Cmd на macOS, Ctrl на других ОС — ровно то, что нужно для совместимых с VS Code шорткатов).3. Развилка: три подхода
3.1 Вариант A — MVP: «выделить все вхождения + одновременный ввод» (рекомендация)
Реализует главный сценарий из VS Code:
Ctrl/Cmd+Shift+L— берём слово под курсором (или текущее выделение), находим все вхождения, превращаем их в «активные курсоры»;Ctrl/Cmd+D— добавить следующее вхождение к набору;Backspace/Deleteприменяются ко всем позициям одновременно через один transaction (копия механикиreplaceAll);Esc— выйти из multi-cursor (один курсор).Что переиспользуется: массив
resultsи логика массовогоtrберутся из SearchAndReplace почти готовыми.Визуальные каретки: через
Decoration.widget(pos, () => cursorDomElement)— ProseMirror умеет «из коробки»; для диапазонов —Decoration.inline.Объём работы: средний. Один новый Tiptap-extension в
packages/editor-ext/src/lib/multi-cursor/+ wiring в клиентском редакторе + горячие клавиши + CSS + юнит-тесты.Риски: средние и ограниченные. Скоуп узкий (только текстовые вхождения), сценарии предсказуемые, тестируются конечным числом кейсов.
3.2 Вариант B — полноценный multi-cursor (как Monaco)
Полный набор из §0:
Alt+Click(произвольная точка),Alt+drag(колонковое выделение),Ctrl/Cmd+Alt+↑/↓(курсор на соседней строке), а также произвольный набор несвязанных курсоров (не по вхождениям).Путь: кастомный
MultiSelection extends Selection(по подсказке мейнтейнера ProseMirror, по образцуCellSelectionизprosemirror-tables), плюс полная маршрутизация ввода:handleTextInput,handleKeyDown(Backspace/Delete/стрелки/Enter/Home/End),handlePaste,handleDrop;Cmd/Ctrl+Zоткатывает все позиции разом);Объём работы: очень большой (многие недели). Готового референса в экосистеме нет — это самостоятельный R&D с отладкой на реальном контенте.
Риски: высокие — см. риск-карту в §4 (IME/composition, конфликты со сложными нодами вроде таблиц и code-блоков, взаимодействие с коллаборацией).
3.3 Вариант C — эмуляция через коллаборацию (отбрасываем)
Идея из Tiptap#3370: «проигрывать правки через отдельного pseudo-user через collaborative-слой». Не берём: ломает provenance правок (в проекте есть бейдж авторства «AI agent» в истории страницы, migration
20260616T130000-agent-provenance— такой хак его загрязнит и запутает), портит историю undo, концептуально криво и хрупко.Сводка
replaceAllSelectionс нуля4. Риск-карта
Для обоих вариантов, но в варианте B каждый пункт — сильно жёстче.
Cmd/Ctrl+Zоткатывает все позиции). Группировка через мету истории, см. какreplaceAllделает одинdispatch(tr).tr.mapping.map(pos). Один локальныйtrс правками в N местах Yjs переварит нормально (это несколько правок в одном Update).tr).CellSelection-подобная логика внутри таблиц — отдельная вселенная; в MVP курсоры в таблицах можно просто не поддерживать (как и вreplaceAll).DecorationSetи длинныйtr. Практически редко > нескольких десятков, но заложить верхнюю границу.5. Рекомендация
Брать Вариант A. Он закрывает главный use-case («быстро поправить повторяющиеся одинаковые куски сразу в нескольких местах»), опирается на уже работающий
replaceAll-механизм, и риск ограничен. Вариант B имеет смысл отдельным эпиком — только если A окажется недостаточно и будет устойчивый спрос на произвольные курсоры; тогда начинать стоит с прототипа кастомногоMultiSelection, чтобы доказать жизнеспособность на сложных узлах до полной реализации.Сознательные границы MVP (Вариант A) — см. §6.7.
6. План реализации Варианта A (MVP) — по шагам
6.1. Новый extension
Создать
packages/editor-ext/src/lib/multi-cursor/multi-cursor.ts— TiptapExtension:Plugin) со state ={ cursors: {from: number, to: number}[] }иDecorationSet(виджеты-каретки для точечных курсоров +Decoration.inlineдля диапазонов);selectAllOccurrences— берёт слово под курсором (или текущее выделение), находит все вхождения (можно вынести общую с search-and-replace логику поиска в утилиту, чтобы не дублироватьprocessSearches), заполняетcursors;addNextOccurrence(Ctrl/Cmd+D) — добавляет следующее вхождение кcursors;exitMultiCursor— очищаетcursors(также вешается наEsc);props:handleTextInput(view, from, to, text)— еслиcursorsнепустой, строит одинtr, вставляяtextв каждую позицию с конца (копия механики из search-and-replace.ts:213-246);handleKeyDown—Backspace/Deleteаналогично (удаление символа перед/после каждой позиции);6.2. Маппинг позиций при изменениях документа
В
state.applyплагина — при любомdocChangedперемапливать все позиции черезtr.mapping.map(pos)и удалять «схлопнувшиеся» (from === toпосле маппинга — это нормально для каретки). Это покрывает и собственные правки, и remote Yjs-правки (y-prosemirror применяет их как обычные transactions — маппинг работает одинаково).6.3. Горячие клавиши
Добавить в существующий блок page-editor.tsx:258-280 (там уже есть
platformModifierKey):platformModifierKey + Shift + KeyL→selectAllOccurrences;platformModifierKey + KeyD→addNextOccurrence;Escape→exitMultiCursor.⚠️ Проверить конфликт
Ctrl/Cmd+Dс браузерным «добавить в закладки» (предотвратить черезevent.preventDefault()) и с любыми существующими биндингами редактора.6.4. Регистрация
packages/editor-ext/src/lib/multi-cursor/index.tsи добавить вpackages/editor-ext/src/index.ts;mainExtensionsв extensions.ts (оно не зависит от коллаборации, поэтому идёт в основной набор, доступный и в обычном, и в коллаборативном редакторе).6.5. CSS
Рядом с collaboration.css (и подключением через
styles/index.css) — стили для классов вроде.multi-cursor__caretи.multi-cursor__label. Визуально отличать от collaborative-кареток (например, другим стилем/цветом), чтобы не путать свои мульти-курсоры с курсорами соавторов.6.6. Тесты
Unit-тесты в
packages/editor-ext(по образцу существующих там тестов) на:tr(ввод/удаление в N позициях, проверка результирующего документа);6.7. Скоуп v1 / что сознательно НЕ входит
Чтобы держать риск в пределах, в MVP не делаем (явно фиксируем как out-of-scope):
Alt+Click(произвольная точка) иAlt+drag(колонковое выделение) — это путь в Вариант B;Ctrl/Cmd+Alt+↑/↓(курсор на соседней строке) — то же;replaceAll);tr).Эти границы — кандидаты на v2 / переход к Варианту B.
7. Открытые вопросы
Ctrl/Cmd+Shift+Lвыделяет целые слова (диапазоны). Делаем ли мы в MVP то же (диапазоны + одновременная замена всего слова), или только точечные каретки после конца слова? Рекомендация: диапазоны — это даёт «переименовать все эти слова сразу», что и есть главная ценность.processSearchesиз search-and-replace в общую утилиту, чтобы не дублировать, или оставить независимую реализацию в multi-cursor? Рекомендация: вынести общую часть (поиск всех вхождений слова по документу), оба расширения используют её.8. Источники
prosemirror-tables/CellSelection— референс реализации «выделения из нескольких диапазонов» для Варианта B.