修复手机键盘弹出时页面位移与焦点跳转
This commit is contained in:
+16
-9
@@ -1,5 +1,5 @@
|
||||
/* Customer workspace: navigation, welcome, conversation and composer. */
|
||||
body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.chat-container {
|
||||
display: flex; width: min(1440px, 100%); height: 100%; margin: auto;
|
||||
overflow: hidden; position: relative; border: 1px solid var(--border);
|
||||
@@ -44,7 +44,7 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.clear-all-btn:hover { color: var(--danger); }
|
||||
.sidebar-note { display: flex; align-items: center; gap: 7px; margin-top: 10px; color: var(--muted); font-size: 10px; }
|
||||
.left-panel-hide { width: 0 !important; padding-left: 0 !important; padding-right: 0 !important; visibility: hidden; border: 0; }
|
||||
.main-chat { flex: 1; min-width: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.main-chat { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; position: relative; overflow: hidden; }
|
||||
.chat-title { flex-shrink: 0; min-height: 75px; padding: 16px 26px; display: flex; align-items: center; gap: 13px; border-bottom: 1px solid var(--border); background: var(--surface); z-index: 31; }
|
||||
.header-copy { min-width: 0; flex: 1; }
|
||||
.header-kicker { font-size: 10px; color: var(--muted); letter-spacing: .06em; margin-bottom: 3px; }
|
||||
@@ -205,17 +205,24 @@ body { padding: 20px; height: 100dvh; overflow: hidden; }
|
||||
.suggest-copy small { font-size: 11px; }
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
body { padding: 0; }
|
||||
.chat-container { border: 0; border-radius: 0; height: 100dvh; }
|
||||
.left-panel { position: absolute; left: 0; top: 72px; bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
|
||||
body { padding: 0; position: fixed; inset: 0; width: 100%; overscroll-behavior: none; }
|
||||
.chat-container { --chat-header-height: 72px; position: fixed; left: 0; right: 0; top: 0; margin: 0; width: 100%; border: 0; border-radius: 0; height: 100vh; height: 100dvh; padding: env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left); }
|
||||
.left-panel { position: absolute; left: 0; top: calc(var(--chat-header-height) + env(safe-area-inset-top)); bottom: 0; width: 270px; max-width: 85vw; z-index: 30; box-shadow: 15px 0 40px rgba(20, 35, 60, .12); }
|
||||
.left-panel .brand { display: none; }
|
||||
.side-closed .left-panel { transform: translateX(-105%); visibility: hidden; pointer-events: none; }
|
||||
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: 72px 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
|
||||
.chat-container:not(.side-closed) .sidebar-backdrop { display: block; position: absolute; inset: calc(var(--chat-header-height) + env(safe-area-inset-top)) 0 0; border: 0; z-index: 20; background: rgba(16, 30, 52, .25); }
|
||||
.main-chat { width: 100%; }
|
||||
.chat-title { min-height: 72px; height: 72px; padding: 13px 18px; gap: 11px; }
|
||||
#chatDom { padding: 24px 20px 15px; }
|
||||
.chat-title { min-height: var(--chat-header-height); height: var(--chat-header-height); padding: 13px 18px; gap: 11px; }
|
||||
#chatDom { padding: 24px 20px 15px; overscroll-behavior-y: contain; }
|
||||
.ai-welcome-card { padding-top: 20px; }
|
||||
.composer { width: calc(100% - 40px); }
|
||||
.composer { width: calc(100% - 40px); padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
|
||||
.chat-container.is-composing { --chat-header-height: 56px; }
|
||||
.is-composing .chat-title { padding-top: 8px; padding-bottom: 8px; }
|
||||
.is-composing .composer { padding-top: 6px; padding-bottom: 6px; }
|
||||
.is-composing .composer-footer { display: none; }
|
||||
#input-text, .history-search { font-size: max(16px, 1em); }
|
||||
.history-search { height: 44px; }
|
||||
.history-search-wrap svg { top: 14px; }
|
||||
.user-message, .robot-message { max-width: 94%; }
|
||||
.del-history-btn { opacity: 1; }
|
||||
.voice-toggle { display: grid; place-items: center; flex: 0 0 40px; height: 36px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--brand-text); font-size: 20px; }
|
||||
|
||||
+46
-11
@@ -6,6 +6,11 @@ const sendBtn = document.getElementById("send-button");
|
||||
const uploadBtn = document.getElementById("upload-btn");
|
||||
const uploadInput = document.getElementById("upload-input");
|
||||
const composerStatus = document.getElementById("composer-status");
|
||||
function focusComposer(force){
|
||||
if(force || !isNarrow() || document.activeElement === inputDom){
|
||||
inputDom.focus({preventScroll:true});
|
||||
}
|
||||
}
|
||||
let uploadPending = false;
|
||||
let uploadVersion = 0;
|
||||
let uploadController = null;
|
||||
@@ -121,12 +126,12 @@ uploadInput.onchange = function(){
|
||||
remove.className = "attachment-remove";
|
||||
remove.textContent = "×";
|
||||
remove.setAttribute("aria-label", t("removeAttachment"));
|
||||
remove.onclick = function(){ clearAttachment(); inputDom.focus(); };
|
||||
remove.onclick = function(){ clearAttachment(); focusComposer(true); };
|
||||
prev.append(copy, remove);
|
||||
var area = document.querySelector(".input-area");
|
||||
area.before(prev);
|
||||
setComposerStatus("");
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}
|
||||
})
|
||||
.catch(function(error){
|
||||
@@ -1140,7 +1145,7 @@ async function returnToAI(){
|
||||
stopHumanMode();
|
||||
setComposerStatus('');
|
||||
addMsg(t('humanClosed'), false);
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}catch(error){
|
||||
if(version === conversationVersion) setComposerStatus(t('humanReturnFailed'), true);
|
||||
}finally{
|
||||
@@ -1166,7 +1171,7 @@ async function sendHumanMessage(content){
|
||||
if(inputDom.value === draft){ inputDom.value = ''; autoGrow(); }
|
||||
if(window.__pendingImg === attachment) clearAttachment();
|
||||
setComposerStatus(t(humanStatus === 'active' ? 'humanSent' : 'humanWaiting'));
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
return true;
|
||||
}catch(error){
|
||||
if(version !== conversationVersion || humanSendingToken !== token) return;
|
||||
@@ -1471,7 +1476,7 @@ async function doSend(retryText){
|
||||
var tag = window.__pendingTag || "[图片]";
|
||||
const draft = inputDom.value, attachmentVersion = uploadVersion;
|
||||
if(!sendWord && !imgUrl){
|
||||
inputDom.focus();
|
||||
focusComposer(true);
|
||||
return;
|
||||
}
|
||||
if(uploadPending){ setComposerStatus(t("uploading")); return; }
|
||||
@@ -1504,7 +1509,7 @@ async function doSend(retryText){
|
||||
addMsg(fullContent, true);
|
||||
if(inputDom.value === draft){ inputDom.value = ""; autoGrow(); }
|
||||
if(attachmentVersion === uploadVersion) clearAttachment();
|
||||
inputDom.focus();
|
||||
focusComposer();
|
||||
}
|
||||
|
||||
const aiBubble = addMsg(t("thinking"), false);
|
||||
@@ -1537,7 +1542,6 @@ async function doSend(retryText){
|
||||
setBusy(false);
|
||||
aiBubble.classList.remove("typing");
|
||||
if(activeBubble === aiBubble) activeBubble = null;
|
||||
inputDom.focus();
|
||||
|
||||
let raw = aiBubble.dataset.raw || "";
|
||||
// 模型有时会把卡片标记原样输出,这里兜底转成时间轴
|
||||
@@ -1719,7 +1723,10 @@ function autoGrow(){
|
||||
return;
|
||||
}
|
||||
inputDom.style.height = "auto";
|
||||
inputDom.style.height = Math.min(inputDom.scrollHeight, 140) + "px";
|
||||
const viewport = window.visualViewport;
|
||||
const height = viewport && viewport.scale === 1 ? viewport.height : window.innerHeight;
|
||||
const limit = isNarrow() && height > 0 ? Math.min(140, Math.max(44, Math.floor(height / 4))) : 140;
|
||||
inputDom.style.height = Math.min(inputDom.scrollHeight, limit) + "px";
|
||||
}
|
||||
inputDom.addEventListener("input", autoGrow);
|
||||
|
||||
@@ -1777,12 +1784,30 @@ renderSidebar();
|
||||
|
||||
function syncChatViewport(){
|
||||
const viewport = window.visualViewport;
|
||||
chatContainer.style.height = viewport && isNarrow() && viewport.scale === 1
|
||||
? Math.round(viewport.height) + 'px' : '';
|
||||
const narrow = isNarrow();
|
||||
const unzoomed = !viewport || Math.abs(viewport.scale - 1) < 0.01;
|
||||
if(narrow && unzoomed){
|
||||
const height = viewport ? viewport.height : window.innerHeight;
|
||||
if(Number.isFinite(height) && height > 0){
|
||||
chatContainer.style.height = Math.round(height) + 'px';
|
||||
// Keyboard focus may pan the visual viewport without changing its height.
|
||||
chatContainer.style.top = Math.max(0, Math.round(viewport && viewport.offsetTop || 0)) + 'px';
|
||||
}
|
||||
}else{
|
||||
chatContainer.style.height = '';
|
||||
chatContainer.style.top = '';
|
||||
}
|
||||
chatContainer.classList.toggle('is-composing', narrow && document.activeElement === inputDom);
|
||||
autoGrow();
|
||||
if(!chatDom.querySelector('.ai-welcome-card')) scrollChatToEnd();
|
||||
updateToBottom();
|
||||
}
|
||||
let viewportSyncQueued = false;
|
||||
function scheduleChatViewport(){
|
||||
if(viewportSyncQueued) return;
|
||||
viewportSyncQueued = true;
|
||||
requestAnimationFrame(function(){ viewportSyncQueued = false; syncChatViewport(); });
|
||||
}
|
||||
|
||||
toggleBtn.onclick = function(){
|
||||
sideOpen = !sideOpen;
|
||||
@@ -1810,7 +1835,17 @@ window.addEventListener("resize", function(){
|
||||
renderSidebar();
|
||||
syncChatViewport();
|
||||
});
|
||||
if(window.visualViewport) window.visualViewport.addEventListener('resize', syncChatViewport);
|
||||
if(window.visualViewport){
|
||||
window.visualViewport.addEventListener('resize', scheduleChatViewport);
|
||||
window.visualViewport.addEventListener('scroll', scheduleChatViewport);
|
||||
}
|
||||
window.addEventListener('pageshow', scheduleChatViewport);
|
||||
inputDom.addEventListener('focus', scheduleChatViewport);
|
||||
inputDom.addEventListener('blur', scheduleChatViewport);
|
||||
// Keep an already open keyboard while tapping Send; keyboard navigation is unchanged.
|
||||
sendBtn.addEventListener('pointerdown', function(event){
|
||||
if(event.button === 0 && document.activeElement === inputDom) event.preventDefault();
|
||||
});
|
||||
syncChatViewport();
|
||||
|
||||
voiceInput = ChatVoice.create({
|
||||
|
||||
+1
-1
@@ -168,7 +168,7 @@ var ChatVoice = {
|
||||
toggle.onclick = function(){
|
||||
if(!mode && !supported()) return;
|
||||
setMode(!mode);
|
||||
if(!mode) input.focus();
|
||||
if(!mode) input.focus({preventScroll:true});
|
||||
};
|
||||
if(window.PointerEvent){
|
||||
hold.addEventListener('pointerdown', function(event){
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
|
||||
<meta name="theme-color" content="#315fd5">
|
||||
<title>柏悦智能物流客服系统</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
|
||||
@@ -61,7 +61,7 @@
|
||||
<button type="button" id="upload-btn" title="发送附件" aria-label="发送附件">+</button>
|
||||
<div class="emoji-control"><button id="emoji-toggle" class="icon-button" type="button" aria-label="选择表情" aria-expanded="false" aria-controls="emoji-panel">😊</button><div id="emoji-panel" class="emoji-picker" hidden></div></div>
|
||||
<input type="file" id="upload-input" accept="image/*,.pdf,.txt,.csv" hidden>
|
||||
<textarea id="input-text" rows="1" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
|
||||
<textarea id="input-text" rows="1" enterkeyhint="send" aria-label="输入问题或运单号" placeholder="输入您的问题或运单号…"></textarea>
|
||||
<button id="voice-hold" class="voice-hold" type="button">按住说话</button>
|
||||
<button type="button" id="send-button">发送</button>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ async function customerPage(pageOptions = {}){
|
||||
constructor(){
|
||||
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
|
||||
this.className = ''; this.dataset = {}; this.scrollHeight = 300; this.clientHeight = 300; this.scrollTop = 0;
|
||||
this.listeners = {};
|
||||
this.listeners = {}; this.focusCalls = [];
|
||||
this.classList = {
|
||||
contains: name => this.className.split(' ').includes(name),
|
||||
add: name => { this.className += ' '+name; },
|
||||
@@ -32,7 +32,8 @@ async function customerPage(pageOptions = {}){
|
||||
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
|
||||
appendChild(child){ child.parent = this; this.children.push(child); }
|
||||
append(...children){ children.forEach(child => this.appendChild(child)); }
|
||||
focus(){}
|
||||
focus(options){ this.focusCalls.push(options); document.activeElement=this; if(this.listeners.focus) this.listeners.focus(); }
|
||||
blur(){ if(document.activeElement === this){ document.activeElement=null; if(this.listeners.blur) this.listeners.blur(); } }
|
||||
querySelector(selector){
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
@@ -67,10 +68,10 @@ async function customerPage(pageOptions = {}){
|
||||
FormData:class { append(){} },
|
||||
requestAnimationFrame:callback => callback(),
|
||||
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
|
||||
window:{innerWidth:pageOptions.width || 1200,
|
||||
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
|
||||
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
|
||||
matchMedia:query => ({matches:pageOptions.width ? pageOptions.width <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1]) : false}),
|
||||
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,
|
||||
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
|
||||
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,offsetTop:0,
|
||||
addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined,
|
||||
prompt:() => pageOptions.offlineMessage || null},
|
||||
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
|
||||
@@ -112,15 +113,22 @@ async function customerPage(pageOptions = {}){
|
||||
await flush();
|
||||
return {context, element, calls, hooks, quickButtons, voiceOptions,
|
||||
resize:async height => {
|
||||
context.window.innerHeight=height;
|
||||
if(context.window.visualViewport) context.window.visualViewport.height=height;
|
||||
(windowListeners.resize || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
resizeViewport:async (height,scale=1) => {
|
||||
Object.assign(context.window.visualViewport,{height,scale});
|
||||
resizeViewport:async (height,scale=1,offsetTop=0) => {
|
||||
Object.assign(context.window.visualViewport,{height,scale,offsetTop});
|
||||
(viewportListeners.resize || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
panViewport:async top => {
|
||||
context.window.visualViewport.offsetTop=top;
|
||||
(viewportListeners.scroll || []).forEach(callback => callback());
|
||||
await flush();
|
||||
},
|
||||
pageShow:async () => { (windowListeners.pageshow || []).forEach(callback => callback()); await flush(); },
|
||||
setState:value => { state=value; },
|
||||
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
|
||||
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
|
||||
@@ -635,6 +643,86 @@ test('clearing a sent draft restores a single-row composer instead of sizing to
|
||||
assert.equal(input.style.height,'');
|
||||
});
|
||||
|
||||
test('keyboard panning keeps the whole mobile frame in the visual viewport and restores after closing', async () => {
|
||||
const app=await customerPage({human:false,width:390,viewportHeight:844});
|
||||
const input=app.element('input-text'), frame=app.element('.chat-container');
|
||||
input.focus();
|
||||
await app.resizeViewport(430,1,180);
|
||||
assert.equal(frame.style.height,'430px');
|
||||
assert.equal(frame.style.top,'180px');
|
||||
assert.ok(frame.classList.contains('is-composing'));
|
||||
await app.panViewport(260);
|
||||
assert.equal(frame.style.top,'260px');
|
||||
assert.equal(frame.style.height,'430px');
|
||||
await app.resizeViewport(0,1,0);
|
||||
assert.equal(frame.style.height,'430px');
|
||||
input.blur();
|
||||
await app.resizeViewport(844);
|
||||
assert.equal(frame.style.top,'0px');
|
||||
assert.equal(frame.style.height,'844px');
|
||||
assert.equal(frame.classList.contains('is-composing'),false);
|
||||
});
|
||||
|
||||
test('embedded browsers without visualViewport use the resized window and recover on pageshow', async () => {
|
||||
const app=await customerPage({human:false,width:390,height:844});
|
||||
const frame=app.element('.chat-container');
|
||||
assert.equal(frame.style.height,'844px');
|
||||
await app.resize(410);
|
||||
assert.equal(frame.style.height,'410px');
|
||||
app.context.window.innerHeight=844;
|
||||
await app.pageShow();
|
||||
assert.equal(frame.style.height,'844px');
|
||||
app.context.window.innerWidth=1200;
|
||||
await app.resize(720);
|
||||
assert.equal(frame.style.height,'');
|
||||
assert.equal(frame.style.top,'');
|
||||
});
|
||||
|
||||
test('a multiline draft leaves room for messages when the keyboard reduces the viewport', async () => {
|
||||
const app=await customerPage({human:false,width:390,viewportHeight:844});
|
||||
const input=app.element('input-text');
|
||||
input.value='多行草稿';
|
||||
input.scrollHeight=300;
|
||||
input.listeners.input();
|
||||
assert.equal(input.style.height,'140px');
|
||||
await app.resizeViewport(340);
|
||||
assert.equal(input.style.height,'85px');
|
||||
await app.resizeViewport(844);
|
||||
assert.equal(input.style.height,'140px');
|
||||
});
|
||||
|
||||
test('late AI completion does not steal focus or reopen a dismissed mobile keyboard', async () => {
|
||||
for(const width of [390,1200]){
|
||||
const app=await customerPage({human:false,width,viewportHeight:844});
|
||||
const input=app.element('input-text');
|
||||
let reply;
|
||||
app.hooks['/stream_chat']=()=>new Promise(resolve=>{reply=resolve;});
|
||||
input.focus();
|
||||
await app.context.window.fillInput('报价问题');
|
||||
await flush();
|
||||
input.blur();
|
||||
app.element('history-search').focus();
|
||||
const before=input.focusCalls.length;
|
||||
reply(streamResponse(['data: {"t":"完成回复"}\n\ndata: {"d":1}\n\n']));
|
||||
await flush();await flush();
|
||||
assert.equal(input.focusCalls.length,before);
|
||||
assert.equal(app.context.document.activeElement,app.element('history-search'));
|
||||
assert.match(app.element('chatDom').textContent,/完成回复/);
|
||||
}
|
||||
});
|
||||
|
||||
test('mobile quick questions do not summon the keyboard and tapping Send retains an active draft focus', async () => {
|
||||
const app=await customerPage({human:false,width:390});
|
||||
const input=app.element('input-text');
|
||||
await app.context.window.fillInput('快捷咨询');await flush();
|
||||
assert.equal(input.focusCalls.length,0);
|
||||
input.focus();
|
||||
let prevented=false;
|
||||
app.element('send-button').listeners.pointerdown({button:0,preventDefault(){prevented=true;}});
|
||||
assert.ok(prevented);
|
||||
assert.equal(app.context.document.activeElement,input);
|
||||
});
|
||||
|
||||
test('recognized voice uses the normal AI and human send routes with a visible user message', async () => {
|
||||
for(const human of [false,true]){
|
||||
const app=await customerPage({human,width:390});
|
||||
|
||||
@@ -14,8 +14,8 @@ function voicePage(options={}){
|
||||
setAttribute(name,value){this.attributes[name]=value;}
|
||||
addEventListener(name,callback){this.listeners[name]=callback;}
|
||||
setPointerCapture(){}
|
||||
focus(){}
|
||||
blur(){}
|
||||
focus(options){this.focusOptions=options;}
|
||||
blur(){this.blurred=true;}
|
||||
}
|
||||
for(const key of ['toggle','hold','panel','label','clock','inputArea','input']) elements[key]=new Element();
|
||||
const tracks=[{stop(){trackStops++;}}], stream={getTracks:()=>tracks};
|
||||
@@ -70,6 +70,15 @@ test('hold and release transcribes audio and sends text once',async()=>{
|
||||
assert.equal(app.messages.length,1);
|
||||
});
|
||||
|
||||
test('switching between voice and keyboard avoids scrolling the page on focus',()=>{
|
||||
const app=voicePage();
|
||||
app.open();
|
||||
assert.ok(app.elements.input.blurred);
|
||||
app.open();
|
||||
assert.equal(app.elements.input.focusOptions.preventScroll,true);
|
||||
assert.equal(app.elements.toggle.attributes['aria-pressed'],'false');
|
||||
});
|
||||
|
||||
test('sliding up cancels without uploading and supports another recording',async()=>{
|
||||
const app=voicePage();app.open();await app.down();app.move(200);await app.up();
|
||||
assert.equal(app.messages.length,0);
|
||||
|
||||
Reference in New Issue
Block a user