修复移动端软键盘页面弹跳黑底
移除 H5 软键盘打开时平台壳全局 transform 位移,避免浏览器原生避让后再次弹跳。 保留键盘打开状态、底部 dock 隐藏和浅色根背景兜底,避免短表单露出黑色宿主底色。 补充小程序 web-view 原生 page 浅色背景和对应样式测试。 更新统一创作页与平台键盘适配文档,沉淀不再全局上移平台壳的约束。
This commit is contained in:
@@ -649,9 +649,9 @@
|
|||||||
## 2026-05-14 移动端输入法弹出时平台画布不压缩
|
## 2026-05-14 移动端输入法弹出时平台画布不压缩
|
||||||
|
|
||||||
- 背景:平台根壳使用 `100dvh` 后,手机浏览器输入法弹出会让可见视口变小,导致创作首页、推荐页等固定游戏式画布被重新压缩。
|
- 背景:平台根壳使用 `100dvh` 后,手机浏览器输入法弹出会让可见视口变小,导致创作首页、推荐页等固定游戏式画布被重新压缩。
|
||||||
- 决策:主站入口统一注册移动端输入法聚焦适配;输入法未打开时记录稳定布局高度,输入法打开期间 `.platform-viewport-shell` 不跟随 `visualViewport.height` 缩小,只通过 `--platform-keyboard-focus-offset` 上移画面聚焦当前输入框,并临时隐藏移动端底部 dock。
|
- 决策:主站入口统一注册移动端输入法聚焦适配;输入法未打开时记录稳定布局高度,输入法打开期间 `.platform-viewport-shell` 不跟随 `visualViewport.height` 缩小,但不再通过 `--platform-keyboard-focus-offset` 全局上移画布,避免 H5 / 小程序 `web-view` 原生输入法避让和平台壳二次位移叠加。键盘打开时只记录 `data-mobile-keyboard-open`、设置底部 inset、隐藏移动端底部 dock,并把可能露出的 `html` / `body` / `#root` 背景切到平台浅色底。
|
||||||
- 影响范围:主站平台壳、移动端创作首页底部输入框、后续所有复用 `.platform-viewport-shell` 的输入表单;业务组件不重复注册键盘适配。
|
- 影响范围:主站平台壳、移动端创作首页底部输入框、后续所有复用 `.platform-viewport-shell` 的输入表单;业务组件不重复注册键盘适配。
|
||||||
- 验证方式:手机竖屏点击输入框,画布不压缩,输入框移动到输入法上方;输入法关闭后画布回位,底部 dock 恢复。
|
- 验证方式:手机竖屏点击输入框,画布不压缩也不整体弹起;输入法关闭后键盘状态清除,底部 dock 恢复。
|
||||||
- 关联文档:`docs/technical/【前端体验】移动端输入法不压缩画布聚焦方案-2026-05-14.md`、`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。
|
- 关联文档:`docs/technical/【前端体验】移动端输入法不压缩画布聚焦方案-2026-05-14.md`、`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。
|
||||||
|
|
||||||
## 2026-05-14 抓大鹅物品素材批量重新生成复用 item-assets 替换模式
|
## 2026-05-14 抓大鹅物品素材批量重新生成复用 item-assets 替换模式
|
||||||
|
|||||||
@@ -2085,8 +2085,8 @@
|
|||||||
|
|
||||||
## 统一创作页短表单软键盘打开不要露出黑底
|
## 统一创作页短表单软键盘打开不要露出黑底
|
||||||
|
|
||||||
- 现象:小程序 / 移动端点击拼图或敲木鱼创作输入框后,输入框和键盘之间出现一大片黑色区域;跳一跳因为按钮区用 `mt-auto` 撑开页面,看起来没有同样问题。
|
- 现象:小程序 / H5 移动端点击拼图或敲木鱼创作输入框后,输入框和键盘之间出现一大片黑色区域;H5 还会明显弹一下。跳一跳因为按钮区用 `mt-auto` 撑开页面,看起来没有同样问题。
|
||||||
- 原因:移动键盘处理会用 `--platform-keyboard-focus-offset` 把 `.platform-viewport-shell` 整体上移;`UnifiedCreationPage` 内容区如果用 `min-h-max` 按短内容收缩,且统一页自身没有平台背景,键盘压缩或位移动画期间会露出 `body` 的黑色宿主底色。
|
- 原因:旧移动键盘处理会用 `--platform-keyboard-focus-offset` 把 `.platform-viewport-shell` 整体上移;但 H5 浏览器和小程序 `web-view` 已会自行处理输入框可见性,二次整体上移会造成页面弹跳并露出 `body` 或原生 `page` 的黑色宿主底色。统一创作短表单若内容区按短内容收缩,也会放大这个黑底暴露。
|
||||||
- 处理:`UnifiedCreationPage` 根容器必须保留 `bg-[image:var(--platform-body-fill)]` 和 `overscroll-contain`,内容区必须用 `flex-1 min-h-0` 占满统一页剩余高度;不要只给某个玩法工作台单独加高度补丁。
|
- 处理:`UnifiedCreationPage` 根容器必须保留 `bg-[image:var(--platform-body-fill)]` 和 `overscroll-contain`,内容区必须用 `flex-1 min-h-0` 占满统一页剩余高度;移动端键盘打开时只记录 `data-mobile-keyboard-open`、隐藏底部 dock、设置键盘 inset 和浅色 `--platform-keyboard-exposed-fill`,不要再对 `.platform-viewport-shell` 做全局 `transform`;小程序 `pages/web-view` 的 `page` 和 web-view class 也要用浅色背景。不要只给某个玩法工作台单独加高度补丁。
|
||||||
- 验证:`npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedCreationWorkspace.test.tsx`;移动端点击拼图、敲木鱼、跳一跳输入框时,键盘上方应持续显示平台浅色背景。
|
- 验证:`npm run test -- src/components/unified-creation/UnifiedCreationPage.test.tsx src/components/unified-creation/UnifiedCreationWorkspace.test.tsx src/mobileViewportKeyboardFocus.test.ts src/index.test.ts miniprogram/pages/web-view/index.style.test.js`;移动端点击拼图、敲木鱼、跳一跳输入框时,页面不应整体弹起,键盘上方应持续显示平台浅色背景。
|
||||||
- 关联:`src/components/unified-creation/UnifiedCreationPage.tsx`、`src/mobileViewportKeyboardFocus.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联:`src/components/unified-creation/UnifiedCreationPage.tsx`、`src/mobileViewportKeyboardFocus.ts`、`src/index.css`、`miniprogram/pages/web-view/index.wxml`、`miniprogram/pages/web-view/index.wxss`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。
|
创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。
|
||||||
|
|
||||||
统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单和表头由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。统一创作页表头按 `unifiedCreationSpec.title` 契约内容原样显示,读取和保存时不再用入口名称自动覆盖;需要改表头时应直接修改后台契约 JSON 的 `title` 字段。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。统一创作页根容器必须保留平台浅色背景并让内容区占满剩余高度,移动端软键盘打开或视口被小程序宿主压缩时,短表单也不得露出浏览器 / 宿主黑底。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
|
统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单和表头由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。统一创作页表头按 `unifiedCreationSpec.title` 契约内容原样显示,读取和保存时不再用入口名称自动覆盖;需要改表头时应直接修改后台契约 JSON 的 `title` 字段。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。统一创作页根容器必须保留平台浅色背景并让内容区占满剩余高度,移动端软键盘打开或视口被小程序宿主压缩时,短表单也不得露出浏览器 / 宿主黑底;H5 根节点在 `data-mobile-keyboard-open=true` 时必须把 `html` / `body` / `#root` 背景切到当前平台浅色底,但不得再用 `.platform-viewport-shell` 全局 `transform` 二次上推页面;小程序 `web-view` 页面原生宿主也必须使用浅色背景,不能沿用全局黑色 page 背景。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
|
||||||
|
|
||||||
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
|
创作表单提交前的泥点余额前置校验只允许用独立弹窗提示失败原因,不得把用户退回创作入口或玩法模板列表,也不得清空当前表单状态。当前适用拼图、抓大鹅和汪汪声浪等会在前端提交前校验泥点的生成入口;余额不足、余额读取失败都应停留在当前工作台,由用户关闭提示后继续编辑或自行补足泥点。
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ server-rs + Axum + SpacetimeDB
|
|||||||
3. 点击按钮弹出独立面板时,必须弹出 dialog / drawer / modal,不要在当前面板下方展开内容。
|
3. 点击按钮弹出独立面板时,必须弹出 dialog / drawer / modal,不要在当前面板下方展开内容。
|
||||||
4. 优先复用现有系统、页面、组件和弹层,不因一次需求新建平行系统。
|
4. 优先复用现有系统、页面、组件和弹层,不因一次需求新建平行系统。
|
||||||
5. 游戏式页面要防止文字、按钮、HUD、底部 dock、输入法和画布互相遮挡。
|
5. 游戏式页面要防止文字、按钮、HUD、底部 dock、输入法和画布互相遮挡。
|
||||||
6. 平台根壳已处理移动端输入法聚焦:输入法弹出时保持画布稳定高度,用偏移聚焦输入框,业务组件不要重复注册全局键盘适配。
|
6. 平台根壳已处理移动端输入法聚焦:输入法弹出时保持画布稳定高度,只记录键盘状态、隐藏底部 dock 并补齐浅色暴露背景,不再全局上移平台壳;业务组件不要重复注册全局键盘适配。
|
||||||
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
|
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
|
||||||
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
|
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
|
||||||
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
|
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
|
||||||
|
|||||||
32
miniprogram/pages/web-view/index.style.test.js
Normal file
32
miniprogram/pages/web-view/index.style.test.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
const PAGE_DIR = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'miniprogram/pages/web-view',
|
||||||
|
);
|
||||||
|
|
||||||
|
function readPageFile(fileName) {
|
||||||
|
return fs.readFileSync(path.join(PAGE_DIR, fileName), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mini program web-view page background', () => {
|
||||||
|
test('keeps the native web-view host light when the mobile keyboard exposes it', () => {
|
||||||
|
const wxml = readPageFile('index.wxml');
|
||||||
|
const wxss = readPageFile('index.wxss');
|
||||||
|
|
||||||
|
expect(wxml).toContain('class="web-view-host"');
|
||||||
|
expect(wxml).not.toContain('class="web-view-page"');
|
||||||
|
expect(wxss).toContain('page');
|
||||||
|
expect(wxss).toContain('.web-view-host');
|
||||||
|
expect(wxss).toContain('background: #fffdf9;');
|
||||||
|
|
||||||
|
const webViewHostBlock = wxss.slice(
|
||||||
|
wxss.indexOf('.web-view-host'),
|
||||||
|
wxss.indexOf('.setup-screen'),
|
||||||
|
);
|
||||||
|
expect(webViewHostBlock).not.toContain('#0b0f14');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<block wx:if="{{webViewUrl}}">
|
<block wx:if="{{webViewUrl}}">
|
||||||
<web-view
|
<web-view
|
||||||
id="genarrative-web-view"
|
id="genarrative-web-view"
|
||||||
|
class="web-view-host"
|
||||||
src="{{webViewUrl}}"
|
src="{{webViewUrl}}"
|
||||||
bindload="handleWebViewLoad"
|
bindload="handleWebViewLoad"
|
||||||
binderror="handleWebViewError"
|
binderror="handleWebViewError"
|
||||||
|
|||||||
@@ -1,3 +1,14 @@
|
|||||||
|
page {
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.web-view-host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #fffdf9;
|
||||||
|
}
|
||||||
|
|
||||||
.setup-screen {
|
.setup-screen {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -79,17 +79,19 @@ body {
|
|||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-mobile-keyboard-open='true'],
|
||||||
|
html[data-mobile-keyboard-open='true'] body,
|
||||||
|
html[data-mobile-keyboard-open='true'] #root {
|
||||||
|
background: var(
|
||||||
|
--platform-keyboard-exposed-fill,
|
||||||
|
linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
.platform-viewport-shell {
|
.platform-viewport-shell {
|
||||||
height: var(--platform-layout-viewport-height, 100vh);
|
height: var(--platform-layout-viewport-height, 100vh);
|
||||||
max-height: var(--platform-layout-viewport-height, 100vh);
|
max-height: var(--platform-layout-viewport-height, 100vh);
|
||||||
min-height: var(--platform-layout-viewport-height, 100vh);
|
min-height: var(--platform-layout-viewport-height, 100vh);
|
||||||
transform: translate3d(
|
|
||||||
0,
|
|
||||||
calc(-1 * var(--platform-keyboard-focus-offset, 0px)),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
transform-origin: top center;
|
|
||||||
transition: transform 180ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@supports (height: 100dvh) {
|
@supports (height: 100dvh) {
|
||||||
|
|||||||
@@ -59,6 +59,30 @@ describe('index stylesheet unread dots', () => {
|
|||||||
expect(css).toContain('::-webkit-scrollbar-thumb');
|
expect(css).toContain('::-webkit-scrollbar-thumb');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the platform fill for root background exposed by mobile keyboard shift', () => {
|
||||||
|
const css = readIndexCss();
|
||||||
|
|
||||||
|
const keyboardRootBlock = getCssBlock(
|
||||||
|
css,
|
||||||
|
"html[data-mobile-keyboard-open='true'],\nhtml[data-mobile-keyboard-open='true'] body,\nhtml[data-mobile-keyboard-open='true'] #root",
|
||||||
|
);
|
||||||
|
expect(keyboardRootBlock).toContain('--platform-keyboard-exposed-fill');
|
||||||
|
expect(keyboardRootBlock).toContain('#fffdf9');
|
||||||
|
expect(keyboardRootBlock).not.toContain('#0a0a0a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not globally transform the platform shell while the mobile keyboard is open', () => {
|
||||||
|
const css = readIndexCss();
|
||||||
|
|
||||||
|
const platformShellBlock = getCssBlock(
|
||||||
|
css,
|
||||||
|
'.platform-viewport-shell {\n height',
|
||||||
|
);
|
||||||
|
expect(platformShellBlock).toContain('--platform-layout-viewport-height');
|
||||||
|
expect(platformShellBlock).not.toContain('translate3d');
|
||||||
|
expect(platformShellBlock).not.toContain('--platform-keyboard-focus-offset');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses warm brown tokens for draft unread markers instead of red literals', () => {
|
it('uses warm brown tokens for draft unread markers instead of red literals', () => {
|
||||||
const css = readIndexCss();
|
const css = readIndexCss();
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,59 @@
|
|||||||
/* @vitest-environment jsdom */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
calculateMobileKeyboardFocusShift,
|
|
||||||
isEditableKeyboardTarget,
|
isEditableKeyboardTarget,
|
||||||
|
resolveMobileKeyboardExposedFill,
|
||||||
|
resolveMobileKeyboardState,
|
||||||
|
stabilizeMobileViewportKeyboardFocus,
|
||||||
} from './mobileViewportKeyboardFocus';
|
} from './mobileViewportKeyboardFocus';
|
||||||
|
|
||||||
|
const originalMatchMedia = window.matchMedia;
|
||||||
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||||
|
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||||
|
const originalInnerHeight = window.innerHeight;
|
||||||
|
const originalMaxTouchPoints = navigator.maxTouchPoints;
|
||||||
|
const originalVisualViewport = window.visualViewport;
|
||||||
|
|
||||||
|
function defineWindowValue<Key extends keyof Window>(key: Key, value: Window[Key]) {
|
||||||
|
Object.defineProperty(window, key, {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function defineNavigatorValue<Key extends keyof Navigator>(
|
||||||
|
key: Key,
|
||||||
|
value: Navigator[Key],
|
||||||
|
) {
|
||||||
|
Object.defineProperty(navigator, key, {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
document.documentElement.removeAttribute('data-mobile-keyboard-open');
|
||||||
|
document.documentElement.removeAttribute('data-mobile-viewport-keyboard-focus');
|
||||||
|
document.documentElement.removeAttribute('style');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
document.documentElement.removeAttribute('data-mobile-keyboard-open');
|
||||||
|
document.documentElement.removeAttribute('data-mobile-viewport-keyboard-focus');
|
||||||
|
document.documentElement.removeAttribute('style');
|
||||||
|
defineWindowValue('matchMedia', originalMatchMedia);
|
||||||
|
defineWindowValue('requestAnimationFrame', originalRequestAnimationFrame);
|
||||||
|
defineWindowValue('cancelAnimationFrame', originalCancelAnimationFrame);
|
||||||
|
defineWindowValue('innerHeight', originalInnerHeight);
|
||||||
|
defineNavigatorValue('maxTouchPoints', originalMaxTouchPoints);
|
||||||
|
defineWindowValue('visualViewport', originalVisualViewport);
|
||||||
|
});
|
||||||
|
|
||||||
describe('isEditableKeyboardTarget', () => {
|
describe('isEditableKeyboardTarget', () => {
|
||||||
it('matches controls that open the mobile keyboard', () => {
|
it('matches controls that open the mobile keyboard', () => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -31,47 +78,125 @@ describe('isEditableKeyboardTarget', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('calculateMobileKeyboardFocusShift', () => {
|
describe('resolveMobileKeyboardExposedFill', () => {
|
||||||
it('moves a bottom input above the visible keyboard area', () => {
|
it('uses the active platform shell fill for exposed mini-program keyboard space', () => {
|
||||||
expect(
|
document.body.innerHTML = `
|
||||||
calculateMobileKeyboardFocusShift({
|
<div
|
||||||
layoutHeight: 800,
|
class="platform-viewport-shell"
|
||||||
visualTop: 0,
|
style="--platform-body-fill: linear-gradient(180deg, rgb(255, 253, 249), rgb(248, 239, 231));"
|
||||||
visualHeight: 500,
|
></div>
|
||||||
targetTop: 720,
|
`;
|
||||||
targetBottom: 770,
|
|
||||||
currentShift: 0,
|
expect(resolveMobileKeyboardExposedFill()).toContain('rgb(255, 253, 249)');
|
||||||
margin: 20,
|
|
||||||
}),
|
|
||||||
).toBe(290);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not move when the focused input is already visible', () => {
|
it('falls back to the light platform fill before the shell mounts', () => {
|
||||||
expect(
|
document.body.innerHTML = '';
|
||||||
calculateMobileKeyboardFocusShift({
|
|
||||||
layoutHeight: 800,
|
expect(resolveMobileKeyboardExposedFill()).toContain('#fffdf9');
|
||||||
visualTop: 0,
|
});
|
||||||
visualHeight: 500,
|
|
||||||
targetTop: 250,
|
|
||||||
targetBottom: 300,
|
|
||||||
currentShift: 0,
|
|
||||||
margin: 20,
|
|
||||||
}),
|
|
||||||
).toBe(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps movement to keyboard inset plus safety margin', () => {
|
describe('resolveMobileKeyboardState', () => {
|
||||||
|
it('detects the keyboard inset without asking the app shell to shift', () => {
|
||||||
expect(
|
expect(
|
||||||
calculateMobileKeyboardFocusShift({
|
resolveMobileKeyboardState({
|
||||||
layoutHeight: 800,
|
layoutHeight: 800,
|
||||||
visualTop: 0,
|
visualTop: 0,
|
||||||
visualHeight: 500,
|
visualHeight: 500,
|
||||||
targetTop: 790,
|
hasEditableTarget: true,
|
||||||
targetBottom: 860,
|
|
||||||
currentShift: 0,
|
|
||||||
margin: 20,
|
|
||||||
maxExtraShift: 20,
|
|
||||||
}),
|
}),
|
||||||
).toBe(320);
|
).toEqual({
|
||||||
|
isOpen: true,
|
||||||
|
insetBottom: 300,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays closed when no editable target is focused', () => {
|
||||||
|
expect(
|
||||||
|
resolveMobileKeyboardState({
|
||||||
|
layoutHeight: 800,
|
||||||
|
visualTop: 0,
|
||||||
|
visualHeight: 500,
|
||||||
|
hasEditableTarget: false,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
isOpen: false,
|
||||||
|
insetBottom: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accounts for browser visual viewport panning without returning a shell offset', () => {
|
||||||
|
expect(
|
||||||
|
resolveMobileKeyboardState({
|
||||||
|
layoutHeight: 800,
|
||||||
|
visualTop: 120,
|
||||||
|
visualHeight: 500,
|
||||||
|
hasEditableTarget: true,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
isOpen: true,
|
||||||
|
insetBottom: 180,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stabilizeMobileViewportKeyboardFocus', () => {
|
||||||
|
it('marks H5 keyboard state without applying a global shell transform offset', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div
|
||||||
|
class="platform-viewport-shell"
|
||||||
|
style="--platform-body-fill: linear-gradient(180deg, rgb(255, 253, 249), rgb(248, 239, 231));"
|
||||||
|
>
|
||||||
|
<input id="theme-input" />
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
defineNavigatorValue('maxTouchPoints', 1);
|
||||||
|
defineWindowValue(
|
||||||
|
'matchMedia',
|
||||||
|
vi.fn().mockReturnValue({ matches: true }) as unknown as Window['matchMedia'],
|
||||||
|
);
|
||||||
|
defineWindowValue(
|
||||||
|
'requestAnimationFrame',
|
||||||
|
((callback: FrameRequestCallback) => {
|
||||||
|
callback(0);
|
||||||
|
return 1;
|
||||||
|
}) as Window['requestAnimationFrame'],
|
||||||
|
);
|
||||||
|
defineWindowValue(
|
||||||
|
'cancelAnimationFrame',
|
||||||
|
vi.fn() as unknown as Window['cancelAnimationFrame'],
|
||||||
|
);
|
||||||
|
defineWindowValue('innerHeight', 800);
|
||||||
|
defineWindowValue('visualViewport', {
|
||||||
|
height: 500,
|
||||||
|
offsetTop: 120,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
} as unknown as VisualViewport);
|
||||||
|
|
||||||
|
stabilizeMobileViewportKeyboardFocus();
|
||||||
|
|
||||||
|
document.getElementById('theme-input')?.focus();
|
||||||
|
document.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(document.documentElement.dataset.mobileKeyboardOpen).toBe('true');
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue(
|
||||||
|
'--platform-keyboard-focus-offset',
|
||||||
|
),
|
||||||
|
).toBe('0px');
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue(
|
||||||
|
'--platform-keyboard-inset-bottom',
|
||||||
|
),
|
||||||
|
).toBe('180px');
|
||||||
|
expect(
|
||||||
|
document.documentElement.style.getPropertyValue(
|
||||||
|
'--platform-keyboard-exposed-fill',
|
||||||
|
),
|
||||||
|
).toContain('rgb(255, 253, 249)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,31 +1,43 @@
|
|||||||
const MOBILE_POINTER_QUERY = '(pointer: coarse)';
|
const MOBILE_POINTER_QUERY = '(pointer: coarse)';
|
||||||
const KEYBOARD_OPEN_THRESHOLD_PX = 96;
|
const KEYBOARD_OPEN_THRESHOLD_PX = 96;
|
||||||
const FOCUS_MARGIN_PX = 18;
|
|
||||||
const MIN_LAYOUT_VIEWPORT_HEIGHT_PX = 320;
|
const MIN_LAYOUT_VIEWPORT_HEIGHT_PX = 320;
|
||||||
|
|
||||||
const LAYOUT_HEIGHT_VAR = '--platform-layout-viewport-height';
|
const LAYOUT_HEIGHT_VAR = '--platform-layout-viewport-height';
|
||||||
const KEYBOARD_FOCUS_OFFSET_VAR = '--platform-keyboard-focus-offset';
|
const KEYBOARD_FOCUS_OFFSET_VAR = '--platform-keyboard-focus-offset';
|
||||||
const KEYBOARD_INSET_VAR = '--platform-keyboard-inset-bottom';
|
const KEYBOARD_INSET_VAR = '--platform-keyboard-inset-bottom';
|
||||||
|
const KEYBOARD_EXPOSED_FILL_VAR = '--platform-keyboard-exposed-fill';
|
||||||
|
const KEYBOARD_EXPOSED_FILL_FALLBACK =
|
||||||
|
'linear-gradient(180deg, #fffdf9 0%, #fdf9f5 54%, #f8efe7 100%)';
|
||||||
|
|
||||||
type KeyboardFocusShiftInput = {
|
type KeyboardFocusShiftInput = {
|
||||||
layoutHeight: number;
|
layoutHeight: number;
|
||||||
visualTop: number;
|
visualTop: number;
|
||||||
visualHeight: number;
|
visualHeight: number;
|
||||||
targetTop: number;
|
hasEditableTarget: boolean;
|
||||||
targetBottom: number;
|
threshold?: number;
|
||||||
currentShift: number;
|
|
||||||
margin?: number;
|
|
||||||
maxExtraShift?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number) {
|
|
||||||
return Math.min(max, Math.max(min, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function readVisualViewport() {
|
function readVisualViewport() {
|
||||||
return typeof window !== 'undefined' ? window.visualViewport : undefined;
|
return typeof window !== 'undefined' ? window.visualViewport : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveMobileKeyboardExposedFill() {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
|
return KEYBOARD_EXPOSED_FILL_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformShell = document.querySelector('.platform-viewport-shell');
|
||||||
|
if (!(platformShell instanceof HTMLElement)) {
|
||||||
|
return KEYBOARD_EXPOSED_FILL_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformFill = window
|
||||||
|
.getComputedStyle(platformShell)
|
||||||
|
.getPropertyValue('--platform-body-fill')
|
||||||
|
.trim();
|
||||||
|
return platformFill || KEYBOARD_EXPOSED_FILL_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
function readLayoutViewportHeight() {
|
function readLayoutViewportHeight() {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
return MIN_LAYOUT_VIEWPORT_HEIGHT_PX;
|
return MIN_LAYOUT_VIEWPORT_HEIGHT_PX;
|
||||||
@@ -110,34 +122,22 @@ export function isEditableKeyboardTarget(
|
|||||||
]).has(inputType);
|
]).has(inputType);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateMobileKeyboardFocusShift({
|
export function resolveMobileKeyboardState({
|
||||||
layoutHeight,
|
layoutHeight,
|
||||||
visualTop,
|
visualTop,
|
||||||
visualHeight,
|
visualHeight,
|
||||||
targetTop,
|
hasEditableTarget,
|
||||||
targetBottom,
|
threshold = KEYBOARD_OPEN_THRESHOLD_PX,
|
||||||
currentShift,
|
|
||||||
margin = FOCUS_MARGIN_PX,
|
|
||||||
maxExtraShift = FOCUS_MARGIN_PX,
|
|
||||||
}: KeyboardFocusShiftInput) {
|
}: KeyboardFocusShiftInput) {
|
||||||
const visualBottom = visualTop + visualHeight;
|
const visualBottom = visualTop + visualHeight;
|
||||||
const safeTop = visualTop + margin;
|
const insetBottom = Math.max(0, Math.round(layoutHeight - visualBottom));
|
||||||
const safeBottom = visualBottom - margin;
|
const isOpen =
|
||||||
const unshiftedTargetTop = targetTop + currentShift;
|
hasEditableTarget && layoutHeight - visualHeight > threshold;
|
||||||
const unshiftedTargetBottom = targetBottom + currentShift;
|
|
||||||
let nextShift = currentShift;
|
|
||||||
|
|
||||||
if (unshiftedTargetBottom - nextShift > safeBottom) {
|
return {
|
||||||
nextShift = unshiftedTargetBottom - safeBottom;
|
isOpen,
|
||||||
}
|
insetBottom: isOpen ? insetBottom : 0,
|
||||||
|
};
|
||||||
if (unshiftedTargetTop - nextShift < safeTop) {
|
|
||||||
nextShift = Math.max(0, unshiftedTargetTop - safeTop);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyboardInset = Math.max(0, layoutHeight - visualBottom);
|
|
||||||
const maxShift = keyboardInset + maxExtraShift;
|
|
||||||
return Math.round(clamp(nextShift, 0, Math.max(0, maxShift)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stabilizeMobileViewportKeyboardFocus() {
|
export function stabilizeMobileViewportKeyboardFocus() {
|
||||||
@@ -154,7 +154,6 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
|||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const visualViewport = readVisualViewport();
|
const visualViewport = readVisualViewport();
|
||||||
let stableLayoutHeight = readLayoutViewportHeight();
|
let stableLayoutHeight = readLayoutViewportHeight();
|
||||||
let currentShift = 0;
|
|
||||||
let frameId = 0;
|
let frameId = 0;
|
||||||
|
|
||||||
const setLayoutHeight = (nextHeight: number) => {
|
const setLayoutHeight = (nextHeight: number) => {
|
||||||
@@ -167,6 +166,10 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
|||||||
|
|
||||||
const setKeyboardState = (isOpen: boolean, insetBottom = 0) => {
|
const setKeyboardState = (isOpen: boolean, insetBottom = 0) => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
root.style.setProperty(
|
||||||
|
KEYBOARD_EXPOSED_FILL_VAR,
|
||||||
|
resolveMobileKeyboardExposedFill(),
|
||||||
|
);
|
||||||
root.dataset.mobileKeyboardOpen = 'true';
|
root.dataset.mobileKeyboardOpen = 'true';
|
||||||
} else {
|
} else {
|
||||||
delete root.dataset.mobileKeyboardOpen;
|
delete root.dataset.mobileKeyboardOpen;
|
||||||
@@ -178,9 +181,8 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setFocusShift = (nextShift: number) => {
|
const resetFocusShift = () => {
|
||||||
currentShift = Math.max(0, Math.round(nextShift));
|
root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, '0px');
|
||||||
root.style.setProperty(KEYBOARD_FOCUS_OFFSET_VAR, `${currentShift}px`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const readActiveTarget = () =>
|
const readActiveTarget = () =>
|
||||||
@@ -193,15 +195,16 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
|||||||
const viewport = readVisualViewport();
|
const viewport = readVisualViewport();
|
||||||
const visualTop = viewport?.offsetTop ?? 0;
|
const visualTop = viewport?.offsetTop ?? 0;
|
||||||
const visualHeight = viewport?.height ?? window.innerHeight;
|
const visualHeight = viewport?.height ?? window.innerHeight;
|
||||||
const visualBottom = visualTop + visualHeight;
|
const keyboardState = resolveMobileKeyboardState({
|
||||||
const keyboardInset = Math.max(0, stableLayoutHeight - visualBottom);
|
layoutHeight: stableLayoutHeight,
|
||||||
const keyboardOpen =
|
visualTop,
|
||||||
Boolean(activeTarget) &&
|
visualHeight,
|
||||||
stableLayoutHeight - visualHeight > KEYBOARD_OPEN_THRESHOLD_PX;
|
hasEditableTarget: Boolean(activeTarget),
|
||||||
|
});
|
||||||
|
|
||||||
if (!keyboardOpen || !activeTarget) {
|
if (!keyboardState.isOpen || !activeTarget) {
|
||||||
setKeyboardState(false);
|
setKeyboardState(false);
|
||||||
setFocusShift(0);
|
resetFocusShift();
|
||||||
|
|
||||||
if (!activeTarget) {
|
if (!activeTarget) {
|
||||||
setLayoutHeight(readLayoutViewportHeight());
|
setLayoutHeight(readLayoutViewportHeight());
|
||||||
@@ -209,19 +212,10 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 中文注释:先保持整页布局高度,再只移动画布,让输入框避开键盘。
|
// 中文注释:H5 浏览器和小程序 web-view 已会自行处理输入框可见性。
|
||||||
const targetRect = activeTarget.getBoundingClientRect();
|
// 这里只记录键盘状态、隐藏底部 dock,并给可能露出的宿主区域补浅色背景。
|
||||||
const nextShift = calculateMobileKeyboardFocusShift({
|
setKeyboardState(true, keyboardState.insetBottom);
|
||||||
layoutHeight: stableLayoutHeight,
|
resetFocusShift();
|
||||||
visualTop,
|
|
||||||
visualHeight,
|
|
||||||
targetTop: targetRect.top,
|
|
||||||
targetBottom: targetRect.bottom,
|
|
||||||
currentShift,
|
|
||||||
});
|
|
||||||
|
|
||||||
setKeyboardState(true, keyboardInset);
|
|
||||||
setFocusShift(nextShift);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleSync = () => {
|
const scheduleSync = () => {
|
||||||
@@ -243,14 +237,14 @@ export function stabilizeMobileViewportKeyboardFocus() {
|
|||||||
|
|
||||||
setLayoutHeight(stableLayoutHeight);
|
setLayoutHeight(stableLayoutHeight);
|
||||||
setKeyboardState(false);
|
setKeyboardState(false);
|
||||||
setFocusShift(0);
|
resetFocusShift();
|
||||||
|
|
||||||
document.addEventListener('focusin', scheduleKeyboardAnimationSync, true);
|
document.addEventListener('focusin', scheduleKeyboardAnimationSync, true);
|
||||||
document.addEventListener('focusout', scheduleKeyboardAnimationSync, true);
|
document.addEventListener('focusout', scheduleKeyboardAnimationSync, true);
|
||||||
window.addEventListener('resize', scheduleKeyboardAnimationSync);
|
window.addEventListener('resize', scheduleKeyboardAnimationSync);
|
||||||
window.addEventListener('orientationchange', () => {
|
window.addEventListener('orientationchange', () => {
|
||||||
setKeyboardState(false);
|
setKeyboardState(false);
|
||||||
setFocusShift(0);
|
resetFocusShift();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setLayoutHeight(readLayoutViewportHeight());
|
setLayoutHeight(readLayoutViewportHeight());
|
||||||
scheduleSync();
|
scheduleSync();
|
||||||
|
|||||||
Reference in New Issue
Block a user