[feature][editor] Множественные курсоры (multi-cursor editing): MVP «выделить все вхождения + одновременный ввод» #196

Open
opened 2026-06-25 22:40:57 +03:00 by Ghost · 0 comments

Перенесено из docs/multi-cursor-editing-plan.md (файл удалён; этот issue — единственный носитель плана).


Множественные курсоры (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. Что уже есть в коде и переиспользуемо

В проекте уже есть расширение SearchAndReplaceeditor-ext, подключено и в клиентском редакторе). Это почти готовый фундамент для главного сценария multi-cursor:

  • search-and-replace.ts:100-174processSearches уже находит все вхождения терма и возвращает массив results: Range[] (диапазоны from/to).
  • search-and-replace.ts:157-168 — уже рисует Decoration.inline для всех совпадений одновременно (это переи��пользуется для подсветки «активных» курсоров).
  • search-and-replace.ts:213-246replaceAll уже выполняет массовую правку в одном 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);
    • handleKeyDownBackspace/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 + KeyLselectAllOccurrences;
  • platformModifierKey + KeyDaddNextOccurrence;
  • EscapeexitMultiCursor.

⚠️ Проверить конфликт 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. Открытые вопросы

  1. Выделение диапазонов vs точечные курсоры. В VS Code Ctrl/Cmd+Shift+L выделяет целые слова (диапазоны). Делаем ли мы в MVP то же (диапазоны + одновременная замена всего слова), или только точечные каретки после конца слова? Рекомендация: диапазоны — это даёт «переименовать все эти слова сразу», что и есть главная ценность.
  2. Общая утилита поиска. Вынести processSearches из search-and-replace в общую утилиту, чтобы не дублировать, или оставить независимую реализацию в multi-cursor? Рекомендация: вынести общую часть (поиск всех вхождений слова по документу), оба расширения используют её.
  3. Граница производительности. Ввести ли хард-кап на число одновременных курсоров (например, 100) с предупреждением пользователю? Рекомендация: да, как страховка.

8. Источники

> Перенесено из `docs/multi-cursor-editing-plan.md` (файл удалён; этот issue — единственный носитель плана). --- # Множественные курсоры (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](apps/client/src/features/editor/extensions/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](https://github.com/ueberdosis/tiptap/issues/3370) «Multiple cursors per user» — открыт, официальной поддержки нет. - Ответ **marijnh** (автор ProseMirror) на [discuss.prosemirror.net](https://discuss.prosemirror.net/t/multi-cursor-editing-in-prosemirror-or-tiptap/8397): готовой реализации нет, но путь обозначен — **«кастомный подкласс `Selection`, по аналогии с `CellSelection` из `prosemirror-tables`, который умеет содержать несколько отдельных диапазонов»**. - Production-готового пакета multi-cursor для Tiptap/ProseMirror в npm **нет** — пилить с нуля. **Вывод:** полноценный multi-cursor — это R&D-проект против устройства движка, а не настройка. Но самый ценный сценарий («поправить повторяющиеся одинаковые куски сразу в нескольких местах») реализуем дёшево, потому что массовая правка в одном transaction у нас уже написана. ## 2. Что уже есть в коде и переиспользуемо В проекте уже есть расширение [SearchAndReplace](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts) (в `editor-ext`, подключено и в клиентском редакторе). Это почти готовый фундамент для главного сценария multi-cursor: - [search-and-replace.ts:100-174](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L100-L174) — `processSearches` уже находит **все** вхождения терма и возвращает массив `results: Range[]` (диапазоны `from`/`to`). - [search-and-replace.ts:157-168](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L157-L168) — уже рисует `Decoration.inline` для **всех** совпадений одновременно (это переи��пользуется для подсветки «активных» курсоров). - [search-and-replace.ts:213-246](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L213-L246) — `replaceAll` уже выполняет **массовую правку в одном transaction**, идя **с конца**, чтобы корректно учитывать сдвиг позиций после каждой вставки/удаления. Это ровно та механика, что нужна для одновременного ввода в несколько курсоров. ```ts // 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](apps/client/src/features/editor/page-editor.tsx#L258-L280) есть блок `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](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts) почти готовыми. **Визуальные каретки:** через `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](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts#L213-L246)); - `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](apps/client/src/features/editor/page-editor.tsx#L258-L280) (там уже есть `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](apps/client/src/features/editor/extensions/extensions.ts) (оно не зависит от коллаборации, поэтому идёт в основной набор, доступный и в обычном, и в коллаборативном редакторе). ### 6.5. CSS Рядом с [collaboration.css](apps/client/src/features/editor/styles/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. Открытые вопросы 1. **Выделение диапазонов vs точечные курсоры.** В VS Code `Ctrl/Cmd+Shift+L` выделяет целые слова (диапазоны). Делаем ли мы в MVP то же (диапазоны + одновременная замена всего слова), или только точечные каретки после конца слова? Рекомендация: диапазоны — это даёт «переименовать все эти слова сразу», что и есть главная ценность. 2. **Общая утилита поиска.** Вынести `processSearches` из search-and-replace в общую утилиту, чтобы не дублировать, или оставить независимую реализацию в multi-cursor? Рекомендация: вынести общую часть (поиск всех вхождений слова по документу), оба расширения используют её. 3. **Граница производительности.** Ввести ли хард-кап на число одновременных курсоров (например, 100) с предупреждением пользователю? Рекомендация: да, как страховка. ## 8. Источники - [Tiptap issue #3370 — Multiple cursors per user](https://github.com/ueberdosis/tiptap/issues/3370) - [discuss.ProseMirror — Multi-cursor editing in ProseMirror (ответ автора ProseMirror о кастомном подклассе Selection)](https://discuss.prosemirror.net/t/multi-cursor-editing-in-prosemirror-or-tiptap/8397) - `prosemirror-tables` / `CellSelection` — референс реализации «выделения из нескольких диапазонов» для Варианта B. - Внутренний код: [SearchAndReplace](packages/editor-ext/src/lib/search-and-replace/search-and-replace.ts) (эталон массового transaction), [page-editor.tsx](apps/client/src/features/editor/page-editor.tsx) (точки подключения горячих клавиш), [extensions.ts](apps/client/src/features/editor/extensions/extensions.ts) (регистрация расширений).
Ghost added the feature label 2026-06-25 22:40:57 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#196