Перейти к содержанию

Связывание Y.Text с текстовым полем

В первом уроке «Черновик» показывал строковое представление Y.Text, но не принимал ввод. Теперь добавим привязку между общим типом и обычным textarea. Привязкой называют слой, который переводит действия интерфейса в изменения модели и отражает изменения модели обратно. Она нужна даже без сети: иначе значение поля и содержимое документа постепенно станут двумя независимыми состояниями.

Полный каталог lesson-02 продолжает имена и начальные данные предыдущего состояния. В нём появился файл src/binding.js, а src/app.js подключает его через bindText. Мы используем текстовое поле ради понятного разбора операций. Для промышленного Markdown-редактора с выделением, сложным вводом и плагинами обычно выбирают специализированную binding-интеграцию, учитывающую конкретную модель редактора.

Почему не заменяем весь текст

При обычном вводе браузер уже изменил textarea.value. Обработчик получает новую строку, тогда как Y.Text содержит старую. Самый короткий перевод состоял бы из удаления всего общего текста и вставки новой строки. Но такая операция описывает гораздо большую правку, чем сделал пользователь. При совместной работе это способно удалить символы, которые другой участник вставил между двумя локальными событиями.

Поэтому найдём общий префикс и общий суффикс строк. Префикс заканчивается там, где первая последовательность символов различается; суффикс отсчитывается с конца и не пересекает префикс. Средний диапазон старой строки удаляется, средний диапазон новой вставляется. Если Анна добавляет слово в одном пункте, неизменённые заголовок и соседние пункты остаются теми же элементами общего текста.

Ниже дан полный src/binding.js. Функция replaceChangedRange сравнивает именно текущее строковое представление модели с текущим значением поля. Функция bindText организует подписки и возвращает функцию их удаления. Значение INPUT обозначает локальный источник изменений; подробно происхождение транзакций разберём в следующем уроке, но имя уже закреплено в коде.

export const INPUT = Symbol('input');
export const TOOL = Symbol('toolbar');
export function replaceChangedRange(doc, text, value, origin = INPUT) {
  const old = text.toString();
  let start = 0;
  while (start < old.length && start < value.length && old[start] === value[start]) start++;
  let tail = 0;
  while (tail < old.length - start && tail < value.length - start && old[old.length - 1 - tail] === value[value.length - 1 - tail]) tail++;
  doc.transact(() => {
    text.delete(start, old.length - start - tail);
    text.insert(start, value.slice(start, value.length - tail));
  }, origin);
}
export function bindText(doc, text, field, preview) {
  let composing = false;
  const render = () => {
    if (composing) return;
    const a = field.selectionStart, b = field.selectionEnd;
    if (field.value !== text.toString()) {
      field.value = text.toString();
      field.setSelectionRange(Math.min(a, text.length), Math.min(b, text.length));
    }
    preview.textContent = text.toString();
  };
  const input = () => { if (!composing) replaceChangedRange(doc, text, field.value); };
  const begin = () => { composing = true; };
  const finish = () => { composing = false; input(); render(); };
  field.addEventListener('input', input);
  field.addEventListener('compositionstart', begin);
  field.addEventListener('compositionend', finish);
  text.observe(render); render();
  return () => {
    text.unobserve(render);
    field.removeEventListener('input', input);
    field.removeEventListener('compositionstart', begin);
    field.removeEventListener('compositionend', finish);
  };
}

Первый цикл увеличивает start, пока символы совпадают. Второй цикл увеличивает tail, пока совпадает окончание. Ограничения второго цикла не позволяют одному символу одновременно входить в неизменённый префикс и суффикс. При полностью одинаковых строках диапазон удаления имеет нулевую длину, а вставляемая часть пуста. Дополнительная замена документа в таком случае не требуется.

Удаление и вставка выполняются внутри одной транзакции. Наблюдатель видит завершённое изменение диапазона, а не случайное промежуточное состояние между двумя операциями. Сами индексы выражены в единицах UTF-16, как индексы JavaScript-строк и выделения текстового поля. Они не совпадают автоматически с количеством визуальных букв: составные символы, некоторые эмодзи и диакритика требуют осторожного обращения в расширенном редакторе.

Обратное отражение модели

Наблюдатель render переносит содержимое общего текста в поле, только когда строки отличаются. Программное присвоение value не вызывает обычное событие пользовательского input, поэтому само по себе оно не должно создавать бесконечную цепочку правок. Проверка равенства также помогает сохранить выделение при локальной операции: лишнее присвоение могло бы переставить курсор даже при неизменённом содержимом.

После самостоятельного открытия второго состояния ввод нового пункта должен менять и поле, и текстовый предварительный просмотр. Удаление выделенной фразы должно удалять тот же диапазон модели. Это ожидаемые наблюдения, а не результаты проведённого запуска. Для чтения полезно проследить цепочку: браузер меняет поле, обработчик вычисляет диапазон, транзакция меняет Y.Text, наблюдатель обновляет представление.

При удалённом изменении старый числовой диапазон выделения может стать недействительным. Учебная привязка ограничивает индексы новой длиной, чтобы не выходить за границы поля, но не обещает семантически сохранить место курсора. Если Борис вставил строку перед фразой Анны, тот же числовой индекс теперь указывает на другой символ. Позднее это можно решать относительными позициями Yjs или средствами готовой binding-интеграции.

Составной ввод

Ввод через IME, распознавание и некоторые мобильные клавиатуры состоит из промежуточных состояний. Пока идёт composition, пользователь ещё выбирает окончательную последовательность символов. Немедленное переприсваивание поля со стороны наблюдателя способно прервать этот процесс. Поэтому обработчик начала композиции включает флаг, а обычное отражение модели временно прекращается.

Когда приходит compositionend, флаг снимается, итоговое значение переводится в операцию над общим текстом, затем интерфейс снова отражает модель. Этот приём достаточен для объяснения жизненного цикла событий, однако у него есть существенная граница. Если удалённая правка пришла в изменяемый диапазон во время композиции, наша простая привязка не выполняет полноценное преобразование пользовательского намерения относительно неё.

Такое ограничение необходимо учитывать до выбора интерфейса выпуска. Например, нельзя обещать одинаковое поведение всех языковых клавиатур только потому, что обычный латинский ввод выглядит правильно. Для серьёзного редактора следует взять binding, который хранит редакторскую позицию и согласует удалённые изменения с composition. Замена всего документа при завершении композиции тоже не становится универсальным исправлением этой проблемы.

Снятие обработчиков

Возвращаемая функция удаляет наблюдателя текста и три обработчика поля. Если открыть документ повторно без такого удаления, один пользовательский ввод может обрабатываться несколькими подписками. Внешне это проявляется повторными вычислениями или неожиданными побочными эффектами, которые ошибочно связывают с CRDT. В действительности причиной становится повторное подключение представления к той же странице.

Отдельно рассмотрите вставку большой строки из буфера. Алгоритм ищет границы по символам и выполняет проходы по строкам; он удобен для небольшого учебного черновика, но не является оптимальным редакторским движком для огромных документов. Сохранение полного строкового представления также занимает память. Выбор настоящего редактора должен учитывать размер материалов, частоту изменений и используемые операции.

Для разбора результата измените слово «расписание» на «сроки», оставив заголовок и остальные символы прежними. Средний диапазон изменится, а края останутся неизменными. Затем попробуйте вставить строку в начало и объясните, почему суффикс сохраняется целиком. Оба варианта развивают один механизм: перевод видимого изменения в ограниченные операции над общим текстом. Следующий урок добавит групповые действия и различение источников транзакций.

Оглавление серии · Исходные состояния проекта.

API сверены с первичной документацией 11 октября 2026 года. Пример имеет статус not_run: фактический запуск и наблюдение результата читателю предстоит выполнить самостоятельно.