Чат v1.1.1: убрать лишний отступ над клавиатурой
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
Уточняет [ADR-024](024-identity-and-ui.md): типографика из `docs/identity/brief.md` остаётся как записана, а страница перестаёт масштабироваться.
|
||||
|
||||
Уточнён [ADR-076](076-ios-input-accessory-is-system.md): строка сообщения снова стала `textarea`; она уже входит в перечень элементов управления, по которым второй тап не гасится.
|
||||
Уточнён [ADR-076](076-ios-input-accessory-is-system.md): строка сообщения снова стала `textarea`, входит в перечень элементов управления, по которым второй тап не гасится, и заменяет весь нижний отступ на 6 px только при открытой экранной клавиатуре.
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
- Страховка на JS — `web/js/zoom.js`: мета-строке Safari верен не всегда. Слушатели непассивные, иначе `preventDefault` не действует. `gesturestart`, `gesturechange`, `gestureend` — события щипка, они бывают только в Safari — гасятся целиком. Второй тап подряд, ближе 350 мс и 40 px к первому, гасится, если он не по элементу управления: `preventDefault` на `touchend` уносит с собой `click`, а нажать кнопку дважды подряд — обычное дело. Прокрутка, свайпы и одиночные нажатия не трогаются вовсе.
|
||||
- `overscroll-behavior: none` у страницы и `contain` у лент: резинового отскока и «потянуть для обновления» нет, прокрутка внутри лент прежняя.
|
||||
- Выделение текста снято с шапки, сайдбара, кнопок и разделителей: долгое нажатие по ним показывало лупу и «копировать», а копировать там нечего. С текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара (ADR-067) не снято — их копируют; версию на iPhone взять больше неоткуда, там нет ни строки адреса, ни консоли.
|
||||
- `viewport-fit=cover` пускает страницу под вырез и системную полосу, поэтому отступы считаются с ней: подвал сайдбара и строка ввода добавляют к нижнему полю `env(safe-area-inset-bottom)`, страница — боковые и верхнюю вставки. Верхняя при `status-bar-style: default` нулевая и не делает ничего; она стоит на случай, когда браузер решит иначе, — строка кода против шапки, уехавшей под часы.
|
||||
- `viewport-fit=cover` пускает страницу под вырез и системную полосу, поэтому отступы считаются с ней: подвал сайдбара и строка ввода добавляют к нижнему полю `env(safe-area-inset-bottom)`, страница — боковые и верхнюю вставки. Когда открыта экранная клавиатура, обычные `20px + env(safe-area-inset-bottom)` строки ввода заменяются на 6 px по ADR-076: клавиатура уже закрывает домашний индикатор. Верхняя вставка при `status-bar-style: default` нулевая и не делает ничего; она стоит на случай, когда браузер решит иначе, — строка кода против шапки, уехавшей под часы.
|
||||
|
||||
## Следствия
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
Проверка v1.1 на реальном iPhone опровергла предположение: полоса осталась и у `contenteditable`. Это часть клавиатурного интерфейса WebKit, а не узел страницы. Web API для её отключения нет. В нативной оболочке внешний вид клавиатуры может менять хост iOS; установленная PWA остаётся веб-страницей и такого доступа не получает.
|
||||
|
||||
На том же экране часть потерянного места оказалась не системной панелью: строка ввода всегда добавляла `20px + env(safe-area-inset-bottom)`. Safari сохраняет нижний safe-area домашнего индикатора и после появления клавиатуры, хотя её прямоугольная поверхность уже закрывает этот край. Получалась отдельная пустая полоса примерно той же высоты, что системная панель.
|
||||
|
||||
`contenteditable` при этом стоил клиенту ручной реализации подсказки, предела, вставки без разметки, переноса строки, `disabled`, счётчика, работы IME и запасного пути для браузеров без `plaintext-only`. Главное следствие ADR-069 не наступило, а цена осталась.
|
||||
|
||||
## Решение
|
||||
@@ -17,10 +19,12 @@
|
||||
- Системная панель iOS принимается как ограничение веб-клиента. `inputmode="none"` и собственная экранная клавиатура не используются: убрать вместе с панелью системную клавиатуру означает потерять обычный набор, раскладки, диктовку и доступность.
|
||||
- Нативная iOS-оболочка не добавляется в Bare. Если абсолютное управление панелью станет требованием, это отдельный продуктовый и архитектурный выбор.
|
||||
- Тап по неинтерактивному месту истории вызывает `blur()` строки сообщения и закрывает клавиатуру. Кнопки и ссылки в ленте сохраняют своё действие.
|
||||
- Пока поле сфокусировано и `visualViewport` уменьшен как при экранной клавиатуре, весь обычный нижний отступ строки ввода (`20px + safe-area` на узком экране, `28px + safe-area` на широком) заменяется на 6 px. Эвристика сверяет масштаб и ширину и требует существенного уменьшения высоты; один фокус не считается клавиатурой, поэтому с физической клавиатурой обычный отступ остаётся. При закрытии размеры пересчитываются. Смена ширины после фокуса и уход приложения в фон возвращают полный безопасный отступ до следующего тапа по полю: известное старое значение `visualViewport` после поворота или возврата не должно прижать ввод к домашнему индикатору.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Строка сообщения снова опирается на браузерное текстовое поле и стала заметно проще. Лишнее не проходит через `maxLength`; ручная обработка `beforeinput`, вставки, IME и текстовых узлов удалена.
|
||||
- Полоса со стрелками и «готово» на iPhone остаётся. Код и документация больше не обещают веб-странице контроль над системным UI.
|
||||
- Пустой зазор между строкой ввода и системной панелью исчезает; место возвращается истории без вмешательства в клавиатуру.
|
||||
- Тап по истории возвращает место ленте без нажатия системной кнопки «готово».
|
||||
- Проверка самой панели по-прежнему требует реального iPhone; поведение `textarea`, отправки и снятия фокуса проверяется в обычном браузере.
|
||||
- Проверка самой панели и точного момента перестроения `visualViewport` по-прежнему требует реального iPhone в Safari и установленном PWA; поведение `textarea`, отправки, эвристики размеров и снятия фокуса проверяется в обычном браузере.
|
||||
|
||||
@@ -42,7 +42,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).
|
||||
- Мобильный: шапка 56 px, отступы 20 px, ввод с min-height 44 px. Нижнее поле подвала и строки ввода считается с `env(safe-area-inset-bottom)`: под системную полосу iPhone они не заходят. При открытой экранной клавиатуре её прямоугольник уже защищает край, поэтому обычные `20px + safe-area` строки ввода заменяются компактными 6 px (ADR-076).
|
||||
- Сообщения — поток, автор и время над текстом; на телефоне и планшете свои — у правого края, чужие — у левого, на десктопе все — у левого; ширина блока не больше `min(640px, 85%)` (ADR-077).
|
||||
- Ввод — рамка 1 px ink, без скруглений, `>` цветом mark слева.
|
||||
- Активный элемент списка — инверсия: фон ink, текст bone.
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@
|
||||
|
||||
Без inline-стилей и inline-скриптов (CSP). Рендер — `document.createElement` и `textContent`; `innerHTML` не используется нигде: сообщения — пользовательские данные.
|
||||
|
||||
Страница ведёт себя как приложение, а не как документ (ADR-075): масштабирования нет, резинового отскока нет, выделение снято с шапки, сайдбара, кнопок и разделителей — но не с текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара: их копируют. Установленное на «Домой» приложение объявлено мета-строками, имя на экране — `bare`, системная полоса остаётся над страницей. Подвал сайдбара и строка ввода отступают от нижней системной полосы iPhone.
|
||||
Страница ведёт себя как приложение, а не как документ (ADR-075): масштабирования нет, резинового отскока нет, выделение снято с шапки, сайдбара, кнопок и разделителей — но не с текста сообщений, отпечатков, полей ввода и версии в подвале сайдбара: их копируют. Установленное на «Домой» приложение объявлено мета-строками, имя на экране — `bare`, системная полоса остаётся над страницей. Подвал сайдбара и строка ввода отступают от нижней системной полосы iPhone; у строки весь нижний отступ становится 6 px, пока край уже закрыт экранной клавиатурой (ADR-076).
|
||||
|
||||
## Вход и регистрация
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053).
|
||||
|
||||
Ввод: рамка 1 px ink, слева `>` цветом `mark`, подсказка «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500; нативный `maxLength` не пускает лишнее. Тап по неинтерактивному месту истории снимает фокус со строки и закрывает клавиатуру (ADR-076).
|
||||
Ввод: рамка 1 px ink, слева `>` цветом `mark`, подсказка «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500; нативный `maxLength` не пускает лишнее. Тап по неинтерактивному месту истории снимает фокус со строки и закрывает клавиатуру. Пока открыта экранная клавиатура, весь нижний отступ строки заменяется 6 px; при физической клавиатуре и после закрытия обычный отступ сохраняется (ADR-076).
|
||||
|
||||
Строка сообщения — `textarea` (ADR-076): она принимает только текст, сама держит вставку, выделение, отмену, подсказку, предел и состояние `disabled`. Растёт под содержимое, дальше семи строк — прокрутка. Системную панель со стрелками и «готово» iOS рисует поверх клавиатуры и для `textarea`, и для `contenteditable`; у веб-страницы нет поддерживаемого API, чтобы её скрыть. Полное управление этой панелью потребовало бы отдельной нативной iOS-оболочки; собственная экранная клавиатура в веб-клиенте не делается.
|
||||
|
||||
|
||||
@@ -885,6 +885,14 @@ input[type="password"] {
|
||||
padding: 0 16px calc(20px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* При открытой экранной клавиатуре нижний край уже закрыт её прямоугольной
|
||||
поверхностью. Safari продолжает отдавать safe-area домашнего индикатора;
|
||||
заменяем весь нижний отступ на компактные 6 px (ADR-076). */
|
||||
|
||||
.compose.is-keyboard {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+79
-1
@@ -10,6 +10,10 @@ import { DESKTOP, clear, el, wide } from "./dom.js";
|
||||
const LIMIT = 4000;
|
||||
const COUNTER_AT = 3500;
|
||||
const COMPOSER_ROWS = 7;
|
||||
// Экранная клавиатура уменьшает visual viewport на сотни пикселей. Порог
|
||||
// отсекает движение панелей браузера; доля высоты работает и на iPad.
|
||||
const KEYBOARD_SHRINK = 120;
|
||||
const KEYBOARD_SHRINK_RATIO = 0.18;
|
||||
|
||||
// Разделители дат: на десктопе — полная дата, на мобильном — короткая,
|
||||
// как в эталоне. Время — ЧЧ:ММ в локальной зоне.
|
||||
@@ -91,8 +95,10 @@ export function renderChat(root, ctx, chatId) {
|
||||
});
|
||||
root.append(view.feed);
|
||||
|
||||
root.append(composer(view));
|
||||
view.compose = composer(view);
|
||||
root.append(view.compose);
|
||||
sizeField(view);
|
||||
const offKeyboard = watchKeyboard(view);
|
||||
// Обычный тап по истории закрывает системную клавиатуру. Интерактивные
|
||||
// элементы ленты (сейчас это «повторить») ведут своё действие сами:
|
||||
// подменять их на жест закрытия клавиатуры нельзя (ADR-076).
|
||||
@@ -138,6 +144,7 @@ export function renderChat(root, ctx, chatId) {
|
||||
offNet();
|
||||
offPeers();
|
||||
offRooms();
|
||||
offKeyboard();
|
||||
media.removeEventListener("change", onMedia);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
@@ -297,6 +304,77 @@ function sizeField(view) {
|
||||
field.rows = Math.max(1, Math.min(COMPOSER_ROWS, rows));
|
||||
}
|
||||
|
||||
// watchKeyboard снимает нижний safe-area только по консервативной эвристике
|
||||
// экранной клавиатуры. На iOS env(safe-area-inset-bottom) ошибочно остаётся
|
||||
// прежним и кладёт под composer ещё одну пустую полосу (ADR-076).
|
||||
//
|
||||
// Одного focus недостаточно: поле бывает сфокусировано с физической
|
||||
// клавиатурой. Экранную выдаёт уменьшившийся visual viewport при масштабе 1;
|
||||
// pinch-зум, если Safari всё-таки его пропустил, за клавиатуру не считается.
|
||||
function watchKeyboard(view) {
|
||||
const viewport = window.visualViewport;
|
||||
let frame = 0;
|
||||
let focusWidth = 0;
|
||||
const update = () => {
|
||||
frame = 0;
|
||||
const page = document.documentElement;
|
||||
const threshold = Math.max(KEYBOARD_SHRINK, page.clientHeight * KEYBOARD_SHRINK_RATIO);
|
||||
const shrunk = viewport !== null
|
||||
&& viewport !== undefined
|
||||
&& Math.abs(viewport.scale - 1) < 0.01
|
||||
// При повороте width обновляется у двух viewport не одновременно.
|
||||
// До стабилизации размеров не принимаем этот переход за клавиатуру.
|
||||
&& Math.abs(page.clientWidth - viewport.width) < 2
|
||||
// WebKit иногда оставляет старую высоту visual viewport даже после
|
||||
// поворота. Смена ширины после фокуса безопасно выключает компенсацию
|
||||
// до следующего тапа по полю.
|
||||
&& Math.abs(page.clientWidth - focusWidth) < 2
|
||||
&& page.clientHeight - viewport.height > threshold;
|
||||
view.compose.classList.toggle("is-keyboard", document.activeElement === view.field && shrunk);
|
||||
};
|
||||
const schedule = () => {
|
||||
if (frame !== 0) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(update);
|
||||
};
|
||||
const arm = () => {
|
||||
focusWidth = document.documentElement.clientWidth;
|
||||
schedule();
|
||||
};
|
||||
// На blur не полагаемся на порядок обновления activeElement в WebKit:
|
||||
// класс должен исчезнуть сразу при закрытии клавиатуры в любом случае.
|
||||
const blur = () => {
|
||||
if (frame !== 0) cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
focusWidth = 0;
|
||||
view.compose.classList.remove("is-keyboard");
|
||||
};
|
||||
// После ухода приложения в фон WebKit может вернуть старую высоту.
|
||||
// Не рискуем краем экрана: компактный отступ включится снова по тапу.
|
||||
const visibility = () => {
|
||||
if (document.hidden) {
|
||||
blur();
|
||||
return;
|
||||
}
|
||||
schedule();
|
||||
};
|
||||
view.field.addEventListener("pointerdown", arm);
|
||||
view.field.addEventListener("focus", arm);
|
||||
view.field.addEventListener("blur", blur);
|
||||
viewport?.addEventListener("resize", schedule);
|
||||
window.addEventListener("resize", schedule);
|
||||
document.addEventListener("visibilitychange", visibility);
|
||||
schedule();
|
||||
return () => {
|
||||
if (frame !== 0) cancelAnimationFrame(frame);
|
||||
view.field.removeEventListener("pointerdown", arm);
|
||||
view.field.removeEventListener("focus", arm);
|
||||
view.field.removeEventListener("blur", blur);
|
||||
viewport?.removeEventListener("resize", schedule);
|
||||
window.removeEventListener("resize", schedule);
|
||||
document.removeEventListener("visibilitychange", visibility);
|
||||
};
|
||||
}
|
||||
|
||||
// allow открывает и закрывает ввод: предупреждение о ключе и уход
|
||||
// из комнаты гасят и поле, и кнопку «>» (ADR-016, ADR-044).
|
||||
function allow(view, on) {
|
||||
|
||||
Reference in New Issue
Block a user