или
, а значение
- // читается textContent: перенос вписываем сами (ADR-069).
- event.preventDefault();
- insert(view, "\n");
- }
+ event.preventDefault();
+ submit(view);
});
- if (view.edit !== "plaintext-only") {
- // plaintext-only приносит из буфера и мыши только текст сам. Без него
- // в блок приезжает чужая разметка, поэтому вставка и перенос — наши.
- view.field.addEventListener("paste", (event) => {
- event.preventDefault();
- insert(view, event.clipboardData?.getData("text/plain") ?? "");
- });
- view.field.addEventListener("drop", (event) => {
- event.preventDefault();
- insert(view, event.dataTransfer?.getData("text/plain") ?? "");
- });
- // Перетаскивание изнутри блока браузер сделал бы переносом: вписал бы
- // текст на новом месте и убрал со старого. Вставку мы делаем сами,
- // отменяя действие по умолчанию, — уносить исходное браузеру нечем,
- // и перенос молча оборачивался бы удвоением. Объявляем копирование:
- // тогда это честная копия (ADR-071).
- view.field.addEventListener("dragstart", (event) => {
- if (event.dataTransfer !== null) {
- event.dataTransfer.effectAllowed = "copy";
- }
- });
- }
return form;
}
-// editing включает редактирование блока и отдаёт значение, которое
-// применилось. Нужен plaintext-only: в нём браузер кладёт в блок только
-// текст, чем бы ни был буфер обмена, и разметке взяться неоткуда.
-//
-// Знают его не все браузеры, и незнакомое значение атрибута делает блок
-// нередактируемым вовсе — то есть строка ввода перестала бы работать молча.
-// Поэтому оно не назначается, а проверяется чтением: не применилось —
-// остаётся "true", а вставку, перетаскивание и перенос строки разбирает
-// composer (ADR-069).
-function editing(field) {
- try {
- field.contentEditable = "plaintext-only";
- } catch {
- // Браузер, которому это значение незнакомо, бросает SyntaxError.
- }
- if (field.contentEditable === "plaintext-only") {
- return "plaintext-only";
- }
- field.contentEditable = "true";
- return "true";
-}
-
-// hint — подсказка в пустой строке ввода (docs/ui.md, «Чат»). placeholder
-// у редактируемого блока не бывает: текст кладётся атрибутом, а рисует его
-// правило CSS через :empty::before. Тот же текст — подпись для экранного
-// диктора: другой у строки ввода нет.
+// hint держит видимую подсказку и доступное имя поля одинаковыми. Один
+// placeholder не заменяет подпись для всех экранных дикторов (ADR-076).
function hint(view, text) {
- view.field.dataset.hint = text;
+ view.field.placeholder = text;
view.field.setAttribute("aria-label", text);
}
-// allow открывает и закрывает ввод: предупреждение о ключе и уход
-// из комнаты гасят и блок, и кнопку «>» (ADR-016, ADR-044). У блока нет
-// disabled — закрытый перестаёт быть редактируемым и говорит об этом
-// экранному диктору.
-function allow(view, on) {
- view.field.contentEditable = on ? view.edit : "false";
- if (on) {
- view.field.removeAttribute("aria-disabled");
- } else {
- view.field.setAttribute("aria-disabled", "true");
+// sizeField растит textarea до семи фактических строк. field-sizing появился
+// в Safari позже самого приложения, поэтому высоту держит нативный rows:
+// сначала даём полю одну строку, читаем полную scrollHeight с переносами и
+// выставляем нужное число. Атрибут не создаёт запрещённого inline-стиля.
+function sizeField(view) {
+ const field = view.field;
+ field.rows = 1;
+ const style = getComputedStyle(field);
+ const line = Number.parseFloat(style.lineHeight);
+ const padding = Number.parseFloat(style.paddingTop) + Number.parseFloat(style.paddingBottom);
+ if (!Number.isFinite(line) || line <= 0 || !Number.isFinite(padding)) {
+ return;
}
+ const rows = Math.ceil((field.scrollHeight - padding) / line);
+ field.rows = Math.max(1, Math.min(COMPOSER_ROWS, rows));
+}
+
+// allow открывает и закрывает ввод: предупреждение о ключе и уход
+// из комнаты гасят и поле, и кнопку «>» (ADR-016, ADR-044).
+function allow(view, on) {
+ view.field.disabled = !on;
view.send.disabled = !on;
}
-// cap держит предел до вставки: обрезается то, что приходит в блок,
-// а набранное остаётся на месте — так же, как считал maxLength у textarea
-// (ADR-071). Влезающее браузер вставляет сам: тогда и отмена (cmd+z)
-// остаётся его, а не нашей.
-//
-// Композицию IME не трогаем вовсе: отменённая на полуслове, она уносит
-// с собой и подтверждённый ввод. Лишнее в ней снимет fit на compositionend.
-function cap(view, event) {
- if (event.isComposing || event.inputType === "insertCompositionText") {
- return;
- }
- if (!event.inputType.startsWith("insert")) {
- return;
- }
- // Перенос строки и прочее без текста считаем одним символом: тогда cap
- // вмешивается только тогда, когда места не осталось совсем.
- const text = event.data ?? event.dataTransfer?.getData("text/plain") ?? "";
- const range = target(view, event);
- if ((text === "" ? 1 : text.length) <= free(view, range)) {
- return;
- }
- event.preventDefault();
- insert(view, text, range);
-}
-
-// target — куда пойдёт вставка: диапазон, который браузер собирается
-// заменить (beforeinput знает его точно — это не всегда выделение:
-// вставка мышью идёт туда, куда отпустили), иначе выделение в блоке.
-function target(view, event = null) {
- const ranges = event?.getTargetRanges?.() ?? [];
- if (ranges.length > 0) {
- const range = document.createRange();
- range.setStart(ranges[0].startContainer, ranges[0].startOffset);
- range.setEnd(ranges[0].endContainer, ranges[0].endOffset);
- return view.field.contains(range.commonAncestorContainer) ? range : null;
- }
- const selection = getSelection();
- const now = selection !== null && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
- return now !== null && view.field.contains(now.commonAncestorContainer) ? now : null;
-}
-
-// free — сколько символов ещё влезет туда, куда идёт вставка: предел минус
-// то, что останется от набранного, когда заменяемое уйдёт.
-function free(view, range) {
- return view.limit - (value(view).length - (range === null ? 0 : range.toString().length));
-}
-
-// value — набранное так, как оно уедет собеседнику. В plaintext-only
-// браузер держит последнюю строку вторым «\n» в самом конце: в textContent
-// он виден, а в сообщении его нет — его снимает trim в sync.send. Считаем
-// и режем по тому же, что отправляем (ADR-071). В запасном пути последнюю
-// строку держит
, которого в textContent нет вовсе, и перенос в конце
-// там настоящий.
-function value(view) {
- const text = view.field.textContent;
- return view.edit === "plaintext-only" && text.endsWith("\n") ? text.slice(0, -1) : text;
-}
-
-// cut режет текст по пределу, не разрывая суррогатную пару: половина пары
-// уехала бы собеседнику битым символом.
-function cut(text, limit) {
- if (limit <= 0) {
- return "";
- }
- if (text.length <= limit) {
- return text;
- }
- const last = text.charCodeAt(limit - 1);
- return text.slice(0, last >= 0xd800 && last <= 0xdbff ? limit - 1 : limit);
-}
-
-// fit — последняя страховка предела (docs/ui.md, «Чат»): лишнее в блок
-// попадает мимо cap — композицией IME или вставкой, о которой браузер
-// не рассказал. Снимается ровно хвост сверх предела, а не переписывается
-// блок целиком: правка узлов оставляет курсор на месте и не сносит стек
-// отмены. Опустевший блок остаётся без узлов: подсказка стоит правилом
-// :empty, а браузер оставляет в опустевшем блоке
, и с ним подсказки
-// не видно.
-function fit(view) {
- const raw = view.field.textContent;
- const text = value(view);
- const keep = cut(text, view.limit);
- if (keep.length < text.length) {
- // Вместе с лишним уходит и заполнитель последней строки, если он есть:
- // он в самом конце, а браузер ставит его снова, когда понадобится.
- trim(view.field, raw.length - keep.length);
- return;
- }
- if (raw === "" && view.field.firstChild !== null) {
- clear(view.field);
- }
-}
-
-// trim снимает с хвоста блока лишние единицы текста, не трогая остального:
-// textContent = … переписал бы блок целиком и унёс бы и курсор, и отмену.
-function trim(field, extra) {
- const walker = document.createTreeWalker(field, NodeFilter.SHOW_TEXT);
- const nodes = [];
- while (walker.nextNode() !== null) {
- nodes.push(walker.currentNode);
- }
- let left = extra;
- for (let i = nodes.length - 1; i >= 0 && left > 0; i -= 1) {
- const node = nodes[i];
- const take = Math.min(left, node.length);
- node.deleteData(node.length - take, take);
- left -= take;
- }
-}
-
-// insert вписывает текст туда, куда идёт вставка, обрезая его по свободному
-// месту. Своими руками, а не execCommand: тот в Chrome разбирает перенос
-// строки в
, а в блоке живёт только текст.
-function insert(view, text, where) {
- // where — диапазон, который назвал beforeinput. Его не передали — берём
- // выделение; выделения в блоке нет — вписываем в конец.
- const range = where === undefined ? target(view) : where;
- const fitted = cut(text, free(view, range));
- if (fitted === "") {
- return;
- }
- const node = document.createTextNode(fitted);
- const selection = getSelection();
- if (range === null) {
- view.field.append(node);
- } else {
- range.deleteContents();
- range.insertNode(node);
- }
- if (selection !== null) {
- const at = document.createRange();
- at.setStartAfter(node);
- at.collapse(true);
- selection.removeAllRanges();
- selection.addRange(at);
- }
- // Вставка делит текст на два-три соседних узла; курсор normalize
- // переставляет сам.
- view.field.normalize();
- // Своё изменение блока события input не порождает: счётчик, предел
- // и последнюю строку трогаем руками.
- fit(view);
- tail(view);
- count(view);
-}
-
-// tail держит последнюю пустую строку видимой. Перенос в самом конце
-// браузер не рисует — строки после него нет, — и курсор оставался бы
-// на прежней строке, а набранное дальше уезжало бы перед переносом.
-// Пустую строку держит
: тот самый заполнитель, который браузер сам
-// ставит в опустевший блок. В textContent его нет, и на отправляемый текст
-// он не влияет.
-//
-// В plaintext-only это не нужно: там последнюю строку браузер ведёт сам,
-// своим переносом, и лишний заполнитель дал бы вторую пустую строку.
-function tail(view) {
- if (view.edit === "plaintext-only") {
- return;
- }
- const last = view.field.lastChild;
- const wants = view.field.textContent.endsWith("\n");
- if (wants && (last === null || last.nodeName !== "BR")) {
- view.field.append(document.createElement("br"));
- return;
- }
- if (!wants && last !== null && last.nodeName === "BR") {
- last.remove();
- }
-}
-
// count — счётчик остатка: появляется после порога (docs/ui.md, «Чат»).
-// Считается то, что уедет: заполнитель последней строки в счёт не идёт
-// (ADR-071).
function count(view) {
- const length = value(view).length;
+ const length = view.field.value.length;
view.counter.textContent = String(view.limit - length);
view.counter.hidden = length <= COUNTER_AT;
}
function submit(view) {
- const text = view.field.textContent;
+ const text = view.field.value;
if (view.blocked || view.gone || text.trim() === "") {
return;
}
- view.field.textContent = "";
+ view.field.value = "";
+ sizeField(view);
count(view);
run(view, () => sync.send(view.chatId, text));
}
@@ -804,8 +588,8 @@ function line(view, record, previous, parent = view.body) {
view.nodes.set(record.id, { node, marks });
}
-// lineClass — своя сторона ленты: свои сообщения вправо, чужие влево
-// (ADR-065), классом на самой строке — выравнивает feed-list.
+// lineClass отмечает свои сообщения отдельным классом. На какой стороне он
+// окажется, CSS решает по основному указателю устройства (ADR-077).
function lineClass(record, me, first) {
const classes = ["line"];
if (record.from === me) {
diff --git a/web/js/zoom.js b/web/js/zoom.js
index 2b6c0b3..809d7a8 100644
--- a/web/js/zoom.js
+++ b/web/js/zoom.js
@@ -59,11 +59,7 @@ function tap(event) {
// дважды подряд — обычное дело. Цена мала: до элементов управления зум
// по двойному тапу не доходит и без страховки — `touch-action:
// manipulation` стоит на документе.
-//
-// Строка сообщения — редактируемый блок, а не textarea (ADR-069), и она
-// в этом перечне: погашенный тап не доносит до неё ни click, ни фокус,
-// то есть второй тап подряд не ставил бы курсор в поле ввода.
function control(target) {
return typeof target?.closest === "function"
- && target.closest("button, input, textarea, select, label, a, [contenteditable]") !== null;
+ && target.closest("button, input, textarea, select, label, a") !== null;
}
diff --git a/web/sw.js b/web/sw.js
index ca5076d..c0f05e1 100644
--- a/web/sw.js
+++ b/web/sw.js
@@ -7,7 +7,7 @@
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
// и старые кэши уходят в activate.
-const VERSION = "v6";
+const VERSION = "v7";
const CACHE = `bare-${VERSION}`;
// Оболочка — всё, из чего клиент поднимается без сети. Список явный: