修复手机键盘弹出时页面位移与焦点跳转

This commit is contained in:
2026-10-30 12:53:32 +08:00
parent 8853ea2dfa
commit a415bcdb88
6 changed files with 171 additions and 32 deletions
+95 -7
View File
@@ -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});
+11 -2
View File
@@ -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);