Прочтите исходную статью и другие материалы в Новом инженерном блоге Grammarly!

Когда пользователь устанавливает расширение Grammarly, он ожидает, что оно будет работать без проблем. Не имеет значения, набирают ли они сообщения в Gmail, публикуют ли они сообщения в Facebook или создают комментарий в ветке Reddit. Grammarly должен выглядеть так, как будто он встроен в любой веб-сайт, на котором он используется.

Но сайтов очень много. Согласно этой статистике, на сегодняшний день в сети находится около двух миллиардов сайтов. Как мы узнаем, что Grammarly отлично работает на каждом веб-сайте, если мы не протестируем их все? И как мы узнаем, что, как только мы начнем работать с Grammarly на веб-сайте, он не изменится внезапно, сводя на нет всю нашу тяжелую работу?

Каждый веб-сайт индивидуален, поэтому универсальное решение невозможно. Некоторые сайты используют простые поля <textarea /> или <div contenteditable=”true” />, некоторые используют один из популярных редакторов форматированного текста с открытым исходным кодом, например Draft.js или Quill.js, некоторые даже создают собственные проприетарные механизмы редактирования текста. И каждый сайт имеет свой уникальный макет и стиль.

По старому

Самой большой проблемой в улучшении совместимости расширения Grammarly всегда было подчеркивание нашей подписи. Это не только сложнее всего реализовать, но и основная функциональность. Общедоступного API для подчеркивания в нативном браузере, которое используется встроенной проверкой орфографии, не существует. Итак, мы должны реализовать свои собственные. Рассмотрим это простое contenteditable текстовое поле:

Исходный HTML-код прост:

<div contenteditable=”true”>
  That its when I decided getting on the bike.
</div>

Когда вы запустите Grammarly в этом поле, он найдет в тексте пару ошибок и подчеркнет их. Вот результат:

Выглядит хорошо! Но что происходит в DOM?

<div contenteditable=”true” data-gramm_id=”d1a428f0–40fd-1fca-a617-acf5aeeaa147" data-gramm=”true” spellcheck=”false” data-gramm_editor=”true”>That <g class=”gr_ gr_4 gr-alert gr_spell gr_inline_cards gr_run_anim ContextualSpelling ins-del” id=”4" data-gr-id=”4">its</g> when I decided <g class=”gr_ gr_3 gr-alert gr_gramm gr_inline_cards gr_run_anim Grammar multiReplace” id=”3" data-gr-id=”3">getting</g> on the bike.</div><grammarly-btn><div class=”_1BN1N-textarea_btn _Kzi1t-show _1v-Lt-errors _3MyEI-has_errors _MoE_1-anonymous _2DJZN-field_hovered” style=”z-index: 2; transform: translate(359px, 89px);”><div class=”_1HjH7-transform_wrap”><div title=”Found 2 errors in text” class=”_3qe6h-status”>2</div></div></div></grammarly-btn>

Уже не так хорошо, даже если он выполняет свою работу.

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

Кажется, что это работает хорошо, и во многих случаях это так, но с этим подходом есть несколько проблем. Во-первых, смешивая текст и подчеркивание, мы загрязняем содержимое текстового поля. Текстовое поле должно содержать только фактический текст, и большая часть кода, работающего в Интернете, ожидает, что это так - и это правильно. Если Grammarly не удалит лишние узлы подчеркивания до того, как код веб-сайта сможет их заметить, это может привести к повреждению текста пользователя, сбою кода веб-сайта или к тому, что электронное письмо будет содержать подчеркивание при отправке. Ой. И в большинстве случаев невозможно узнать, когда именно следует удалить подчеркивание.

Другая, возможно, даже более серьезная проблема с этим подходом заключается в том, что веб-сайтам не нравится, когда кто-то пытается изменить их DOM. Код, работающий на сайте, ожидает, что созданная им модель DOM не будет изменена каким-либо другим кодом. Если это предположение неверно, могут случиться плохие вещи. Поэтому разработчики веб-сайтов и текстовых редакторов начали искать способы отключить Grammarly, в том числе такие популярные редакторы, как ProseMirror, Quill.js, Draft.js и многие другие.

Новое направление

Если расширение Grammarly не работает должным образом с редакторами форматированного текста и фреймворками пользовательского интерфейса, люди собираются удалить его. Многие веб-сайты используют поля форматированного текста (форматирование, изображения, упоминания и т. Д.), И, вероятно, даже больше используют фреймворк пользовательского интерфейса, такой как Ember. Если Grammarly не работает в этих случаях, со временем он будет работать на все меньшей и меньшей части Интернета.

Пришло время изменить принцип работы расширения Grammarly. Нам нужно было прекратить добавлять узлы подчеркивания внутри текстовых полей и быть очень осторожными с любыми модификациями DOM. Если мы собирались вообще вносить какие-либо изменения в DOM, нам нужно было убедиться, что код, работающий на странице, не заметит их.

Но нам все еще нужен был способ отображения подчеркивания.

Прототип

Оказывается, нетрудно создать простое доказательство концепции. Предположим, мы хотим отобразить элементы подчеркивания за пределами текстового поля и не вносить никаких изменений в само поле. Это означает, что подчеркивание должно быть поверх него. Чтобы это произошло, нам нужно точно знать, где должно располагаться каждое подчеркивание. Этого можно добиться с помощью API Range.getClientRects (). Этот метод позволяет получить список прямоугольников, занимающих определенный диапазон текста на экране.

Вот ранний прототип. Это менее 200 LoC, включая все стандартные. И это дает следующий результат:

В DOM текстовое поле чистое:

<body>
  <div contenteditable=”true” spellcheck=”false”>
    That its when I decided getting on the bike.
  </div>
  <!-- boilerplate html omitted -->
  <highlights>
    <div>
      <div style=”position: fixed; top: 31px; left: 47px; width: 15px; height: 3px; background: rgba(255, 0, 0, 0.5);” />
      <div style=”position: fixed; top: 31px; left: 168px; width: 45px; height: 3px; background: rgba(255, 0, 0, 0.5);” />
    </div>
  </highlights>
</body>

Обратите внимание, что контейнер подчеркиваемых элементов является самым последним элементом тела документа. Каждое подчеркивание располагается точно поверх соответствующего фрагмента текста.

Отслеживание положения текстового поля

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

Понятно, что подчеркивания нужно обновлять при изменении текста. Но что, если текст останется прежним, но изменится расположение текстового поля (или элементов вокруг него)?

Поскольку мы хотим, чтобы интерфейс Grammarly выглядел безупречно и «нативно», чрезвычайно важно, чтобы наш пользовательский интерфейс идеально совпадал с тем, что уже размещено на странице. Здесь мало места для ошибок, особенно с подчеркиванием - они должны быть точными и выглядеть как единое целое с текстом.

Для этого нам необходимо надежно и точно знать размер, положение и стиль текстового поля - в любое время. Запросить эти значения достаточно просто: API Element.getBoundingClientRect () и Window.getComputedStyle () предоставляют именно это. Проблема здесь в том, что нет API или события, чтобы узнать, когда эти значения изменяются.

Мы можем попробовать заменить отсутствие событий эвристикой. Подумайте об этом: что должно произойти на странице, чтобы изменить положение, размер или стиль текстового поля? Вот что у нас получилось:

  • Изменение содержимого текстового поля: в зависимости от того, что находится в текстовом поле, оно может иметь разный размер и даже положение в некоторых случаях.
  • Прокрутка окна: может изменять клиентскую позицию элемента
  • Изменение размера окна: можно изменить макет всей страницы, включая целевой элемент
  • Прокрутка контейнера: если элемент находится внутри прокручиваемого контейнера, его положение может измениться при прокрутке. То же самое для всех прокручиваемых предков элемента.
  • Прокрутка текстового поля (так как нам нужно отслеживать положение самого текста)
  • Изменение атрибута стиля и класса
  • Другие атрибуты элемента (например, cols, rows, wrap для <textarea />)
  • Все вышеперечисленное, кроме предков и всех остальных элементов на странице
  • Добавление и удаление элементов в другом месте страницы (изменение какого-либо удаленного элемента может привести к изменению макета для нашего текстового поля)
  • Глобальные изменения таблицы стилей: вставка или удаление правил CSS и таблиц стилей

Для каждого из вышеперечисленных, кроме глобальных изменений таблицы стилей, есть событие, на которое вы можете подписаться. Однако событие для изменения DOM, API MutationObserver, лучше использовать экономно. При неправильном использовании это может снизить производительность всей страницы. Поэтому мы не можем просто слепо поставить MutationObserver на элемент документа, чтобы получать уведомление о каждом изменении на странице.

Что еще мы можем сделать? Есть интересный API IntersectionObserver, который может уведомить вас об изменении значения видимой области элемента. Более того, он также даст вам клиентский прямоугольник элемента в обратном вызове, что нам и нужно. Хотя IntersectionObserver на самом деле не предназначен для такого использования, немного взломав, мы можем заставить его делать то, что нам нужно во многих случаях.

Это дает нам хорошую отправную точку для определения того, когда изменяется размер, положение или стиль текстового поля, но он все же не охватывает все случаи. Например, при изменении размера соседнего элемента может измениться положение текстового поля. Если мы в отчаянии, мы можем попытаться прикрыть подобные случаи другой довольно слабой эвристикой:

  • Клавиши окна и события мыши. Часто изменения на странице происходят как реакция на ввод данных пользователем.

Подписка на эти мероприятия улучшит охват нашего случая, но все равно не даст нам 100% покрытия. Учитывая это, а также тот факт, что Safari вообще не поддерживает IntersectionObserver, остается один последний вариант:

  • Опрос: Независимо от любых других сигналов, мы собираемся проверять текстовое поле примерно каждую секунду, чтобы увидеть, не изменилось ли что-нибудь.

Хотя это не приведет к постоянному выравниванию по пикселям, это обеспечит необходимый запасной вариант для обеспечения выравнивания подчеркивания в конечном итоге.

Представление

Хорошо, если мы уже проводим опрос, почему бы нам просто не опросить положение и стили текстовых полей с максимально возможной частотой? Конечно, если мы опрашиваем браузер с лимитом отображения 60 раз в секунду, у нас всегда будут актуальные значения для каждого возможного кадра? И это будет намного проще реализовать, чем кучу эвристик и хаков. Почему мы вообще не подумали об этом? Что ж, я думаю, вы уже знаете, к чему я клоню.

Как упоминалось выше, получение позиции, размера и стиля элемента включает в себя вызовы getBoundingClientRect () и getComputedStyle (). Когда вы вызываете эти методы, браузеру может потребоваться принудительно выполнить синхронную компоновку / перекомпоновку. Раньше это было и, вероятно, остается частым источником проблем с производительностью на высокодинамичных веб-страницах. И мы действительно не можем позволить себе создавать еще больше проблем с производительностью с нашим расширением. Если вы попытаетесь вызвать getBoundingClientRect элемента текстового поля 60 раз в секунду на популярном сайте, таком как facebook.com, он может легко потреблять более 90% ресурсов ЦП на среднем оборудовании.

Но это еще не все. Мы использовали Range.getClientRects (), чтобы получить координаты подчеркивания - он также находится в списке вызовов, вызывающих макет / перекомпоновку. И, к сожалению, нам приходится использовать его много: один раз для каждого подчеркнутого фрагмента текста каждый раз, когда изменяется текстовое содержимое, положение, размер или стиль текстового поля. Так что если много подчеркнутых слов, это может привести к значительному падению производительности. Это особенно заметно с событиями прокрутки: пользователи ожидают, что прокрутка будет плавной. Но если мы принудительно раскроем макет с помощью этих вызовов в обработчике событий прокрутки, это не будет гладко.

Один из способов уменьшить количество getClientRects() вызовов - расположить подчеркивание относительно текстового поля. Мы уже запрашиваем позицию текстового поля, чтобы проверить, изменилось ли оно с прошлого раза, поэтому мы можем использовать это значение для:

  1. переводим клиентские координаты, полученные от Range.getClientRects(), в координаты смещения относительно элемента текстового поля
  2. расположите контейнер подчеркиваемых элементов прямо над текстовым полем

Предостережение заключается в том, что, поскольку теперь нам нужна клиентская позиция текстового поля для перевода подчеркнутой клиентской позиции, мы должны запрашивать оба значения одновременно. Это означает, что для каждого пакета Range.getClientRects() вызовов нам нужно сделать хотя бы один getBoundingClientRect() вызов элемента текстового поля. Это связано с тем, что положение текстового поля могло измениться с момента последнего запроса, и если мы используем устаревшее значение, мы получим неверные координаты подчеркивания.

При этой оптимизации нам нужно будет только пересчитать координаты подчеркивания либо при изменении текстового содержимого, либо при изменении размера текстового поля. Для прокрутки и изменения позиции нам потребуется только обновить элемент контейнера подчеркивания.

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

Мое последнее замечание по этой теме касается профилирования и отслеживания производительности. Если вы обычно работаете на высокопроизводительном компьютере (как и многие разработчики), вы можете даже не заметить никаких проблем с производительностью своего программного обеспечения. Проблемы могут возникнуть только тогда, когда ваш код запускается средним пользователем со средним оборудованием. Но если мы обнаруживаем проблемы с производительностью только тогда, когда пользователь сталкивается с ними, тогда уже слишком поздно. К счастью, современные браузеры поставляются с множеством мощных инструментов профилирования производительности, включая регулирование ЦП. Я обнаружил, что это очень полезно, особенно на ранней стадии разработки, когда принимается множество архитектурных решений.

Есть еще проблемы?

Всегда есть больше проблем! До сих пор мы никогда не рассматривали сложные макеты в этой статье: всплывающие диалоги, липкие заголовки, окна чата в правом нижнем углу и т. Д. Наша первоначальная реализация предполагала, что текстовое поле всегда отображается полностью, но это часто не так. Его можно обрезать, если он переполняется в контейнере (который сам может переполняться и т. Д., Рекурсивно). Его также можно накрыть элементом со стилем position: sticky. Первоначальная реализация этого не учитывала.

Хорошая новость заключается в том, что мы можем решить эту проблему, но во многих случаях нам потребуется поместить контейнер подчеркивания ближе к элементу текстового поля в DOM. Однако это риск, поскольку таким образом мы с большей вероятностью повлияем на код, работающий на странице.

В заключение

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

Создание такого расширения связано с множеством проблем, некоторые из которых мы, надеюсь, сможем изучить в будущих публикациях. Мы пока не нашли идеального решения, но с каждым улучшением, которое мы вносим, ​​мы на один шаг приближаем более качественное письмо к миллионам людей - и это конечная цель.