Kundenansicht neu E-Fix M5: Chat hält die Eingabe über der Tastatur (revealChatComposer aus app.js)
Der Chat der neuen Ansicht übernahm nur das Ausblenden der Leiste, nicht das Nachschieben der Eingabe: auf kleinen Handys (iPhone SE, Android mit hoher Tastatur) konnte die Eingabezeile unter der Tastatur stehen bleiben, und die letzte Nachricht verschwand beim Schrumpfen des Verlaufs. Jetzt wie in app.js: 350 ms nach dem Fokus im Composer und bei jedem visualViewport-Resize wird die Eingabe in den sichtbaren Bereich geschoben (composerVersatz, nur wenn verdeckt, 8 px Luft, nur am Handy), und ein ResizeObserver hält die neueste Nachricht sichtbar, wenn man unten war. Hörer und Beobachter werden beim Neuaufbau abgebaut. Tests für die Rechnung und die Verdrahtung in knAnsichtChat.test.js. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f8962ffa50
commit
8bb02f3949
2 files changed
+76
-3
No files matched your search
@@ -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')
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
Reference in new issue
Block a user