diff --git a/docs/decisions/024-identity-and-ui.md b/docs/decisions/024-identity-and-ui.md index 036f93e..9d871e0 100644 --- a/docs/decisions/024-identity-and-ui.md +++ b/docs/decisions/024-identity-and-ui.md @@ -1,6 +1,6 @@ # ADR-024: Айдентика «Скобы», интерфейс и язык -Уточнён [ADR-075](075-no-zoom-app-feel.md) (масштабирование запрещено, размеры шрифтов прежние), [ADR-067](067-version-in-sidebar-foot.md) (версия и время коммита в подвале сайдбара) и [ADR-069](069-message-input-is-editable-block.md) (строка сообщения — редактируемый блок). +Уточнён [ADR-075](075-no-zoom-app-feel.md) (масштабирование запрещено, размеры шрифтов прежние), [ADR-067](067-version-in-sidebar-foot.md) (версия и время коммита в подвале сайдбара), [ADR-076](076-ios-input-accessory-is-system.md) (строка сообщения — `textarea`) и [ADR-077](077-message-alignment-by-pointer.md) (стороны сообщений зависят от способа ввода). ## Контекст diff --git a/docs/decisions/064-composer-not-form.md b/docs/decisions/064-composer-not-form.md index ab4856e..fa194af 100644 --- a/docs/decisions/064-composer-not-form.md +++ b/docs/decisions/064-composer-not-form.md @@ -1,5 +1,7 @@ # ADR-064: строка ввода — div, а не form +Пересмотрен [ADR-076](076-ios-input-accessory-is-system.md): ни контейнер, ни вид редактируемого элемента не дают веб-странице управления системной панелью iOS; поле снова стало `textarea`, контейнер `div` оставлен как безвредная деталь реализации. + Уточняет `docs/ui.md`, «Доступность»: `form` в списке семантики держит `role="form"`, а не элемент `
`, — для строки ввода чата. Пересмотрен [ADR-069](069-message-input-is-editable-block.md). Диагноз здесь неверен: полосу помощника форм iOS рисует не из-за ``, а для любых `input` и `textarea`, — вынос поля из формы её не убрал. Контейнер `div` с `role="form"` и кнопка `type="button"` остаются, `textarea` заменён редактируемым блоком — тем самым «следующим шагом», который записан ниже в «Следствиях». @@ -19,7 +21,7 @@ - `` → `
`. `role="form"` — тот же ориентир для скринридера, что и элемент ``, без вызывающих панель heuristics WebKit. - Кнопка отправки — `type="button"` с явным `click`, вместо `type="submit"` и события `submit` на форме. - `autocomplete="off"` на самом поле — на случай, если панель зависит ещё и от него, а не только от ``. -- `textarea`, `maxLength`, `field-sizing: content`, placeholder — без изменений. +- На момент решения `textarea`, `maxLength`, `field-sizing: content` и placeholder оставались без изменений; актуальное состояние строки ввода задаёт пересмотревший это решение ADR-076. Остальные формы (вход, `#/new`, участники, настройки) не тронуты: там несколько полей в форме, панель навигации между ними — по крайней мере, не чистый минус, и незачем менять то, что не жаловались. diff --git a/docs/decisions/065-message-side-alignment.md b/docs/decisions/065-message-side-alignment.md index 211da2f..abf1343 100644 --- a/docs/decisions/065-message-side-alignment.md +++ b/docs/decisions/065-message-side-alignment.md @@ -1,5 +1,7 @@ # ADR-065: сообщения выравниваются по стороне — свои вправо, чужие влево +Уточнён [ADR-077](077-message-alignment-by-pointer.md): стороны остаются на телефоне и планшете, на десктопе все сообщения выравниваются слева. + Пересматривает часть [ADR-024](024-identity-and-ui.md): десктопную сетку ленты «автор 132 px + текст» из `docs/identity/brief.md`, «Компоновка». Уточняет `docs/ui.md`, «Чат». ## Контекст diff --git a/docs/decisions/069-message-input-is-editable-block.md b/docs/decisions/069-message-input-is-editable-block.md index bdde49f..9c28a69 100644 --- a/docs/decisions/069-message-input-is-editable-block.md +++ b/docs/decisions/069-message-input-is-editable-block.md @@ -1,5 +1,7 @@ # ADR-069: Строка сообщения — редактируемый блок, а не поле формы +Пересмотрен [ADR-076](076-ios-input-accessory-is-system.md): проверка на реальном iPhone опровергла главное следствие — системная панель остаётся и у `contenteditable`; строка снова стала `textarea`. + Уточняет [ADR-024](024-identity-and-ui.md) («Чат» и «Доступность» в `docs/ui.md`) и [ADR-075](075-no-zoom-app-feel.md): в перечне элементов управления, по которым второй тап не гасится, прибавился редактируемый блок. Уточнён [ADR-071](071-input-limit-cuts-what-arrives.md): предел держится до вставки и режет приходящее, а не хвост блока. diff --git a/docs/decisions/071-input-limit-cuts-what-arrives.md b/docs/decisions/071-input-limit-cuts-what-arrives.md index 56ee04d..85ece94 100644 --- a/docs/decisions/071-input-limit-cuts-what-arrives.md +++ b/docs/decisions/071-input-limit-cuts-what-arrives.md @@ -1,5 +1,7 @@ # ADR-071: Предел строки сообщения держится до вставки +Пересмотрен [ADR-076](076-ios-input-accessory-is-system.md): после возврата к `textarea` предел снова держит нативный `maxLength`; ручная обработка вставки больше не нужна. + Уточняет [ADR-069](069-message-input-is-editable-block.md): предел 4000 символов держит по-прежнему сам блок, но режет он приходящее, а не набранное. ## Контекст diff --git a/docs/decisions/075-no-zoom-app-feel.md b/docs/decisions/075-no-zoom-app-feel.md index ccdc7d8..aa8c6fd 100644 --- a/docs/decisions/075-no-zoom-app-feel.md +++ b/docs/decisions/075-no-zoom-app-feel.md @@ -2,7 +2,7 @@ Уточняет [ADR-024](024-identity-and-ui.md): типографика из `docs/identity/brief.md` остаётся как записана, а страница перестаёт масштабироваться. -Уточнён [ADR-069](069-message-input-is-editable-block.md): строка сообщения стала редактируемым блоком, и она в перечне элементов управления, по которым второй тап не гасится. +Уточнён [ADR-076](076-ios-input-accessory-is-system.md): строка сообщения снова стала `textarea`; она уже входит в перечень элементов управления, по которым второй тап не гасится. ## Контекст diff --git a/docs/decisions/076-ios-input-accessory-is-system.md b/docs/decisions/076-ios-input-accessory-is-system.md new file mode 100644 index 0000000..99e7d6a --- /dev/null +++ b/docs/decisions/076-ios-input-accessory-is-system.md @@ -0,0 +1,26 @@ +# ADR-076: Панель ввода iOS системная, строка сообщения — `textarea` + +Пересматривает [ADR-064](064-composer-not-form.md), [ADR-069](069-message-input-is-editable-block.md) и [ADR-071](071-input-limit-cuts-what-arrives.md). Уточняет `docs/ui.md`, «Чат» и «Доступность». + +## Контекст + +В v1.1 строка сообщения прошла два варианта: `textarea` вынесли из ``, затем заменили на `contenteditable="plaintext-only"`. Оба решения исходили из предположения, что системная полоса iOS со стрелками и «готово» появляется только у поля формы. + +Проверка v1.1 на реальном iPhone опровергла предположение: полоса осталась и у `contenteditable`. Это часть клавиатурного интерфейса WebKit, а не узел страницы. Web API для её отключения нет. В нативной оболочке внешний вид клавиатуры может менять хост iOS; установленная PWA остаётся веб-страницей и такого доступа не получает. + +`contenteditable` при этом стоил клиенту ручной реализации подсказки, предела, вставки без разметки, переноса строки, `disabled`, счётчика, работы IME и запасного пути для браузеров без `plaintext-only`. Главное следствие ADR-069 не наступило, а цена осталась. + +## Решение + +- Строка сообщения снова становится `textarea` с нативными `placeholder`, `maxLength`, `disabled`, выделением, вставкой и отменой. Число `rows` считается по фактической высоте текста от одной до семи: это сохраняет автовысоту в Safari без поддержки `field-sizing`. Доступное имя повторяет подсказку через `aria-label`, кнопка `>` объявляется как «отправить»; `autocapitalize`, `autocorrect` и `enterkeyhint` остаются прежними. +- Контейнер `div role="form"` и кнопка `type="button"` остаются. Они не убирают системную панель, но и не создают отдельного поведения; менять их ещё раз без причины незачем. +- Системная панель iOS принимается как ограничение веб-клиента. `inputmode="none"` и собственная экранная клавиатура не используются: убрать вместе с панелью системную клавиатуру означает потерять обычный набор, раскладки, диктовку и доступность. +- Нативная iOS-оболочка не добавляется в Bare. Если абсолютное управление панелью станет требованием, это отдельный продуктовый и архитектурный выбор. +- Тап по неинтерактивному месту истории вызывает `blur()` строки сообщения и закрывает клавиатуру. Кнопки и ссылки в ленте сохраняют своё действие. + +## Следствия + +- Строка сообщения снова опирается на браузерное текстовое поле и стала заметно проще. Лишнее не проходит через `maxLength`; ручная обработка `beforeinput`, вставки, IME и текстовых узлов удалена. +- Полоса со стрелками и «готово» на iPhone остаётся. Код и документация больше не обещают веб-странице контроль над системным UI. +- Тап по истории возвращает место ленте без нажатия системной кнопки «готово». +- Проверка самой панели по-прежнему требует реального iPhone; поведение `textarea`, отправки и снятия фокуса проверяется в обычном браузере. diff --git a/docs/decisions/077-message-alignment-by-pointer.md b/docs/decisions/077-message-alignment-by-pointer.md new file mode 100644 index 0000000..f180222 --- /dev/null +++ b/docs/decisions/077-message-alignment-by-pointer.md @@ -0,0 +1,21 @@ +# ADR-077: На десктопе все сообщения слева, на touch свои справа + +Уточняет [ADR-065](065-message-side-alignment.md) и `docs/ui.md`, «Чат». + +## Контекст + +ADR-065 развёл свои и чужие сообщения по сторонам на всех экранах. На телефоне и планшете сторона ускоряет чтение короткой узкой ленты. На десктопе широкая лента даёт обратный эффект: взгляд ходит между краями, хотя цвет собственного ника уже отличает автора. + +Порог ширины не отличает тип устройства. Планшет бывает шире небольшого окна ноутбука и не должен менять привычную мобильную сторону только из-за поворота экрана. + +## Решение + +- На устройстве с точным основным указателем и наведением (`hover: hover` и `pointer: fine`) все блоки сообщений выравниваются по левому краю. +- В остальных случаях сохраняется ADR-065: свои сообщения справа, чужие слева. Это телефоны, планшеты и другие touch-интерфейсы. +- Поток, ограничение ширины блока, автор над текстом, цвет своего ника и полноширинные разделители не меняются. + +## Следствия + +- Десктопная лента снова читается по одному левому краю, но не возвращается к старой сетке `132px 1fr`. +- Планшет не считается десктопом только из-за ширины экрана. +- Гибридное устройство следует своему основному указателю, который сообщает браузер. Подключение дополнительной мыши само по себе правило не меняет. diff --git a/docs/identity/brief.md b/docs/identity/brief.md index 40d8a22..cb73764 100644 --- a/docs/identity/brief.md +++ b/docs/identity/brief.md @@ -43,7 +43,7 @@ font-family: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monos - Десктоп: сайдбар 224 px с правой границей `line`, шапка 64 px, отступы контента 32 px. - Мобильный: шапка 56 px, отступы 20 px, ввод с min-height 44 px. Нижнее поле подвала и строки ввода считается с `env(safe-area-inset-bottom)`: под системную полосу iPhone они не заходят (ADR-075). -- Сообщения — поток на обеих ширинах, автор и время над текстом; свои — у правого края ленты, чужие — у левого, ширина блока не больше `min(640px, 85%)` (ADR-065). +- Сообщения — поток, автор и время над текстом; на телефоне и планшете свои — у правого края, чужие — у левого, на десктопе все — у левого; ширина блока не больше `min(640px, 85%)` (ADR-077). - Ввод — рамка 1 px ink, без скруглений, `>` цветом mark слева. - Активный элемент списка — инверсия: фон ink, текст bone. - Разделители — 1 px `line`; разделитель «новые» — 1 px mark. diff --git a/docs/identity/screens.html b/docs/identity/screens.html index 54ccbda..d905465 100644 --- a/docs/identity/screens.html +++ b/docs/identity/screens.html @@ -30,7 +30,7 @@ .author { color:var(--mute); font-size:12px; } .author.me { color:var(--mark); } .author .t { color:var(--stone); } - /* поток сообщений: свои — у правого края, чужие — у левого (ADR-065) */ + /* поток сообщений: на touch своё справа, на десктопе всё слева (ADR-077) */ .msg { display:flex; flex-direction:column; gap:4px; max-width:min(640px, 85%); } .msg.me { align-self:flex-end; align-items:flex-end; } .msg p { margin:0; } @@ -42,6 +42,7 @@ /* 2a — десктоп */ .desktop { width:1120px; height:700px; display:grid; grid-template-columns:224px 1fr; } + .desktop .msg.me { align-self:flex-start; align-items:flex-start; } .side { border-right:1px solid var(--line); display:flex; flex-direction:column; } .brand { height:64px; display:flex; align-items:center; gap:10px; padding:0 20px; border-bottom:1px solid var(--line); font-size:15px; letter-spacing:-0.02em; } .list { padding:20px; display:flex; flex-direction:column; gap:2px; } diff --git a/docs/open-questions.md b/docs/open-questions.md index ace6161..9e5b453 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -5,4 +5,4 @@ - Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало. - Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016). -Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей. ADR-064 и ADR-069 — полоса помощника форм над клавиатурой iOS, ADR-065 — сторона сообщений в ленте, ADR-066 — серверный «перец», ADR-067…072 и ADR-073…075 — версия в подвале, самообновление PWA, строка ввода, запрет масштабирования, код причины отказа пушей. +Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей. ADR-064 и ADR-069 — попытки убрать системную панель ввода iOS, ADR-065 — сторона сообщений в ленте, ADR-066 — серверный «перец», ADR-067…075 — версия в подвале, самообновление PWA, строка ввода, запрет масштабирования, код причины отказа пушей. ADR-076 фиксирует системное ограничение панели iOS и возврат к `textarea`, ADR-077 — стороны сообщений отдельно для touch и десктопа. diff --git a/docs/ui.md b/docs/ui.md index 61ead22..5d046f2 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -38,13 +38,13 @@ Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. Без темы и «N онлайн». -Лента: поток блоков сверху вниз, одинаковый на десктопе и мобильном; подряд идущие сообщения одного автора — без повтора автора над группой. Свои сообщения выровнены по правому краю ленты, чужие — по левому (ADR-065); свой ник в колонке автора при этом остаётся цветом `mark`. Ширина блока сообщения ограничена — иначе выравнивать нечего, — текст внутри блока выровнен по левому краю независимо от стороны. Разделители дат и «новые» — во всю ширину ленты: линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`. +Лента: поток блоков сверху вниз; подряд идущие сообщения одного автора — без повтора автора над группой. На телефоне и планшете свои сообщения выровнены по правому краю, чужие — по левому; на десктопе все сообщения стоят слева (ADR-077). Десктоп определяется точным указателем с наведением, а не шириной окна: широкий планшет остаётся планшетом. Свой ник остаётся цветом `mark`. Ширина блока сообщения ограничена — иначе выравнивать нечего, — текст внутри блока всегда выровнен по левому краю. Разделители дат и «новые» — во всю ширину ленты: линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`. Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053). -Ввод: рамка 1 px ink, слева `>` цветом `mark`, подсказка «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500; лишнее обрезается и при наборе, и при вставке: режется приходящее, набранное остаётся на месте (ADR-071). +Ввод: рамка 1 px ink, слева `>` цветом `mark`, подсказка «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500; нативный `maxLength` не пускает лишнее. Тап по неинтерактивному месту истории снимает фокус со строки и закрывает клавиатуру (ADR-076). -Строка сообщения — редактируемый блок, а не поле формы: над клавиатурой iOS рисует полосу помощника форм, и бывает она только у `input` и `textarea` (ADR-069). Блок принимает и отдаёт только текст: вставка приходит текстом, значение читается и пишется `textContent`, `innerHTML` не появляется. Подсказку рисует правило CSS для пустого блока. Растёт под содержимое, дальше семи строк — прокрутка. Заблокированный ввод перестаёт быть редактируемым. Остальные строки ввода — вход, регистрация, смена пароля, «новый чат», участники — остаются полями формы: там нужны менеджеры паролей, а полоса на разовом экране не мешает. +Строка сообщения — `textarea` (ADR-076): она принимает только текст, сама держит вставку, выделение, отмену, подсказку, предел и состояние `disabled`. Растёт под содержимое, дальше семи строк — прокрутка. Системную панель со стрелками и «готово» iOS рисует поверх клавиатуры и для `textarea`, и для `contenteditable`; у веб-страницы нет поддерживаемого API, чтобы её скрыть. Полное управление этой панелью потребовало бы отдельной нативной iOS-оболочки; собственная экранная клавиатура в веб-клиенте не делается. Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038). @@ -118,6 +118,6 @@ Семантика: `nav`, `main`, `form`, `button`, `ul/li` для списков; `aria-live="polite"` на ленте; фокус в строку ввода при открытии чата на десктопе; контраст ink/bone и mark/bone не ниже 4.5:1; цели нажатия на мобильном не меньше 44 px. -Строка сообщения — редактируемый блок (ADR-069), и семантику ему задают руками: `role="textbox"`, `aria-multiline="true"`, подпись `aria-label` тем же текстом, что подсказка. Заблокированный ввод перестаёт быть редактируемым и говорит об этом `aria-disabled`. +Строка сообщения — нативная `textarea` (ADR-076): роль многострочного поля и состояние `disabled` сообщает сам браузер; доступное имя повторяет текст видимой подсказки через `aria-label`, кнопка `>` объявляется как «отправить». Масштабирование запрещено — и щипок, и двойной тап (ADR-075). Это сознательный размен: интерфейс ведёт себя как приложение, а человек, которому нужно увеличить мелкое, средства лишается. Размеры шрифтов от запрета не меняются: поля ввода остаются 14 px, как в `docs/identity/brief.md`. Браузер вправе запрет проигнорировать. diff --git a/web/app.css b/web/app.css index b5d6e30..24bdb63 100644 --- a/web/app.css +++ b/web/app.css @@ -752,9 +752,8 @@ input[type="password"] { margin-top: 0; } -/* строка сообщения: автор над текстом, блок целиком к своему краю — - свои вправо, чужие влево (ADR-065). Ширина блока ограничена, иначе - выравнивать нечего — текст в один абзац занял бы всю ленту. */ +/* строка сообщения: автор над текстом. На телефоне и планшете своё стоит + у правого края; на десктопе все сообщения идут слева (ADR-077). */ .line { display: flex; @@ -773,6 +772,17 @@ input[type="password"] { align-items: flex-end; } +/* Ширина окна не отличает планшет от десктопа: iPad бывает шире ноутбука. + Точный указатель с hover — признак десктопного способа работы; там сторона + не помогает и только заставляет взгляд ходить через всю ленту. */ + +@media (hover: hover) and (pointer: fine) { + .line--me { + align-self: flex-start; + align-items: flex-start; + } +} + .author { display: flex; flex-wrap: wrap; @@ -918,12 +928,12 @@ input[type="password"] { line-height: 1.5; } -/* строка сообщения — редактируемый блок, а не поле формы (ADR-069): - растёт под текст сама, дальше семи строк — прокрутка. Переносы строк - в ней настоящие символы, поэтому pre-wrap; длинное слово рвётся, - иначе строка ввода уезжает вбок */ +/* строка сообщения — textarea (ADR-076): браузер сам держит вставку, + выделение, отмену и предел. Число rows растёт под текст до семи строк, + дальше поле прокручивается */ .input .input__field--text { + resize: none; overflow-y: auto; /* семь строк — это семь высот строки плюс вертикальные отступы: без них предел вышел бы вдвое ниже обещанного */ @@ -932,14 +942,6 @@ input[type="password"] { overflow-wrap: anywhere; } -/* подсказка пустой строки: placeholder у блока не бывает, текст приходит - атрибутом data-hint (ADR-069) */ - -.input .input__field--text:empty::before { - content: attr(data-hint); - color: var(--stone); -} - .input .input__field::placeholder { color: var(--stone); } diff --git a/web/js/pwa.js b/web/js/pwa.js index 4955b5f..f3fab9b 100644 --- a/web/js/pwa.js +++ b/web/js/pwa.js @@ -330,11 +330,6 @@ function apply() { // Набранным считается только то, куда набирают: textarea и поля из // TYPED. Чекбокс, файл, скрытое поле и кнопка непусты сами по себе, // а обновление они бы запретили насовсем. -// -// Строка сообщения в чате — редактируемый блок, а не поле формы -// (ADR-069): value у него нет, набранное лежит в textContent. Считается -// и закрытый блок: ввод бывает заблокирован предупреждением о ключе, -// а недописанное в нём остаётся. function typed() { for (const node of document.querySelectorAll("input, textarea")) { if (node.value === "") { @@ -344,11 +339,6 @@ function typed() { return true; } } - for (const node of document.querySelectorAll("[contenteditable]")) { - if (node.textContent !== "") { - return true; - } - } return false; } diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js index 1816492..457f97a 100644 --- a/web/js/ui/chat.js +++ b/web/js/ui/chat.js @@ -9,6 +9,7 @@ import { DESKTOP, clear, el, wide } from "./dom.js"; // Предел текста и порог счётчика — docs/ui.md, «Чат». const LIMIT = 4000; const COUNTER_AT = 3500; +const COMPOSER_ROWS = 7; // Разделители дат: на десктопе — полная дата, на мобильном — короткая, // как в эталоне. Время — ЧЧ:ММ в локальной зоне. @@ -49,10 +50,6 @@ export function renderChat(root, ctx, chatId) { roomId: sync.roomIdOf(chatId), limit: ctx.config?.maxMessageChars ?? LIMIT, alive: true, - // Каким значением открыт редактируемый блок строки ввода: - // "plaintext-only" или "true" (ADR-069). От него зависит, разбирает ли - // браузер вставку и перенос строки сам или это делаем мы. - edit: "true", // Имя комнаты; до чтения записи чата вместо него идентификатор, // как в db.blankChat. name: sync.roomIdOf(chatId), @@ -95,6 +92,17 @@ export function renderChat(root, ctx, chatId) { root.append(view.feed); root.append(composer(view)); + sizeField(view); + // Обычный тап по истории закрывает системную клавиатуру. Интерактивные + // элементы ленты (сейчас это «повторить») ведут своё действие сами: + // подменять их на жест закрытия клавиатуры нельзя (ADR-076). + view.feed.addEventListener("click", (event) => { + const target = event.target; + if (target instanceof Element && target.closest("button, a, [role=button]") !== null) { + return; + } + view.field.blur(); + }); const offMessages = sync.on("messages", (detail) => { if (detail.chatId === view.chatId) { @@ -117,6 +125,10 @@ export function renderChat(root, ctx, chatId) { const media = matchMedia(DESKTOP); const onMedia = () => paint(view, true); media.addEventListener("change", onMedia); + // Переносы зависят от ширины поля. Поворот телефона и изменение окна + // пересчитывают число строк даже без нового ввода (ADR-076). + const onResize = () => sizeField(view); + window.addEventListener("resize", onResize); run(view, () => load(view)); @@ -127,6 +139,7 @@ export function renderChat(root, ctx, chatId) { offPeers(); offRooms(); media.removeEventListener("change", onMedia); + window.removeEventListener("resize", onResize); }; } @@ -201,10 +214,10 @@ async function refreshRoom(view, known) { // цветом mark. Enter отправляет только на десктопе; на мобильном он делает // перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»). // -// Строка сообщения — редактируемый блок, а не поле формы (ADR-069): над -// клавиатурой iOS рисует полосу помощника форм, и бывает она только -// у input и textarea. Со страницы её не убрать, а в чате она занимает место -// и ничего не делает: поле на экране одно, переходить стрелками некуда. +// Строка сообщения — textarea (ADR-076). Проверка на реальном iPhone +// показала, что системная панель WebKit бывает и у contenteditable, а +// поддерживаемого веб-API для неё нет. Нативное поле зато само держит +// обычное редактирование, вставку, предел и доступность. function composer(view) { const form = el("div", "compose"); form.setAttribute("role", "form"); @@ -217,13 +230,11 @@ function composer(view) { const prompt = el("span", "p", ">"); prompt.setAttribute("aria-hidden", "true"); - view.field = el("div", "input__field input__field--text"); - view.edit = editing(view.field); - // Поле ввода без input: экранному диктору о нём говорят роль и подпись, - // подпись же стоит подсказкой в пустом блоке (docs/ui.md, «Доступность»). - view.field.setAttribute("role", "textbox"); - view.field.setAttribute("aria-multiline", "true"); + view.field = el("textarea", "input__field input__field--text"); + view.field.rows = 1; hint(view, "сообщение"); + view.field.maxLength = view.limit; + view.field.autocomplete = "off"; // Клавиша Enter на мобильном переносит строку, а не отправляет: отправка // там на кнопке «>» (docs/ui.md, «Чат»). Поэтому «enter», а не «send»: // подпись клавиши обещает то, что клавиша делает. @@ -239,301 +250,74 @@ function composer(view) { view.send = el("button", "input__send", ">"); view.send.type = "button"; + view.send.setAttribute("aria-label", "отправить"); view.send.addEventListener("click", () => submit(view)); row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), view.send); form.append(view.bar, row); - // Предел держится до вставки, а не после: обрезается приходящее, - // а набранное остаётся на месте (ADR-071). - view.field.addEventListener("beforeinput", (event) => cap(view, event)); - view.field.addEventListener("input", (event) => { - if (event.isComposing) { - // Пока идёт композиция IME, содержимое не трогаем: правка оборвала - // бы её на полуслове, а подтверждённое не попало бы в блок вовсе. - // Подтверждённое разберёт compositionend (ADR-071). - return; - } - fit(view); - tail(view); - count(view); - }); - view.field.addEventListener("compositionend", () => { - fit(view); - tail(view); + view.field.addEventListener("input", () => { + sizeField(view); count(view); }); view.field.addEventListener("keydown", (event) => { - if (event.key !== "Enter" || event.isComposing) { + if (event.key !== "Enter" || event.shiftKey || event.isComposing) { return; } - if (wide() && !event.shiftKey) { - event.preventDefault(); - submit(view); + if (!wide()) { return; } - if (view.edit !== "plaintext-only") { - // Блок без plaintext-only ставит на Enter
или
, а значение - // читается 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}`; // Оболочка — всё, из чего клиент поднимается без сети. Список явный: