修复手机键盘弹出时页面位移与焦点跳转
This commit is contained in:
@@ -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