textContent в блоке Тильды съедает ссылку tel: и переносы строк
Один элемент Zero Block — это не строка, а разметка. «190 лет» лежит в нём вместе с переносом строки, телефон — внутри ссылки tel, и запись в textContent заменяет всё это одной строкой: перенос исчезает, ссылка исчезает вместе с возможностью позвонить в один тап. Ошибки в консоли не будет. Единица правки здесь — текстовый узел, а не элемент.
Задача выглядит на две строки: найти элемент, написать в него новое значение. Именно так её и решают — и получают страницу, на которой телефон перестал быть ссылкой, а заголовок «190 лет опыта» слился в одну строку. Ни одной ошибки в консоли. Разбираемся, что именно лежит внутри текстового элемента Тильды и какая операция его не портит.
Короткий ответ
Не писать в textContent и в innerText. Оба свойства при записи заменяют всё
содержимое элемента одной строкой, а содержимого там больше, чем видно: перенос
живёт тегом <br>, телефон — ссылкой <a href="tel:…">, отдельные слова
завёрнуты в <span> со своими стилями. Править надо текстовый узел: nodeValue в
нём меняется, не задевая ни теги, ни атрибуты, ни обработчики Тильды.
Текстовый атом Zero Block — это разметка, а не строка
Откройте инспектор на артборде и разверните любой текстовый элемент. Внутри
.tn-atom окажется примерно такое:
<div class="tn-atom">190 лет<br>опыта</div>
А у элемента с телефоном — такое:
<div class="tn-atom"><a href="tel:88002222824">8-800-222-28-24</a></div>
Оба случая делает дизайнер обычными средствами панели: перенос — клавишей Shift+Enter, телефон — вставкой ссылки. В редакторе это выглядит как текст, и в вашем коде это выглядит как текст. В DOM это два и три узла соответственно.
То же верно и для обычных блоков: .t-title и .t-descr тоже держат внутри
переносы и ссылки, просто в Zero Block это встречается чаще, потому что там
дизайнер верстает свободно.
Что теряется, и почему в консоли чисто
Запись в textContent — это удаление всех дочерних узлов и вставка одного
текстового. Формально операция выполнена, поэтому браузеру не о чем сообщать.
Теряется при этом ровно то, что было размечено:
| Что было в атоме | После записи в textContent | Чем это видно посетителю |
|---|---|---|
190 лет<br>опыта | 190 лет опыта одной строкой | Заголовок поехал по ширине, макет сломался на телефоне |
<a href="tel:…">…</a> | просто цифры | По номеру больше нельзя позвонить в один тап |
<span style="…">₽</span> | ₽ без стилей | Символ стал другого размера или цвета |
| ссылка с обработчиком Тильды | обработчика нет | Клик перестал работать, ошибки нет |
Последняя строка — самая коварная. Тильда навешивает обработчики на элементы внутри блоков, и они живут на конкретных узлах. Узел заменён — обработчик потерян; ничего не падает, просто клик больше ничего не делает.
Чтение textContent даёт строку, которую нельзя разбирать
Обратная сторона того же различия. Прочитать текст атома целиком тоже нельзя — точнее, можно, но результату нельзя верить:
el.textContent; // '190 летопыта'
Перенос строки был тегом, а не символом, поэтому в строке на его месте нет
ничего: слова склеились. Дальше эта строка обычно попадает в разбор — «взять
первое число», «отрезать пояснение», — и разбор видит 190 летопыта вместо
190 лет.
Именно поэтому проверка «число в поле нашлось» проходит, а результат неверен. В моде-счётчике разбирается значение узла, а не элемента, и правила разбора строгие: строка обязана быть числом целиком, с коротким пояснением по краям.
// Дефис между цифрами — телефон, диапазон, артикул. Не наше дело.
if (/\d\s*[-—–]\s*\d/.test(trimmed)) return null;
var m = trimmed.match(/^(\D{0,3}?)(\d[\d\s.,]*)(\D{0,10})$/);
if (!m) return null;
«190 лет» проходит, «12 500 ₽» проходит, «Бесплатная доставка от 2000 рублей» не
проходит, 8-800-222-28-24 не проходит. Без разделения на узлы такие правила
написать невозможно: в склеенной строке нет границ, по которым решать. Готовый код
с этим разбором лежит в каталоге, в разделе
эффектов текста.
Единица работы — текстовый узел
Обход узлов пишется один раз и выглядит так:
function textNodes(el) {
var out = [];
var node;
var walker = document.createTreeWalker(el, 4 /* SHOW_TEXT */, null);
while ((node = walker.nextNode())) {
var tag = node.parentNode && node.parentNode.tagName;
// Внутри script и style цифры — это код, а не текст страницы.
if (tag === 'SCRIPT' || tag === 'STYLE') continue;
out.push(node);
}
return out;
}
Три детали, каждая из которых нужна.
SHOW_TEXT — это 4. Константа NodeFilter.SHOW_TEXT существует, но её
приходится писать числом там, где код должен работать без сборки и в старых
браузерах; тогда рядом обязателен комментарий, иначе через месяц никто не
объяснит, что такое 4.
Пропуск SCRIPT и STYLE — не перестраховка. Внутри секции Тильды почти всегда
есть свой <style> с правилами для элементов артборда, и цифры из 0.3s или
#rec1024570506 попадут в обход, если его не поставить.
Обход, а не прямые дети. Текст может лежать на два уровня глубже: внутри ссылки,
внутри <span>, внутри выделения жирным.
Где хранить свои метки, если узлов в поле два
Как только правится узел, а не элемент, появляется вопрос: где держать состояние.
На узле его держать нельзя — у текстового узла нет ни атрибутов, ни dataset.
Значит, метка живёт на элементе-хозяине, и это создаёт неочевидную задачу: в одном
поле может быть два числа, а хозяин у них один.
Правильно так: метка ставится на хозяина до первой записи, а счётчик незакрытых правок держится на хозяине же.
host.setAttribute('data-mk-count', ''); // до записи, а не после
node.nodeValue = item.render(from);
Зачем метка до записи — за этим стоит вторая половина статьи: по ней потом отличают свою правку от чужой. Зачем счётчик — вот зачем:
// Ширину и метку отпускает то число, которое досчитало последним.
if (h !== -1 && --pending[h] > 0) return;
Пока число растёт, растёт и его ширина, и в обычном блоке соседи прыгали бы на каждом кадре, — поэтому на время счёта поле получает фиксированную ширину. Если эту ширину снимет первое досчитавшее число, второе продолжит менять текст в поле без фиксации, и прыжки вернутся. Отпускать поле должно последнее.
И третье: возвращать надо не пустоту, а то, что стояло в инлайне до вас.
saved[h] = {
minWidth: host.style.minWidth,
fontVariantNumeric: host.style.fontVariantNumeric,
};
Пустая строка в style.minWidth — тоже значение, и она может быть чужой:
инлайновую ширину элементам артборда пишет сам Zero Block. Ширина ставится
инлайном по той же причине, по которой инлайном ставится прозрачность в анимации:
правило по классу секции не перебивает правило Zero Block с id в селекторе, и
почему это так — отдельный разбор.
Возвращать исходную строку копией, а не пересборкой
Когда правка закончилась, в узле должна стоять ровно та строка, которая была до вас, — символ в символ. Не похожая, а та же:
/* Возвращаем исходную строку целиком, а не собранную заново: так на
месте снова стоит то, что написал дизайнер, включая пробелы и
знаки, которые мы могли не разобрать. */
node.nodeValue = item.text;
Пересборка выглядит безобиднее, чем она есть. В строке дизайнера встречаются
узкий неразрывный пробел между разрядами, обычный неразрывный пробел перед
символом рубля, сам символ ₽, знак процента без пробела, мягкий перенос. Всё
это надо угадать — а угадывать не нужно, потому что исходная строка у вас уже
есть.
То же правило работает и на время самой анимации: формат числа берётся из того, как его написал дизайнер. Чем разделены разряды в макете — пробелом, узким пробелом, запятой или ничем, — тем разделяем и мы:
var groupMatch = body.match(/\d([\s,.])\d{3}/);
var group = groupMatch ? groupMatch[1] : '';
Иначе число на глазах посетителя меняет формат: было «12 500», стало «12500», а в конце снова «12 500».
Как отличить свою правку от правки Тильды
Тильда правит текст на странице сама, и это не предположение. Блок отзывов T605 обрезает длинные цитаты и дописывает многоточие — на той же странице, где никакого пользовательского скрипта нет, правки те же. Поэтому вопрос «мой ли это код сломал текст» решается не рассуждением, а журналом:
new MutationObserver((list) => {
for (const m of list) {
if (m.type !== 'characterData') continue;
const host = m.target.parentElement;
const mine = !!(host && host.hasAttribute('data-mk-count'));
console.log({ was: m.oldValue, now: m.target.nodeValue, mine });
}
}).observe(document, {
characterData: true,
characterDataOldValue: true,
subtree: true,
});
Два условия делают журнал полезным. characterDataOldValue: true — иначе в
oldValue будет null, и сравнить не с чем. И наблюдатель должен быть поставлен
до того, как страница выполнит хоть один свой скрипт: тогда в oldValue
первой правки лежит ровно то, что написал дизайнер.
Флаг mine работает только потому, что метка на хозяине ставится до записи. Если
ставить её после, первая же собственная правка будет записана в журнал как чужая.
Что проверить у себя
- В коде нет записи в
textContentиinnerText. Ни одной. - Правится
nodeValueтекстового узла, узлы найдены обходом. - Обход пропускает
SCRIPTиSTYLE— внутри секции Тильды есть свой<style>. - Разбор строки идёт по узлу, а не по элементу: в склеенной строке нет границ.
- Ссылка
tel:на странице осталась ссылкой — проверьте в инспекторе после того, как скрипт отработал. - Метка на поле ставится до первой записи, а снимается после последней.
- Исходная строка возвращается копией, а не собирается заново.