25 KiB
Множественные курсоры (multi-cursor editing) — анализ и подходы
Статус: черновик / обсуждение. Код не пишется; цель этого документа — зафиксировать архитектурный вердикт, развилку подходов и рекомендацию.
Важное уточнение термина: речь про несколько собственных курсоров одного пользователя в одном документе (как в VS Code:
Alt+Clickдобавить курсор,Ctrl/Cmd+D— следующее вхождение,Ctrl/Cmd+Shift+L— все вхождения), чтобы править несколько мест одновременно. Не про collaborative-курсоры соавторов — те в проекте уже работают (CollaborationCaret+ Hocuspocus awareness).Зафиксированные выводы (см. разделы ниже):
- Полноценный VS Code-style multi-cursor нельзя «включить флагом»: движок редактора (ProseMirror) хранит в состоянии ровно одно выделение, в отличие от Monaco/CodeMirror с массивом selections. Готового production-пакета в экосистеме Tiptap/ProseMirror нет.
- ~80% пользовательской ценности даёт ограниченный MVP («выделить все вхождения + одновременный ввод»), который опирается на уже работающий в проекте механизм
replaceAllиз расширенияSearchAndReplace.- Рекомендация: реализовать MVP (Вариант A); полноценный набор (Вариант B) — отдельный большой эпик, имеет смысл браться только если MVP окажется недостаточно.
0. О чём речь (и о чём НЕ речь)
Что хочется — несколько кареток в одном документе; набранный текст и Backspace/Delete применяются ко всем позициям одновременно; одно Cmd/Ctrl+Z откатывает всю мульти-правку целиком. Сценарии из VS Code:
| Действие | Горячая клавиша | Суть |
|---|---|---|
| Добавить курсор | Alt+Click |
Курсор в произвольной точке клика |
| Добавить курсор строкой выше/ниже | Ctrl/Cmd+Alt+↑/↓ |
Копия курсора на соседней строке |
| Выделить следующее вхождение | Ctrl/Cmd+D |
Добавить к набору следующее вхождение слова |
| Выделить все вхождения | Ctrl/Cmd+Shift+L |
Все вхождения сразу |
| Колонковое/блочное выделение | Alt+drag |
Прямоугольник курсоров по строкам |
О чём НЕ речь — collaborative-курсоры (видеть, где сейчас находится другой соавтор). Это в Gitmost уже есть и работает отдельно: CollaborationCaret в extensions.ts подключается через collabExtensions(...), а сервер Hocuspocus по умолчанию форвардит awareness. Этот документ её не касается.
1. Архитектурный вердикт: почему это не «включить флаг»
Редактор Gitmost — Tiptap поверх ProseMirror (@tiptap/core 3.20.4, @tiptap/pm 3.20.4). Принципиальное отличие от VS Code: Monaco/CodeMirror хранит массив selections, а ProseMirror хранит в EditorState ровно один Selection:
EditorState = { doc, selection: Selection /* единственное */, storedMarks, ... }
На этой единственной selection завязано в ProseMirror почти всё:
- команды ввода (
insertText,insertContent) работают с текущейselection; - обработчики
handleTextInput,handleKeyDown,handlePaste,handleDropполучают одно выделение; - история (undo/redo) оперирует transactions с одним выделением;
- критично для нас — синхронизация через y-prosemirror тоже опирается на единственную selection (свою «awareness-selection» отдельно, но не на локальный массив).
Доказательства из первоисточников:
- Tiptap issue ueberdosis/tiptap#3370 «Multiple cursors per user» — открыт, официальной поддержки нет.
- Ответ marijnh (автор ProseMirror) на discuss.prosemirror.net: готовой реализации нет, но путь обозначен — «кастомный подкласс
Selection, по аналогии сCellSelectionизprosemirror-tables, который умеет содержать несколько отдельных диапазонов». - Production-готового пакета multi-cursor для Tiptap/ProseMirror в npm нет — пилить с нуля.
Вывод: полноценный multi-cursor — это R&D-проект против устройства движка, а не настройка. Но самый ценный сценарий («поправить повторяющиеся одинаковые куски сразу в нескольких местах») реализуем дёшево, потому что массовая правка в одном transaction у нас уже написана.
2. Что уже есть в коде и переиспользуемо
В проекте уже есть расширение SearchAndReplace (в editor-ext, подключено и в клиентском редакторе). Это почти готовый фундамент для главного сценария multi-cursor:
- search-and-replace.ts:100-174 —
processSearchesуже находит все вхождения терма и возвращает массивresults: Range[](диапазоныfrom/to). - search-and-replace.ts:157-168 — уже рисует
Decoration.inlineдля всех совпадений одновременно (это переиспользуется для подсветки «активных» курсоров). - search-and-replace.ts:213-246 —
replaceAllуже выполняет массовую правку в одном transaction, идя с конца, чтобы корректно учитывать сдвиг позиций после каждой вставки/удаления. Это ровно та механика, что нужна для одновременного ввода в несколько курсоров.
// search-and-replace.ts:213-246 — готовый эталон массового transaction
const replaceAll = (replaceTerm, results, { tr, dispatch }) => {
// Process replacements in reverse order to avoid position shifting issues
for (let i = resultsCopy.length - 1; i >= 0; i -= 1) {
const { from, to } = resultsCopy[i];
// ... собрать marks, удалить старый текст, вставить новый
tr.delete(from, to);
if (replaceTerm) tr.insert(from, tr.doc.type.schema.text(replaceTerm, marks));
}
dispatch(tr); // одна транзакция → одна запись в истории (один undo)
};
То есть самая хитрая часть 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; - построение одного мульти-position transaction для каждого события;
- визуальный рендер нескольких кареток и диапазонов;
- undo-группировка (одно
Cmd/Ctrl+Zоткатывает все позиции разом); - перемапливание позиций курсоров при любых изменениях документа, включая remote Yjs-правки.
Объём работы: очень большой (многие недели). Готового референса в экосистеме нет — это самостоятельный R&D с отладкой на реальном контенте.
Риски: высокие — см. риск-карту в §4 (IME/composition, конфликты со сложными нодами вроде таблиц и code-блоков, взаимодействие с коллаборацией).
3.3 Вариант C — эмуляция через коллаборацию (отбрасываем)
Идея из Tiptap#3370: «проигрывать правки через отдельного pseudo-user через collaborative-слой». Не берём: ломает provenance правок (в проекте есть бейдж авторства «AI agent» в истории страницы, migration 20260616T130000-agent-provenance — такой хак его загрязнит и запутает), портит историю undo, концептуально криво и хрупко.
Сводка
| Вариант A (MVP) | Вариант B (full) | Вариант C | |
|---|---|---|---|
| Сценарии | «все вхождения», «+следующее вхождение» | полный набор VS Code | — |
| База | готовый replaceAll |
кастомный Selection с нуля |
collaborative-слой |
| Объём | средний | очень большой | — |
| Риск | средний (ограниченный) | высокий | высокий |
| Рекомендация | да | только если A мало | нет |
4. Риск-карта
Для обоих вариантов, но в варианте B каждый пункт — сильно жёстче.
| Зона | Суть | Где больнее |
|---|---|---|
| Undo/redo | Мульти-правка должна быть одной записью истории (одно Cmd/Ctrl+Z откатывает все позиции). Группировка через мету истории, см. как replaceAll делает один dispatch(tr). |
B |
| Коллаборация (Yjs) | Пока активны ваши курсоры, может прилететь remote-правка — позиции курсоров надо перемапливать через tr.mapping.map(pos). Один локальный tr с правками в N местах Yjs переварит нормально (это несколько правок в одном Update). |
B |
| IME / dead keys | Ввод через composition (буквы с акцентами, CJK) одновременно в несколько курсоров — крайне хрупко; для MVP (Вариант A) проще: на время composition можно схлопывать к одному курсору. | B |
| Schema / сложные узлы | Курсор внутри code-блока + курсор в заголовке: одна и та же вставка может нарушить schema одного узла, но не другого. Нужно gracefully skip конфликтующие курсоры (не ронять весь tr). |
B (A — почти не касается, т.к. вхождения — текстовые) |
| Таблицы / callouts | CellSelection-подобная логика внутри таблиц — отдельная вселенная; в MVP курсоры в таблицах можно просто не поддерживать (как и в replaceAll). |
B |
| Производительность | Очень много курсоров → большой 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 — Tiptap Extension:
- плагин (ProseMirror
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аналогично (удаление символа перед/после каждой позиции);- игнорировать/схлопнуть multi-cursor при начале composition (IME) — см. §4.
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 позициях, проверка результирующего документа); - маппинг позиций после локальной правки и после имитированной remote-правки;
- граничные случаи: курсоры на границах узлов, схлопывание, пустой набор.
6.7. Скоуп v1 / что сознательно НЕ входит
Чтобы держать риск в пределах, в MVP не делаем (явно фиксируем как out-of-scope):
Alt+Click(произвольная точка) иAlt+drag(колонковое выделение) — это путь в Вариант B;Ctrl/Cmd+Alt+↑/↓(курсор на соседней строке) — то же;- курсоры внутри таблиц, code-блоков и callouts — только обычный текст (как в
replaceAll); - одновременный ввод через IME в несколько позиций (на время composition схлопываем к одному курсору);
- курсоры, затрагивающие разные schema-узлы одновременно (если вставка нарушает schema в одной из позиций — пропускаем эту позицию, не роняем весь
tr).
Эти границы — кандидаты на v2 / переход к Варианту B.
7. Открытые вопросы
- Выделение диапазонов vs точечные курсоры. В VS Code
Ctrl/Cmd+Shift+Lвыделяет целые слова (диапазоны). Делаем ли мы в MVP то же (диапазоны + одновременная замена всего слова), или только точечные каретки после конца слова? Рекомендация: диапазоны — это даёт «переименовать все эти слова сразу», что и есть главная ценность. - Общая утилита поиска. Вынести
processSearchesиз search-and-replace в общую утилиту, чтобы не дублировать, или оставить независимую реализацию в multi-cursor? Рекомендация: вынести общую часть (поиск всех вхождений слова по документу), оба расширения используют её. - Граница производительности. Ввести ли хард-кап на число одновременных курсоров (например, 100) с предупреждением пользователю? Рекомендация: да, как страховка.
8. Источники
- Tiptap issue #3370 — Multiple cursors per user
- discuss.ProseMirror — Multi-cursor editing in ProseMirror (ответ автора ProseMirror о кастомном подклассе Selection)
prosemirror-tables/CellSelection— референс реализации «выделения из нескольких диапазонов» для Варианта B.- Внутренний код: SearchAndReplace (эталон массового transaction), page-editor.tsx (точки подключения горячих клавиш), extensions.ts (регистрация расширений).