diff --git a/public/neu/ansicht-chat.js b/public/neu/ansicht-chat.js index 1ac7bad..2969d13 100644 --- a/public/neu/ansicht-chat.js +++ b/public/neu/ansicht-chat.js @@ -4,6 +4,7 @@ import { api } from './api.js' import { h, leeren } from './dom.js' import { K, fmtDatumZeit, meldeZaehlerNeu, nimmChatVorgabe } from './ansicht-hilfen.js' +import { HANDY_ABFRAGE } from './leiste.js' export const CHAT_TEXTE = { platzhalter: 'Nachricht an das WEBklar-Team…', @@ -21,18 +22,56 @@ export function nachrichtMeta(msg) { return `${meine ? 'Du' : msg?.senderName || 'Webklar-Team'} · ${fmtDatumZeit(msg?.createdAt)}` } -let aktiv = null // der laufende Chat: { timer, composer } +/** + * Um wie viele Pixel muss die Seite scrollen, damit die Eingabe im sichtbaren Bereich (visualViewport) steht? Aus revealChatComposer (app.js:2240-2260): + * nur nachschieben, wenn sie wirklich verdeckt ist (8 px Luft), nie die Seite nach oben pinnen. Unter 1 Pixel und ungültige Werte ergeben 0. + */ +export function composerVersatz({ rect, visibleTop, visibleBottom }, luft = 8) { + let delta = 0 + if (rect.bottom > visibleBottom - luft) delta = rect.bottom - visibleBottom + luft + else if (rect.top < visibleTop + luft) delta = rect.top - visibleTop - luft + return Number.isFinite(delta) && Math.abs(delta) > 1 ? delta : 0 +} + +/** + * Die Eingabe über der Tastatur halten (E-Fix M5, wie app.js): 350 ms nach dem Fokus im Composer (die Tastatur-Animation auf iOS) und bei jedem + * visualViewport-Resize die Eingabe in den sichtbaren Bereich schieben. Nur am Handy und nur, solange der Fokus im Composer liegt. + * Rückgabe { abbau } entfernt die Hörer. + */ +export function verdrahteEingabe({ win = globalThis.window, doc = globalThis.document, wurzel }) { + const vv = win?.visualViewport + const zeige = () => { + if (!vv || !wurzel.contains(doc.activeElement) || !win.matchMedia?.(HANDY_ABFRAGE).matches) return + const delta = composerVersatz({ rect: wurzel.getBoundingClientRect(), visibleTop: vv.offsetTop, visibleBottom: vv.offsetTop + vv.height }) + if (delta) win.scrollBy(0, delta) + } + const beiFokus = (ev) => { if (wurzel.contains(ev.target) && ev.target.matches?.('textarea, input')) win.setTimeout(zeige, 350) } + const frame = (fn) => (win.requestAnimationFrame ? win.requestAnimationFrame(fn) : fn()) + const beiVv = () => frame(() => frame(zeige)) + doc.addEventListener('focusin', beiFokus) + vv?.addEventListener('resize', beiVv) + return { + abbau() { + doc.removeEventListener('focusin', beiFokus) + vv?.removeEventListener('resize', beiVv) + }, + } +} + +let aktiv = null // der laufende Chat: { timer, composer, eingabe, beobachter } function raeumeAuf() { if (!aktiv) return if (aktiv.timer) clearInterval(aktiv.timer) try { aktiv.composer?.destroy?.() } catch { /* schon abgebaut */ } + try { aktiv.eingabe?.abbau() } catch { /* schon abgebaut */ } + try { aktiv.beobachter?.disconnect() } catch { /* schon abgebaut */ } aktiv = null } export async function zeigeChat(wurzel, { route, me, nav } = {}) { raeumeAuf() - const eigener = { timer: null, composer: null } + const eigener = { timer: null, composer: null, eingabe: null, beobachter: null } aktiv = eigener const gilt = () => aktiv === eigener leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …')) @@ -119,6 +158,13 @@ export async function zeigeChat(wurzel, { route, me, nav } = {}) { onSent: (message) => { haengeAn([message]) }, }) + // Eingabe über der Tastatur halten; wird der Verlauf kleiner (Tastatur, Leiste), bleibt die neueste Nachricht sichtbar, sofern man unten war (E-Fix M5) + eigener.eingabe = verdrahteEingabe({ win: globalThis.window, doc: globalThis.document, wurzel: composerWurzel }) + if (typeof globalThis.ResizeObserver === 'function') { + eigener.beobachter = new globalThis.ResizeObserver(() => { if (verlauf.dataset.stick !== 'false') verlauf.scrollTop = verlauf.scrollHeight }) + eigener.beobachter.observe(verlauf) + } + const vorgabe = nimmChatVorgabe() if (vorgabe) { const feld = composerWurzel.querySelector('textarea') diff --git a/test/knAnsichtChat.test.js b/test/knAnsichtChat.test.js index e5f525b..c6641bb 100644 --- a/test/knAnsichtChat.test.js +++ b/test/knAnsichtChat.test.js @@ -1,7 +1,7 @@ import { test, mock } from 'node:test' import assert from 'node:assert/strict' import { readFileSync, existsSync } from 'node:fs' -import { zeigeChat, CHAT_TEXTE, nachrichtMeta } from '../public/neu/ansicht-chat.js' +import { zeigeChat, CHAT_TEXTE, nachrichtMeta, composerVersatz, verdrahteEingabe } from '../public/neu/ansicht-chat.js' import { setzeChatVorgabe } from '../public/neu/ansicht-hilfen.js' import { mit, me, nachricht, neueWurzel } from './support/knDaten.js' @@ -77,3 +77,30 @@ test('Chat: Texte und Meta der Nachricht', () => { assert.equal(nachrichtMeta(nachricht({ sender: 'customer', createdAt: '2026-10-14T08:05:00.000Z' })), 'Du · 14.10.26, 10:05') assert.equal(nachrichtMeta(nachricht({ sender: 'admin', senderName: '', createdAt: '2026-10-14T08:05:00.000Z' })), 'Webklar-Team · 14.10.26, 10:05') }) + +// M5 (E-Fix): Eingabe über der Tastatur halten, wie revealChatComposer in app.js:2240-2260 +test('M5: composerVersatz schiebt nur, wenn die Eingabe verdeckt ist (Tastatur unten) oder über dem sichtbaren Bereich liegt, mit 8 px Luft', () => { + const sicht = { visibleTop: 0, visibleBottom: 500 } + assert.equal(composerVersatz({ rect: { top: 400, bottom: 460 }, ...sicht }), 0, 'sichtbar: nichts tun') + assert.equal(composerVersatz({ rect: { top: 430, bottom: 492 }, ...sicht }), 0, 'knapp innerhalb der Luft: nichts tun') + assert.equal(composerVersatz({ rect: { top: 480, bottom: 540 }, ...sicht }), 48, 'unten verdeckt: um die Überdeckung plus Luft nach unten scrollen') + assert.equal(composerVersatz({ rect: { top: -30, bottom: 30 }, ...sicht }), -38, 'oben abgeschnitten: nach oben') + assert.equal(composerVersatz({ rect: { top: 400.5, bottom: 491.5 }, ...sicht }), 0, 'unter 1 px: nichts tun') + assert.equal(composerVersatz({ rect: { top: Number.NaN, bottom: Number.NaN }, ...sicht }), 0) +}) +test('M5: verdrahteEingabe prüft 350 ms nach dem Fokus und bei jedem visualViewport-Resize, nur am Handy und nur mit Fokus im Composer; abbau entfernt die Hörer', () => { + const scrolled = []; const geplant = []; const vvHoerer = new Map(); const docHoerer = new Map() + const feld = { matches: (s) => s.includes('textarea') } + const wurzel = { contains: (el) => el === feld, getBoundingClientRect: () => ({ top: 480, bottom: 540 }) } + const doc = { activeElement: feld, addEventListener: (n, f) => docHoerer.set(n, f), removeEventListener: (n) => docHoerer.delete(n) } + const vv = { offsetTop: 0, height: 500, addEventListener: (n, f) => vvHoerer.set(n, f), removeEventListener: (n) => vvHoerer.delete(n) } + let handy = true + const win = { visualViewport: vv, matchMedia: () => ({ matches: handy }), scrollBy: (x, y) => scrolled.push([x, y]), requestAnimationFrame: (fn) => fn(), setTimeout: (fn, ms) => geplant.push([ms, fn]) } + const v = verdrahteEingabe({ win, doc, wurzel }) + docHoerer.get('focusin')({ target: feld }); assert.deepEqual(geplant.map((g) => g[0]), [350]); geplant[0][1](); assert.deepEqual(scrolled, [[0, 48]]) + vvHoerer.get('resize')(); assert.deepEqual(scrolled.at(-1), [0, 48], 'beim Resize der Tastatur wieder') + docHoerer.get('focusin')({ target: { matches: () => true } }); assert.equal(geplant.length, 1, 'Fokus außerhalb des Composers plant nichts') + handy = false; vvHoerer.get('resize')(); assert.equal(scrolled.length, 2, 'am Desktop nie') + handy = true; doc.activeElement = null; vvHoerer.get('resize')(); assert.equal(scrolled.length, 2, 'ohne Fokus im Composer nie') + v.abbau(); assert.deepEqual([docHoerer.size, vvHoerer.size], [0, 0]) +})