Mit "@" jemanden im Chat ansprechen

Filipe: "ich will das man die leute mit @ markieren kann im chat."

MARKIEREN IST KEINE FARBE, SONDERN EINE ANSPRACHE. Sie funktioniert
nur, wenn drei Dinge zusammen stimmen: Der Richtige ist gemeint, er
MERKT es, und man sieht es im Satz. Fehlt eines davon, ist es eine
Attrappe -- und zwar eine, die gut aussieht.

WER GEMEINT IST, ENTSCHEIDET DER SERVER. Der Browser schickt nur Text.
Damit wirkt ein von Hand getipptes "@VanVan" genauso wie eines aus der
Auswahlliste, und niemand kann jemanden ansprechen, der gar nicht im
Raum sitzt -- auch nicht ueber die Schnittstelle.

Die Regeln stehen in server/chat-erwaehnung.js, jede mit Begruendung.
Zwei davon sind keine Feinheit, sondern der Unterschied zwischen
brauchbar und laestig:

  * Das Zeichen VOR dem "@" darf kein Buchstabe sein. Sonst piepst
    jede E-Mail-Adresse im Chat jemanden an
    ("[email protected]").
  * Das Zeichen DANACH auch nicht, und der laengste Name gewinnt.
    Sonst spricht "@Tilikum" Tili an.

MAN MERKT ES AUCH OHNE OFFENEN RAUM. Eine Zahl in der Gespraechsliste
sagt "hier ist etwas" -- nicht, ob es an dich war. Wer morgens vier
Raeume mit Zahlen sieht, macht den lautesten zuerst auf, und dort
steht selten das, was auf ihn wartet. Jetzt steht ein "@" daneben.

UND DIE MELDUNG AUFS HANDY SAGT ES: "VanVan hat dich erwaehnt" statt
"Nachricht von VanVan". Als EIGENE Art, die sich getrennt abschalten
laesst -- wer den lauten Chat stumm stellt, will trotzdem wissen, wenn
ihn jemand direkt anspricht. Ohne Ausnahme von der Ruhezeit: Ein Anruf
wartet auf eine Antwort, ein "@Anna" nicht.

DIE AUSWAHL BEIM TIPPEN loest drei Dinge, die man sonst selbst wissen
muesste: wie die Person genau heisst, wer ueberhaupt im Raum ist, und
ob man sich vertippt hat. Tastatur zuerst (Pfeile, Enter, Tab, Escape).
Sie erzwingt nichts -- wer weitertippt, schreibt einfach weiter.

ZWEI RECHNUNGEN, EINE ANTWORT: Der Browser braucht dieselbe Aufloesung,
um sofort hervorzuheben. Genau dort laufen Dinge auseinander, und dann
haette die Seite jemanden hervorgehoben, den niemand benachrichtigt
hat. pruef-erwaehnung LIEST die Browserfassung aus der Datei und fuehrt
sie aus -- ein Nachbau wuerde pruefen, ob ich zweimal dasselbe
schreiben kann.

---- DABEI GEFUNDEN: "gelesen bis 999999" ----------------------------

Der Server nahm fuer den Lesestand JEDE ganze Zahl an. Wer einmal eine
Zahl hinter allem Vorhandenen schickte, sah in diesem Gespraech nie
wieder einen Zaehler und nie wieder ein @-Zeichen: Alles Kuenftige galt
als gelesen, bevor es geschrieben war. Das faellt niemandem als
Zusammenhang auf -- man merkt nur, dass "die Benachrichtigungen nicht
gehen".

Der Browser schickt heute immer die letzte gesehene Nummer, ist also
nicht der Grund. Aber eine Grenze, die nur davon lebt, dass der
Aufrufer sich benimmt, ist keine. Jetzt wird auf die juengste
Nachricht des Raums begrenzt -- abgeleitet, nicht geraten.

Gefunden hat es meine eigene Pruefung: Sie setzte zum Aufraeumen 99999
und wunderte sich danach, warum das frische "@" nicht leuchtete.

WOHER MAN ERFAEHRT, DASS ES DAS GIBT: Im Chat gibt es keinen
Hilfeknopf. Der Hinweis steht deshalb im Leersatz einer neuen Gruppe --
dort, wo man beim ersten Oeffnen ohnehin hinsieht, und nur ab drei
Leuten. In einem Zweiergespraech waere er Ballast.

GEPRUEFT: pruef-erwaehnung 67/0 (neu) -- 13 Regelfaelle ohne Server,
der Weg durch die Schnittstelle, das Zeichen in der Liste samt
Gegenprobe bei jemandem, der dabei aber nicht gemeint war, beide
Aufloesungen nebeneinander, und der ganze Ablauf am echten Bildschirm
bis "Enter waehlt und schickt NICHT ab".
Dazu pruef-chat 48/0, pruef-chat-optik 36/0, pruef-treffchat 110/0,
pruef-chat-kanaele 79/0, pruef-glocke 31/0, pruef-push 24/0,
pruef-anruf-klingelt, pruef-css-klassen, pruef-meldungen,
pruef-nachfrage, pruef-tippziele.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-20 17:54:16 +02:00
co-authored by Claude Opus 5
parent 409ed551f3
commit 2a26a49f91
44 changed files with 1959 additions and 534 deletions
+353 -18
View File
@@ -465,6 +465,23 @@
mitte.append(vorschau);
knopf.append(mitte);
/* WURDE ICH HIER ANGESPROCHEN? (20.09.2026)
Eine Zahl sagt "hier ist etwas". Sie sagt nicht, ob es an DICH
war. Wer morgens vier Gespraeche mit Zahlen sieht, macht das
lauteste zuerst auf -- und dort steht selten das, was auf ihn
wartet.
Das Zeichen steht VOR der Zahl, weil es die wichtigere Auskunft
ist, und es traegt einen eigenen Satz fuer Vorleseprogramme:
Ein "@" allein wird je nach Programm als "at", "Klammeraffe"
oder gar nicht vorgelesen. */
if (r.erwaehnt) {
const marke = el('span', 'chat-raum__erwaehnt', '@');
marke.title = 'Du wurdest hier erwähnt';
marke.setAttribute('aria-label', 'Du wurdest hier erwähnt');
knopf.append(marke);
}
if (r.ungelesen) {
const zahl = el('span', 'chat-raum__zahl', String(r.ungelesen));
zahl.setAttribute('aria-label', `${r.ungelesen} ungelesen`);
@@ -952,7 +969,22 @@
Die Seite sieht danach voellig normal aus. */
for (const alt of [...ziel.children]) if (alt.id !== 'verlauf-leer') alt.remove();
if (!nachrichten.length) {
ziel.append(el('p', 'chat__leer', 'Noch nichts geschrieben. Fang an.'));
/* WOHER WEISS JEMAND, DASS ES "@" GIBT? (20.09.2026)
Nirgends. Ein Zeichen, das man kennen muss, um es zu finden,
kennt beim ersten Mal niemand -- und im Chat gibt es keinen
Hilfeknopf, in den man es schreiben koennte.
Der Satz steht deshalb GENAU DORT, wo eine Gruppe anfaengt:
beim ersten Oeffnen, bevor etwas drinsteht. Er verschwindet mit
der ersten Nachricht und steht damit nie im Weg.
NUR BEI DREI ODER MEHR. In einem Zweiergespraech ist ohnehin
klar, wer gemeint ist -- dort waere der Hinweis Ballast. */
const inGruppe = (offen?.teilnehmer?.length || 0) > 2;
ziel.append(el('p', 'chat__leer', inGruppe
? 'Noch nichts geschrieben. Fang an — mit @ sprichst du jemanden direkt an.'
: 'Noch nichts geschrieben. Fang an.'));
return;
}
@@ -1073,7 +1105,7 @@
NUR WENN WIRKLICH TEXT DA IST: Eine Nachricht, die nur aus
einem Foto besteht, hätte sonst einen leeren Absatz darunter
-- und der nimmt Platz, den niemand erklären kann. */
if (n.text) blase.append(mitLinks(n.text));
if (n.text) blase.append(textAbsatz(n.text));
}
const fuss = el('div', 'chat-nachricht__fuss');
@@ -1335,23 +1367,289 @@
`rel="noopener noreferrer"` ist Pflicht: Ohne `noopener` kann die
geoeffnete Seite auf das Fenster zurueckgreifen, aus dem sie kam. */
const LINKMUSTER = /(https?:\/\/[^\s<>"']+|www\.[^\s<>"']+)/g;
function mitLinks(text) {
const p = el('p', 'chat-nachricht__text');
let letzte = 0;
for (const treffer of String(text).matchAll(LINKMUSTER)) {
const roh = treffer[0];
/* Satzzeichen am Ende gehoeren zum Satz, nicht zur Adresse. */
const sauber = roh.replace(/[.,;:!?)\]]+$/, '');
if (treffer.index > letzte) p.append(document.createTextNode(text.slice(letzte, treffer.index)));
const a = el('a', 'chat-nachricht__link', sauber);
a.href = sauber.startsWith('www.') ? 'https://' + sauber : sauber;
a.target = '_blank';
a.rel = 'noopener noreferrer';
p.append(a);
if (sauber.length < roh.length) p.append(document.createTextNode(roh.slice(sauber.length)));
letzte = treffer.index + roh.length;
/* =====================================================================
WER IST MIT "@" GEMEINT? -- die Fassung fuer den Browser
(20.09.2026)
DIESELBEN REGELN WIE IM SERVER. Das Original samt Begruendung je
Regel steht in server/chat-erwaehnung.js; hier steht eine zweite,
wortgleiche Fassung.
WARUM UEBERHAUPT ZWEIMAL: Der Browser muss beim Tippen und beim
Zeichnen sofort wissen, welcher Teil des Satzes ein Name ist --
ohne zu fragen. Eine Nachfrage je Nachricht waere eine Abfrage
mehr fuer etwas, das sich aus dem vorliegenden Text ergibt.
UND WARUM DAS GEFAEHRLICH IST: Zwei Rechnungen fuer dieselbe Frage
laufen irgendwann auseinander -- und dann hebt die Seite jemanden
hervor, den der Server nie benachrichtigt hat, oder umgekehrt.
Deshalb stellt `pruef-erwaehnung.mjs` beide Fassungen an denselben
Proben nebeneinander. Sie LIEST diese Funktion aus der Datei und
fuehrt sie aus, statt sie nachzubauen: Ein Nachbau wuerde pruefen,
ob ich zweimal dasselbe schreiben kann.
SIE STEHT BEWUSST OHNE AUSSENBEZUG DA -- auch das Zeichenmuster
wird hier drinnen gebildet. Sonst liesse sie sich nicht einzeln
herausloesen und ausfuehren. */
function erwaehnungenFinden(text, leute) {
const WORT = /[\p{L}\p{N}_]/u;
const wortBei = (t, i) => (i >= 0 && i < t.length && WORT.test(t[i]));
const satz = String(text || '');
if (!satz.includes('@')) return [];
const nachLaenge = (Array.isArray(leute) ? leute.slice() : [])
.filter((x) => x && x.id != null && String(x.name || '').trim())
.sort((a, b) => String(b.name).length - String(a.name).length);
if (!nachLaenge.length) return [];
const klein = satz.toLowerCase();
const treffer = [];
for (let i = klein.indexOf('@'); i !== -1; i = klein.indexOf('@', i + 1)) {
if (wortBei(satz, i - 1)) continue;
for (const person of nachLaenge) {
const name = String(person.name);
if (!klein.startsWith(name.toLowerCase(), i + 1)) continue;
if (wortBei(satz, i + 1 + name.length)) continue;
treffer.push({ id: person.id, name, von: i, bis: i + 1 + name.length });
break;
}
}
if (letzte < text.length) p.append(document.createTextNode(text.slice(letzte)));
return treffer;
}
/* =====================================================================
DIE AUSWAHL BEIM TIPPEN (20.09.2026)
Filipe: "ich will das man die leute mit @ markieren kann im chat."
WARUM EINE AUSWAHLLISTE UND NICHT NUR "@ TIPPEN GENUEGT":
Der Server erkennt auch ein von Hand getipptes "@VanVan" -- die
Liste ist also keine Pflicht, sondern eine Abkuerzung. Sie loest
aber drei Dinge, die man sonst selbst wissen muesste:
* WIE heisst die Person genau? "@Van" trifft nicht, "@vanvan"
schon. Ohne Liste probiert man.
* WER ist ueberhaupt in diesem Raum? Nur die koennen gemeint
sein -- das sieht man sonst nirgends beim Schreiben.
* Habe ich mich VERTIPPT? Ein "@Vanvna" faellt niemandem auf,
am wenigsten dem, der gemeint war.
WAS SIE BEWUSST NICHT TUT: Sie erzwingt nichts. Wer weitertippt,
ohne etwas zu waehlen, schreibt einfach weiter; die Liste
verschwindet, sobald nichts mehr passt. Eine Auswahl, aus der man
nicht herauskommt, ist eine Falle -- und im Chat schreibt man oft
ein "@" ohne jede Absicht ("um 10@Bahnhof").
DIE TASTATUR ZUERST: Pfeil hoch/runter, Enter oder Tab waehlt,
Escape schliesst. Am Rechner schreibt die Betreuung, und dort ist
der Griff zur Maus mitten im Satz der teuerste Handgriff
ueberhaupt.
===================================================================== */
/** Steht gerade eine Auswahl offen? Dann: ab welcher Stelle im Text
* (Position des "@") und welcher Eintrag ist vorgemerkt. */
let erwaehnAuswahl = null;
/** Die Stelle vor dem Schreibzeiger auf ein angefangenes "@..." hin
* ansehen.
*
* GIBT null ZURUECK, SOBALD ETWAS NICHT PASST -- und das ist der
* wichtigste Teil: Ein Leerzeichen beendet die Suche, ein Buchstabe
* vor dem "@" laesst sie gar nicht erst anfangen (sonst oeffnete
* sich die Liste mitten in jeder E-Mail-Adresse). Dieselbe Grenze
* wie beim Aufloesen, damit die Liste nichts anbietet, was der
* Server hinterher nicht erkennt. */
function angefangenesAt(feld) {
const bis = feld.selectionStart;
if (bis !== feld.selectionEnd) return null; // etwas ist markiert
const text = feld.value.slice(0, bis);
const at = text.lastIndexOf('@');
if (at === -1) return null;
const davor = at > 0 ? text[at - 1] : '';
if (davor && /[\p{L}\p{N}_]/u.test(davor)) return null;
const rumpf = text.slice(at + 1);
/* Ein Leerzeichen (oder ein Umbruch) beendet es. Namen in diesem
Haus sind einwortig; dieselbe Annahme steht im Server. */
if (/\s/.test(rumpf)) return null;
if (rumpf.length > 24) return null; // so heisst niemand
return { at, rumpf };
}
/** Wer passt gerade? -- die Teilnehmer dieses Raums, ohne mich. */
function erwaehnTreffer(rumpf) {
const klein = rumpf.toLowerCase();
return (offen?.teilnehmer || [])
/* SICH SELBST KANN MAN NICHT ANSPRECHEN -- der Server verwirft es
ohnehin. Ein Eintrag, der nichts bewirkt, gehoert nicht in die
Liste. */
.filter((t) => t.id !== ich?.id)
.filter((t) => String(t.name).toLowerCase().startsWith(klein))
.slice(0, 8);
}
function erwaehnSchliessen() {
erwaehnAuswahl = null;
const l = $('erwaehn-liste');
if (l) { l.hidden = true; l.textContent = ''; }
$('text')?.removeAttribute('aria-activedescendant');
}
/** Die Liste zeichnen -- oder schliessen, wenn nichts passt. */
function erwaehnPruefen() {
const feld = $('text');
const liste = $('erwaehn-liste');
if (!feld || !liste) return;
const stand = angefangenesAt(feld);
if (!stand) { erwaehnSchliessen(); return; }
const treffer = erwaehnTreffer(stand.rumpf);
if (!treffer.length) { erwaehnSchliessen(); return; }
/* Die Vormerkung bleibt auf DERSELBEN Person, solange sie noch
passt. Sonst springt die Auswahl bei jedem Buchstaben auf den
ersten Eintrag zurueck, und wer zweimal nach unten getippt hat,
verliert es beim naechsten Zeichen. */
const vorherId = erwaehnAuswahl?.treffer?.[erwaehnAuswahl.wahl]?.id;
const neueWahl = Math.max(0, treffer.findIndex((t) => t.id === vorherId));
erwaehnAuswahl = { at: stand.at, rumpf: stand.rumpf, treffer, wahl: neueWahl };
liste.textContent = '';
treffer.forEach((t, i) => {
const b = el('button', 'erwaehn-liste__eintrag');
b.type = 'button';
b.id = 'erwaehn-' + t.id;
b.setAttribute('role', 'option');
b.setAttribute('aria-selected', i === neueWahl ? 'true' : 'false');
if (i === neueWahl) b.dataset.wahl = 'ja';
const z = el('span', 'erwaehn-liste__zeichen', initialen(t.name));
z.setAttribute('aria-hidden', 'true');
z.dataset.rolle = t.rolle || '';
b.append(z, el('span', 'erwaehn-liste__name', t.name));
if (ROLLENKURZ[t.rolle]) b.append(el('span', 'erwaehn-liste__rolle', ROLLENKURZ[t.rolle]));
/* mousedown statt click: Ein Klick nimmt dem Textfeld zuerst den
Schreibzeiger, und danach weiss niemand mehr, wohin der Name
gehoert. `preventDefault` haelt ihn, wo er ist. */
b.addEventListener('mousedown', (e) => { e.preventDefault(); erwaehnWaehlen(i); });
liste.append(b);
});
liste.hidden = false;
feld.setAttribute('aria-activedescendant', 'erwaehn-' + treffer[neueWahl].id);
}
/** Den vorgemerkten Namen einsetzen. */
function erwaehnWaehlen(i) {
const a = erwaehnAuswahl;
const feld = $('text');
if (!a || !feld) return;
const person = a.treffer[i ?? a.wahl];
if (!person) return;
const bis = feld.selectionStart;
/* EIN LEERZEICHEN HINTERHER -- sonst klebt das naechste Wort am
Namen, und aus "@VanVankannst" wird gar keine Ansprache mehr.
Nur, wenn dort nicht schon eines steht. */
const rest = feld.value.slice(bis);
const luecke = rest.startsWith(' ') ? '' : ' ';
feld.value = feld.value.slice(0, a.at) + '@' + person.name + luecke + rest;
const neu = a.at + 1 + person.name.length + luecke.length;
feld.setSelectionRange(neu, neu);
erwaehnSchliessen();
feld.focus();
hoeheAnpassen();
entwurfMerken();
zaehlerZeigen();
}
/** Die Tasten, solange die Liste offen ist.
*
* GIBT true ZURUECK, wenn die Taste verbraucht ist -- der Aufrufer
* hoert dann auf. So bleibt Enter zum Abschicken erhalten, sobald
* keine Liste offen steht, und Escape verwirft weiterhin die
* Antwort. Ohne diese Rueckgabe muesste an zwei Stellen dieselbe
* Bedingung stehen. */
function erwaehnTaste(e) {
if (!erwaehnAuswahl) return false;
const n = erwaehnAuswahl.treffer.length;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const richtung = e.key === 'ArrowDown' ? 1 : -1;
erwaehnAuswahl.wahl = (erwaehnAuswahl.wahl + richtung + n) % n;
const knoepfe = $('erwaehn-liste').querySelectorAll('.erwaehn-liste__eintrag');
knoepfe.forEach((b, i) => {
b.setAttribute('aria-selected', i === erwaehnAuswahl.wahl ? 'true' : 'false');
if (i === erwaehnAuswahl.wahl) b.dataset.wahl = 'ja'; else delete b.dataset.wahl;
});
const gewaehlt = erwaehnAuswahl.treffer[erwaehnAuswahl.wahl];
$('text').setAttribute('aria-activedescendant', 'erwaehn-' + gewaehlt.id);
knoepfe[erwaehnAuswahl.wahl]?.scrollIntoView({ block: 'nearest' });
return true;
}
if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); erwaehnWaehlen(); return true; }
if (e.key === 'Escape') { e.preventDefault(); erwaehnSchliessen(); return true; }
return false;
}
/* EIN ABSATZ, ZWEI SORTEN HERVORHEBUNG (erweitert 20.09.2026).
Hiess frueher `mitLinks` und konnte nur Adressen. Seit es das
Markieren mit "@" gibt, muessen ZWEI Sorten Stellen in denselben
Absatz -- und sie koennen sich ueberschneiden: In
"schau auf www.x.de/@Tili" steckt ein Name mitten in einer
Adresse.
DESHALB ERST SAMMELN, DANN SORTIEREN, DANN ZEICHNEN. Zwei
Schleifen nacheinander haetten den Text zweimal zerschnitten und
die zweite haette die Stuecke der ersten nicht gesehen. Bei einer
Ueberschneidung gewinnt, was frueher anfaengt; die Adresse ist
laenger und faengt frueher an, also bleibt sie ganz.
textContent, nie innerHTML -- das gilt unveraendert und ist der
Grund, warum hier Knoten gebaut und nicht Zeichenketten
zusammengeklebt werden. Eine Nachricht darf keine Auszeichnung
einschleusen, auch keinen eigenen Namen in spitzen Klammern. */
function textAbsatz(text) {
const p = el('p', 'chat-nachricht__text');
const roh = String(text);
const stellen = [];
for (const treffer of roh.matchAll(LINKMUSTER)) {
/* Satzzeichen am Ende gehoeren zum Satz, nicht zur Adresse. */
const sauber = treffer[0].replace(/[.,;:!?)\]]+$/, '');
stellen.push({ art: 'link', von: treffer.index, bis: treffer.index + sauber.length, wort: sauber });
}
/* Gegen die Teilnehmer DIESES Raums -- wie im Server. Steht kein
Raum offen (etwa beim Suchen), wird nichts hervorgehoben: lieber
grau als falsch. */
for (const t of erwaehnungenFinden(roh, offen?.teilnehmer || [])) {
stellen.push({ art: 'erwaehnung', von: t.von, bis: t.bis,
wort: roh.slice(t.von, t.bis), id: t.id });
}
stellen.sort((a, b) => a.von - b.von || b.bis - a.bis);
let letzte = 0;
for (const st of stellen) {
if (st.von < letzte) continue; // liegt in einer frueheren Stelle
if (st.von > letzte) p.append(document.createTextNode(roh.slice(letzte, st.von)));
if (st.art === 'link') {
const a = el('a', 'chat-nachricht__link', st.wort);
a.href = st.wort.startsWith('www.') ? 'https://' + st.wort : st.wort;
a.target = '_blank';
a.rel = 'noopener noreferrer';
p.append(a);
} else {
/* KEIN KNOPF, SONDERN EINE MARKE. Ein Klick darauf muesste
irgendwohin fuehren -- zum Steckbrief? in ein Zweiergespraech?
Beides waere eine Entscheidung, die niemand bestellt hat, und
ein Knopf, der nichts tut, ist schlimmer als keiner.
Hervorgehoben wird, damit man es SIEHT; mehr verspricht es
nicht. */
const m = el('span', 'chat-erwaehnung', st.wort);
/* BIN ICH GEMEINT? Dann deutlicher. Das ist der eigentliche
Zweck: In einem Raum mit acht Leuten soll der eigene Name
aus dem Absatz herausspringen. */
if (ich && st.id === ich.id) m.dataset.ich = 'ja';
p.append(m);
}
letzte = st.bis;
}
if (letzte < roh.length) p.append(document.createTextNode(roh.slice(letzte)));
return p;
}
@@ -1992,6 +2290,10 @@
zur Maus. */
$('antwort-weg')?.addEventListener('click', antwortLoeschen);
$('text')?.addEventListener('keydown', (e) => {
/* ERST DIE AUSWAHLLISTE, DANN DIE ANTWORT. Escape raeumt immer
das Oberste weg -- steht eine Liste offen, ist sie gemeint.
Sonst verloere man mit einem Tastendruck beides auf einmal. */
if (erwaehnTaste(e)) return;
if (e.key === 'Escape' && antwortAuf) { e.preventDefault(); antwortLoeschen(); }
});
@@ -2001,7 +2303,21 @@
hoeheAnpassen();
entwurfMerken();
zaehlerZeigen();
erwaehnPruefen();
});
/* AUCH BEIM BLOSSEN BEWEGEN DES SCHREIBZEIGERS. Wer mit der Maus
oder den Pfeiltasten hinter ein bereits getipptes "@Van" springt,
will dort weitermachen -- ohne diesen Weg bliebe die Liste zu, bis
er einen Buchstaben tippt. */
$('text').addEventListener('click', erwaehnPruefen);
$('text').addEventListener('keyup', (e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight'
|| e.key === 'Home' || e.key === 'End') erwaehnPruefen();
});
/* Wer das Feld verlaesst, hat es sich anders ueberlegt. `mousedown`
auf einem Eintrag haelt den Schreibzeiger fest, deshalb schliesst
dieser Weg die Liste nicht versehentlich vor dem Klick. */
$('text').addEventListener('blur', erwaehnSchliessen);
/* ---------- Entwurf je Gespraech (09.09.2026) ----------------------------
Wer mitten im Satz das Gespraech wechselt, findet den Satz beim
@@ -2133,6 +2449,25 @@
NICHT -- dort ist Enter die Zeilenschaltung der Bildschirmtastatur,
und wer eine zweite Zeile will, käme sonst nie dazu. */
$('text').addEventListener('keydown', (e) => {
/* STEHT DIE AUSWAHL OFFEN, GEHOERT ENTER IHR. Ohne diese Zeile
waere jedes Waehlen mit der Tastatur ein versehentlich
abgeschickter halber Satz -- und zwar ausgerechnet fuer die, die
am Rechner schnell schreiben.
Der Ausgang steht VOR der Handy-Abfrage: Am Handy schickt Enter
ohnehin nicht ab, aber waehlen soll es auch dort.
GEFRAGT WIRD NACH `defaultPrevented`, NICHT NACH DER LISTE.
Der erste Anlauf fragte `if (erwaehnAuswahl && ...)` -- und lief
ins Leere: Der Handler weiter oben waehlt den Namen aus und
SCHLIESST dabei die Liste. Wenn dieser hier an die Reihe kommt,
ist `erwaehnAuswahl` schon null, die Bedingung falsch, und der
halbe Satz geht raus. Genau der Fehler, den die Funktion
verhindern soll.
`defaultPrevented` beantwortet die Frage, um die es wirklich
geht: Hat diese Taste ihre Arbeit schon getan? */
if (e.defaultPrevented) return;
const handy = window.matchMedia('(hover: none)').matches;
if (e.key === 'Enter' && !e.shiftKey && !handy) {
e.preventDefault();