mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Images du fil : miniatures des deux côtés, loupe au clic
Une pièce jointe n'affichait que son nom de fichier, et une image posée par l'IA sortait à sa taille NATIVE — .chatimg portait la classe mais aucune règle CSS, donc une capture remplissait la colonne et poussait le texte hors de vue. - pièce jointe image : vignette dans le composeur (objectURL local, rien n'est encore parti au serveur) et tuile 200x160 dans le message envoyé, hydratée comme les captures (fetch authentifié + blob:) ; - images de l'IA plafonnées à 280px de haut ; - clic sur n'importe quelle image = loupe plein écran (Échap ou clic sur le fond pour fermer). La source est relue AU CLIC, l'hydratation remplaçant le src par un blob: après coup. align-items:flex-start sur .msg-files au passage : une pastille texte voisine d'une vignette s'étirait à sa hauteur et, arrondie à 999px, devenait un gros ovale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
781520afd8
commit
88e1b38582
5 files changed
+310
-10
No files matched your search
+155
-5
@@ -1232,9 +1232,48 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
alignée à droite comme elle. Rangée et bulle restent deux objets distincts,
|
||||
séparés par l'espacement normal du fil — les rapprocher les faisait lire comme
|
||||
une seule bulle difforme qui déborde vers le haut. */
|
||||
/* align-items:flex-start : depuis que la rangée peut contenir une VIGNETTE, une
|
||||
pastille texte voisine s'étirait à sa hauteur (étirement par défaut du flex) —
|
||||
et, arrondie à 999px, se retrouvait en gros ovale. Chaque objet garde sa
|
||||
hauteur propre. */
|
||||
#chat .msg-files{align-self:flex-end;display:flex;flex-wrap:wrap;justify-content:flex-end;
|
||||
gap:6px;max-width:75%}
|
||||
align-items:flex-start;gap:6px;max-width:75%}
|
||||
#chat .msg-files .chip-file{background:var(--bubble);max-width:100%}
|
||||
/* Vignette dans une pastille (composeur) : assez grande pour reconnaître la
|
||||
capture qu'on vient de coller, assez petite pour que la rangée reste une
|
||||
rangée de pastilles et pas une planche-contact. */
|
||||
.chip-file .cf-thumb{flex:none;width:26px;height:26px;object-fit:cover;border-radius:3px;
|
||||
background:var(--panel)}
|
||||
.chip-file.has-thumb{padding-left:4px}
|
||||
/* Pièce jointe IMAGE d'un message envoyé : la vignette remplace la pastille.
|
||||
Plafonnée pour ne pas déséquilibrer le fil ; cliquable pour la voir en grand. */
|
||||
#chat .msg-files .msg-img{display:flex;max-width:100%;border-radius:4px;overflow:hidden;
|
||||
border:1px solid var(--border);background:var(--panel)}
|
||||
/* margin/border remis à zéro : .chatimg (posée par hydrateImages) s'applique
|
||||
aussi ici, or le cadre est déjà porté par la tuile. */
|
||||
#chat .msg-files .msg-img img{display:block;max-width:200px;max-height:160px;
|
||||
width:auto;height:auto;object-fit:contain;cursor:zoom-in;margin:0;border:none;border-radius:0}
|
||||
/* Images posées par l'IA dans sa réponse (captures, /api/chat/image). Sans ce
|
||||
plafond elles sortaient à leur taille NATIVE : une capture d'écran remplissait
|
||||
la colonne entière et poussait le texte hors de vue. */
|
||||
#chat .chatimg{display:block;max-width:100%;max-height:280px;width:auto;height:auto;
|
||||
margin:6px 0;border:1px solid var(--border);border-radius:4px;cursor:zoom-in}
|
||||
/* Loupe : l'image reprend toute la fenêtre, moins une marge. Le fond ferme au
|
||||
clic (curseur zoom-out), l'image non — on veut pouvoir la regarder de près. */
|
||||
#lightbox{position:fixed;inset:0;z-index:80;display:none;align-items:center;
|
||||
justify-content:center;padding:28px;background:rgba(0,0,0,.72);cursor:zoom-out;
|
||||
opacity:0;transition:opacity .16s ease}
|
||||
#lightbox.show{opacity:1}
|
||||
#lightbox img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;
|
||||
border-radius:4px;box-shadow:0 16px 50px rgba(0,0,0,.45);cursor:default}
|
||||
#lightbox .lb-x{position:absolute;top:10px;right:14px;margin:0;padding:0;
|
||||
width:36px;height:36px;display:flex;align-items:center;justify-content:center;
|
||||
font-size:22px;line-height:1;border:none;border-radius:50%;
|
||||
background:rgba(0,0,0,.35);color:#fff}
|
||||
#lightbox .lb-x:hover{background:rgba(0,0,0,.6)}
|
||||
#lightbox .lb-name{position:absolute;left:0;right:0;bottom:10px;text-align:center;
|
||||
color:#fff;opacity:.72;font-size:12px;pointer-events:none}
|
||||
@media (prefers-reduced-motion:reduce){#lightbox{transition:none}}
|
||||
/* Lien de téléchargement dans une réponse : un lien normal, mais la flèche dit
|
||||
qu'il ramène un fichier plutôt qu'il n'ouvre une page. */
|
||||
#chat .msg .filelink{cursor:pointer}
|
||||
@@ -5198,6 +5237,13 @@ function hydrateImages(root){
|
||||
img.dataset.hydrated = '1';
|
||||
const src = img.getAttribute('src');
|
||||
img.classList.add('chatimg');
|
||||
// Nom lisible pour la loupe : la fin du chemin visé, pas l'URL d'API.
|
||||
let name = img.getAttribute('alt') || '';
|
||||
if(!name){
|
||||
const m = /[?&]path=([^&]*)/.exec(src);
|
||||
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
||||
}
|
||||
bindZoom(img, name);
|
||||
const cached = IMG_CACHE.get(src);
|
||||
if(cached){ img.src = cached; return; }
|
||||
try{
|
||||
@@ -6529,12 +6575,28 @@ function fmtSize(n){
|
||||
if(n >= 1024) return Math.round(n/1024)+' Ko';
|
||||
return n+' o';
|
||||
}
|
||||
// Un nom de fichier ne dit pas ce qu'on a joint : pour une image, on montre
|
||||
// l'image. Reconnaissance à l'extension — le serveur, lui, renifle le CONTENU
|
||||
// (handleChatImage) et répond 415 si ça n'en est pas une, donc au pire on affiche
|
||||
// une vignette cassée qui retombe sur son texte de repli.
|
||||
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|ico|heic|heif|tiff?)$/i;
|
||||
function isImageName(name){ return IMG_EXT.test(String(name||'')); }
|
||||
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
|
||||
// objet visuel des deux côtés, seul le contexte (CSS) change.
|
||||
//
|
||||
// opts.thumb = URL d'aperçu : la pastille porte alors une vignette à gauche du
|
||||
// nom. Le composeur s'en sert avec un objectURL local (rien n'est encore parti
|
||||
// vers le serveur à ce stade).
|
||||
function fileChip(name, size, opts){
|
||||
opts=opts||{};
|
||||
const chip=document.createElement('div');
|
||||
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
|
||||
if(opts.thumb){
|
||||
const t=document.createElement('img');
|
||||
t.className='cf-thumb'; t.src=opts.thumb; t.alt='';
|
||||
chip.appendChild(t);
|
||||
chip.classList.add('has-thumb');
|
||||
}
|
||||
const n=document.createElement('span');
|
||||
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
|
||||
const s=document.createElement('span');
|
||||
@@ -6552,12 +6614,31 @@ function fileChip(name, size, opts){
|
||||
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
|
||||
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
|
||||
// un bloc bâtard, moitié texte moitié pastilles.
|
||||
// Vignette d'une image DÉJÀ déposée : on la redemande au serveur par son chemin
|
||||
// de travail. hydrateImages fait le fetch authentifié et pose le blob: — une
|
||||
// balise <img> ne peut pas porter la clé de pilotage.
|
||||
function msgImgTile(f){
|
||||
const box=document.createElement('div');
|
||||
box.className='msg-img';
|
||||
const img=document.createElement('img');
|
||||
img.src='/api/chat/image?path='+encodeURIComponent(f.path);
|
||||
img.alt=f.name||''; img.title=f.name||'';
|
||||
box.appendChild(img);
|
||||
return box;
|
||||
}
|
||||
function addMsgFiles(el, files){
|
||||
if(!el || !files || !files.length) return;
|
||||
const box=document.createElement('div');
|
||||
box.className='msg-files';
|
||||
for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
|
||||
for(const f of files){
|
||||
// Image : la vignette REMPLACE la pastille texte. Deux objets pour un seul
|
||||
// fichier (une vignette + son nom en pastille) doublerait la rangée sans rien
|
||||
// apprendre — le nom vit dans l'infobulle et dans la loupe.
|
||||
if(f.path && isImageName(f.name)) box.appendChild(msgImgTile(f));
|
||||
else box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
|
||||
}
|
||||
el.parentNode.insertBefore(box, el);
|
||||
hydrateImages(box);
|
||||
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
|
||||
markEmptyMsg(el);
|
||||
return box;
|
||||
@@ -6705,11 +6786,18 @@ function renderAttach(){
|
||||
sizeText: a.state==='err' ? 'échec'
|
||||
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
|
||||
: fmtSize(a.size),
|
||||
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
|
||||
thumb: a.thumb,
|
||||
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); dropThumb(a); renderAttach(); }
|
||||
}));
|
||||
}
|
||||
}
|
||||
function clearAttach(){ ATTACH=[]; renderAttach(); }
|
||||
// L'aperçu du composeur vit sur un objectURL local : il meurt avec sa pastille,
|
||||
// sinon chaque fichier retiré laisse son image en mémoire jusqu'au rechargement.
|
||||
// (La bulle envoyée, elle, repart du serveur — elle ne dépend pas de cet URL.)
|
||||
function dropThumb(a){
|
||||
if(a && a.thumb){ URL.revokeObjectURL(a.thumb); a.thumb=null; }
|
||||
}
|
||||
function clearAttach(){ for(const a of ATTACH) dropThumb(a); ATTACH=[]; renderAttach(); }
|
||||
// Ce qui part avec le message, pour l'afficher dans sa bulle.
|
||||
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
|
||||
|
||||
@@ -6780,7 +6868,11 @@ function addFiles(files){
|
||||
for(const f of files||[]){
|
||||
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
|
||||
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
|
||||
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'});
|
||||
// Aperçu tout de suite, depuis le fichier local : rien n'est encore parti
|
||||
// vers le serveur (le dépôt n'a lieu qu'à l'envoi), donc c'est la seule
|
||||
// source d'image disponible à ce stade.
|
||||
const thumb = isImageName(f.name) ? URL.createObjectURL(f) : null;
|
||||
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued', thumb:thumb});
|
||||
}
|
||||
renderAttach();
|
||||
}
|
||||
@@ -7380,4 +7472,62 @@ function paintModelSwitch(){
|
||||
// Une bascule en cours ne doit pas voir sa sélection sauter sous la souris.
|
||||
if(prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
|
||||
}
|
||||
// ===== Loupe d'image =======================================================
|
||||
// Les images du fil sont PLAFONNÉES (CSS .chatimg / .msg-img) : sans plafond,
|
||||
// une capture sortait à sa taille native et mangeait tout l'écran, ce qui rendait
|
||||
// la conversation illisible. Le plafond ne se paie donc pas en information : un
|
||||
// clic rouvre l'image ici, aussi grande que la fenêtre le permet.
|
||||
//
|
||||
// On réutilise la source DÉJÀ chargée par la vignette (blob: hydraté par
|
||||
// hydrateImages, ou objectURL local d'une pièce jointe en attente) : rien n'est
|
||||
// re-téléchargé, et ça marche derrière la clé de pilotage — qu'une balise <img>
|
||||
// ne sait pas porter, d'où le blob: en premier lieu.
|
||||
function lightboxEl(){
|
||||
let el=document.getElementById('lightbox');
|
||||
if(el) return el;
|
||||
el=document.createElement('div');
|
||||
el.id='lightbox';
|
||||
el.innerHTML='<button type="button" class="lb-x" title="fermer" aria-label="fermer">×</button>'
|
||||
+'<img alt=""><div class="lb-name"></div>';
|
||||
// Clic sur le FOND (ou sur le ×) = fermer ; clic sur l'image = ne rien faire,
|
||||
// sinon on referme dès qu'on veut la regarder de près.
|
||||
el.onclick=e=>{ if(e.target===el || e.target.classList.contains('lb-x')) closeLightbox(); };
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
// Capture (3e argument à true) : sans elle, Échap serait d'abord vu par les
|
||||
// écouteurs des modales déjà en place et la loupe ne se fermerait pas.
|
||||
function _lbKey(e){
|
||||
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); closeLightbox(); }
|
||||
}
|
||||
function openLightbox(src, name){
|
||||
if(!src) return;
|
||||
const el=lightboxEl(), img=el.querySelector('img');
|
||||
img.src=src;
|
||||
img.alt=name||'';
|
||||
el.querySelector('.lb-name').textContent=name||'';
|
||||
showModal(el);
|
||||
document.addEventListener('keydown', _lbKey, true);
|
||||
}
|
||||
function closeLightbox(){
|
||||
const el=document.getElementById('lightbox'); if(!el) return;
|
||||
document.removeEventListener('keydown', _lbKey, true);
|
||||
hideModal(el);
|
||||
// La source n'est lâchée qu'APRÈS la transition : la vider tout de suite
|
||||
// laissait un cadre vide clignoter pendant la fermeture.
|
||||
setTimeout(()=>{
|
||||
if(!el.classList.contains('show')) el.querySelector('img').removeAttribute('src');
|
||||
}, 200);
|
||||
}
|
||||
// Rend une image cliquable (idempotent : renderBody reconstruit le DOM à chaque
|
||||
// delta du streaming, et hydrateImages nous rappelle sur les mêmes éléments).
|
||||
//
|
||||
// On lit currentSrc/src AU CLIC et non à la liaison : l'hydratation remplace la
|
||||
// source par un blob: après coup, et figer l'URL /api/chat/image ici rouvrirait
|
||||
// une image que le navigateur n'a pas le droit de charger seul.
|
||||
function bindZoom(img, name){
|
||||
if(!img || img.dataset.zoom) return;
|
||||
img.dataset.zoom='1';
|
||||
img.addEventListener('click', ()=>openLightbox(img.currentSrc||img.src, name||img.alt||''));
|
||||
}
|
||||
</script></body></html>
|
||||
@@ -191,6 +191,13 @@ function hydrateImages(root){
|
||||
img.dataset.hydrated = '1';
|
||||
const src = img.getAttribute('src');
|
||||
img.classList.add('chatimg');
|
||||
// Nom lisible pour la loupe : la fin du chemin visé, pas l'URL d'API.
|
||||
let name = img.getAttribute('alt') || '';
|
||||
if(!name){
|
||||
const m = /[?&]path=([^&]*)/.exec(src);
|
||||
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
||||
}
|
||||
bindZoom(img, name);
|
||||
const cached = IMG_CACHE.get(src);
|
||||
if(cached){ img.src = cached; return; }
|
||||
try{
|
||||
|
||||
@@ -26,12 +26,28 @@ function fmtSize(n){
|
||||
if(n >= 1024) return Math.round(n/1024)+' Ko';
|
||||
return n+' o';
|
||||
}
|
||||
// Un nom de fichier ne dit pas ce qu'on a joint : pour une image, on montre
|
||||
// l'image. Reconnaissance à l'extension — le serveur, lui, renifle le CONTENU
|
||||
// (handleChatImage) et répond 415 si ça n'en est pas une, donc au pire on affiche
|
||||
// une vignette cassée qui retombe sur son texte de repli.
|
||||
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|ico|heic|heif|tiff?)$/i;
|
||||
function isImageName(name){ return IMG_EXT.test(String(name||'')); }
|
||||
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
|
||||
// objet visuel des deux côtés, seul le contexte (CSS) change.
|
||||
//
|
||||
// opts.thumb = URL d'aperçu : la pastille porte alors une vignette à gauche du
|
||||
// nom. Le composeur s'en sert avec un objectURL local (rien n'est encore parti
|
||||
// vers le serveur à ce stade).
|
||||
function fileChip(name, size, opts){
|
||||
opts=opts||{};
|
||||
const chip=document.createElement('div');
|
||||
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
|
||||
if(opts.thumb){
|
||||
const t=document.createElement('img');
|
||||
t.className='cf-thumb'; t.src=opts.thumb; t.alt='';
|
||||
chip.appendChild(t);
|
||||
chip.classList.add('has-thumb');
|
||||
}
|
||||
const n=document.createElement('span');
|
||||
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
|
||||
const s=document.createElement('span');
|
||||
@@ -49,12 +65,31 @@ function fileChip(name, size, opts){
|
||||
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
|
||||
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
|
||||
// un bloc bâtard, moitié texte moitié pastilles.
|
||||
// Vignette d'une image DÉJÀ déposée : on la redemande au serveur par son chemin
|
||||
// de travail. hydrateImages fait le fetch authentifié et pose le blob: — une
|
||||
// balise <img> ne peut pas porter la clé de pilotage.
|
||||
function msgImgTile(f){
|
||||
const box=document.createElement('div');
|
||||
box.className='msg-img';
|
||||
const img=document.createElement('img');
|
||||
img.src='/api/chat/image?path='+encodeURIComponent(f.path);
|
||||
img.alt=f.name||''; img.title=f.name||'';
|
||||
box.appendChild(img);
|
||||
return box;
|
||||
}
|
||||
function addMsgFiles(el, files){
|
||||
if(!el || !files || !files.length) return;
|
||||
const box=document.createElement('div');
|
||||
box.className='msg-files';
|
||||
for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
|
||||
for(const f of files){
|
||||
// Image : la vignette REMPLACE la pastille texte. Deux objets pour un seul
|
||||
// fichier (une vignette + son nom en pastille) doublerait la rangée sans rien
|
||||
// apprendre — le nom vit dans l'infobulle et dans la loupe.
|
||||
if(f.path && isImageName(f.name)) box.appendChild(msgImgTile(f));
|
||||
else box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
|
||||
}
|
||||
el.parentNode.insertBefore(box, el);
|
||||
hydrateImages(box);
|
||||
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
|
||||
markEmptyMsg(el);
|
||||
return box;
|
||||
@@ -202,11 +237,18 @@ function renderAttach(){
|
||||
sizeText: a.state==='err' ? 'échec'
|
||||
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
|
||||
: fmtSize(a.size),
|
||||
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
|
||||
thumb: a.thumb,
|
||||
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); dropThumb(a); renderAttach(); }
|
||||
}));
|
||||
}
|
||||
}
|
||||
function clearAttach(){ ATTACH=[]; renderAttach(); }
|
||||
// L'aperçu du composeur vit sur un objectURL local : il meurt avec sa pastille,
|
||||
// sinon chaque fichier retiré laisse son image en mémoire jusqu'au rechargement.
|
||||
// (La bulle envoyée, elle, repart du serveur — elle ne dépend pas de cet URL.)
|
||||
function dropThumb(a){
|
||||
if(a && a.thumb){ URL.revokeObjectURL(a.thumb); a.thumb=null; }
|
||||
}
|
||||
function clearAttach(){ for(const a of ATTACH) dropThumb(a); ATTACH=[]; renderAttach(); }
|
||||
// Ce qui part avec le message, pour l'afficher dans sa bulle.
|
||||
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
|
||||
|
||||
@@ -277,7 +319,11 @@ function addFiles(files){
|
||||
for(const f of files||[]){
|
||||
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
|
||||
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
|
||||
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'});
|
||||
// Aperçu tout de suite, depuis le fichier local : rien n'est encore parti
|
||||
// vers le serveur (le dépôt n'a lieu qu'à l'envoi), donc c'est la seule
|
||||
// source d'image disponible à ce stade.
|
||||
const thumb = isImageName(f.name) ? URL.createObjectURL(f) : null;
|
||||
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued', thumb:thumb});
|
||||
}
|
||||
renderAttach();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// ===== Loupe d'image =======================================================
|
||||
// Les images du fil sont PLAFONNÉES (CSS .chatimg / .msg-img) : sans plafond,
|
||||
// une capture sortait à sa taille native et mangeait tout l'écran, ce qui rendait
|
||||
// la conversation illisible. Le plafond ne se paie donc pas en information : un
|
||||
// clic rouvre l'image ici, aussi grande que la fenêtre le permet.
|
||||
//
|
||||
// On réutilise la source DÉJÀ chargée par la vignette (blob: hydraté par
|
||||
// hydrateImages, ou objectURL local d'une pièce jointe en attente) : rien n'est
|
||||
// re-téléchargé, et ça marche derrière la clé de pilotage — qu'une balise <img>
|
||||
// ne sait pas porter, d'où le blob: en premier lieu.
|
||||
function lightboxEl(){
|
||||
let el=document.getElementById('lightbox');
|
||||
if(el) return el;
|
||||
el=document.createElement('div');
|
||||
el.id='lightbox';
|
||||
el.innerHTML='<button type="button" class="lb-x" title="fermer" aria-label="fermer">×</button>'
|
||||
+'<img alt=""><div class="lb-name"></div>';
|
||||
// Clic sur le FOND (ou sur le ×) = fermer ; clic sur l'image = ne rien faire,
|
||||
// sinon on referme dès qu'on veut la regarder de près.
|
||||
el.onclick=e=>{ if(e.target===el || e.target.classList.contains('lb-x')) closeLightbox(); };
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
// Capture (3e argument à true) : sans elle, Échap serait d'abord vu par les
|
||||
// écouteurs des modales déjà en place et la loupe ne se fermerait pas.
|
||||
function _lbKey(e){
|
||||
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); closeLightbox(); }
|
||||
}
|
||||
function openLightbox(src, name){
|
||||
if(!src) return;
|
||||
const el=lightboxEl(), img=el.querySelector('img');
|
||||
img.src=src;
|
||||
img.alt=name||'';
|
||||
el.querySelector('.lb-name').textContent=name||'';
|
||||
showModal(el);
|
||||
document.addEventListener('keydown', _lbKey, true);
|
||||
}
|
||||
function closeLightbox(){
|
||||
const el=document.getElementById('lightbox'); if(!el) return;
|
||||
document.removeEventListener('keydown', _lbKey, true);
|
||||
hideModal(el);
|
||||
// La source n'est lâchée qu'APRÈS la transition : la vider tout de suite
|
||||
// laissait un cadre vide clignoter pendant la fermeture.
|
||||
setTimeout(()=>{
|
||||
if(!el.classList.contains('show')) el.querySelector('img').removeAttribute('src');
|
||||
}, 200);
|
||||
}
|
||||
// Rend une image cliquable (idempotent : renderBody reconstruit le DOM à chaque
|
||||
// delta du streaming, et hydrateImages nous rappelle sur les mêmes éléments).
|
||||
//
|
||||
// On lit currentSrc/src AU CLIC et non à la liaison : l'hydratation remplace la
|
||||
// source par un blob: après coup, et figer l'URL /api/chat/image ici rouvrirait
|
||||
// une image que le navigateur n'a pas le droit de charger seul.
|
||||
function bindZoom(img, name){
|
||||
if(!img || img.dataset.zoom) return;
|
||||
img.dataset.zoom='1';
|
||||
img.addEventListener('click', ()=>openLightbox(img.currentSrc||img.src, name||img.alt||''));
|
||||
}
|
||||
@@ -1201,9 +1201,48 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
alignée à droite comme elle. Rangée et bulle restent deux objets distincts,
|
||||
séparés par l'espacement normal du fil — les rapprocher les faisait lire comme
|
||||
une seule bulle difforme qui déborde vers le haut. */
|
||||
/* align-items:flex-start : depuis que la rangée peut contenir une VIGNETTE, une
|
||||
pastille texte voisine s'étirait à sa hauteur (étirement par défaut du flex) —
|
||||
et, arrondie à 999px, se retrouvait en gros ovale. Chaque objet garde sa
|
||||
hauteur propre. */
|
||||
#chat .msg-files{align-self:flex-end;display:flex;flex-wrap:wrap;justify-content:flex-end;
|
||||
gap:6px;max-width:75%}
|
||||
align-items:flex-start;gap:6px;max-width:75%}
|
||||
#chat .msg-files .chip-file{background:var(--bubble);max-width:100%}
|
||||
/* Vignette dans une pastille (composeur) : assez grande pour reconnaître la
|
||||
capture qu'on vient de coller, assez petite pour que la rangée reste une
|
||||
rangée de pastilles et pas une planche-contact. */
|
||||
.chip-file .cf-thumb{flex:none;width:26px;height:26px;object-fit:cover;border-radius:3px;
|
||||
background:var(--panel)}
|
||||
.chip-file.has-thumb{padding-left:4px}
|
||||
/* Pièce jointe IMAGE d'un message envoyé : la vignette remplace la pastille.
|
||||
Plafonnée pour ne pas déséquilibrer le fil ; cliquable pour la voir en grand. */
|
||||
#chat .msg-files .msg-img{display:flex;max-width:100%;border-radius:4px;overflow:hidden;
|
||||
border:1px solid var(--border);background:var(--panel)}
|
||||
/* margin/border remis à zéro : .chatimg (posée par hydrateImages) s'applique
|
||||
aussi ici, or le cadre est déjà porté par la tuile. */
|
||||
#chat .msg-files .msg-img img{display:block;max-width:200px;max-height:160px;
|
||||
width:auto;height:auto;object-fit:contain;cursor:zoom-in;margin:0;border:none;border-radius:0}
|
||||
/* Images posées par l'IA dans sa réponse (captures, /api/chat/image). Sans ce
|
||||
plafond elles sortaient à leur taille NATIVE : une capture d'écran remplissait
|
||||
la colonne entière et poussait le texte hors de vue. */
|
||||
#chat .chatimg{display:block;max-width:100%;max-height:280px;width:auto;height:auto;
|
||||
margin:6px 0;border:1px solid var(--border);border-radius:4px;cursor:zoom-in}
|
||||
/* Loupe : l'image reprend toute la fenêtre, moins une marge. Le fond ferme au
|
||||
clic (curseur zoom-out), l'image non — on veut pouvoir la regarder de près. */
|
||||
#lightbox{position:fixed;inset:0;z-index:80;display:none;align-items:center;
|
||||
justify-content:center;padding:28px;background:rgba(0,0,0,.72);cursor:zoom-out;
|
||||
opacity:0;transition:opacity .16s ease}
|
||||
#lightbox.show{opacity:1}
|
||||
#lightbox img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;
|
||||
border-radius:4px;box-shadow:0 16px 50px rgba(0,0,0,.45);cursor:default}
|
||||
#lightbox .lb-x{position:absolute;top:10px;right:14px;margin:0;padding:0;
|
||||
width:36px;height:36px;display:flex;align-items:center;justify-content:center;
|
||||
font-size:22px;line-height:1;border:none;border-radius:50%;
|
||||
background:rgba(0,0,0,.35);color:#fff}
|
||||
#lightbox .lb-x:hover{background:rgba(0,0,0,.6)}
|
||||
#lightbox .lb-name{position:absolute;left:0;right:0;bottom:10px;text-align:center;
|
||||
color:#fff;opacity:.72;font-size:12px;pointer-events:none}
|
||||
@media (prefers-reduced-motion:reduce){#lightbox{transition:none}}
|
||||
/* Lien de téléchargement dans une réponse : un lien normal, mais la flèche dit
|
||||
qu'il ramène un fichier plutôt qu'il n'ouvre une page. */
|
||||
#chat .msg .filelink{cursor:pointer}
|
||||
|
||||
Reference in new issue
Block a user