Files
SimpleNav/index.php
T
laoyang aac6d3e0f4 联想词键盘选词彻底修复:上下键 / 逗号句号 / 输入法组合上屏四条路径全通
背景:用户反馈「上下键和逗号句号都无法选择候选词,只能用鼠标」。逐条查下去
发现是**三个各自独立的原因**叠在一起,任何一个没修都会让人觉得「还是坏的」。

① 单组按键模式让另一组键静默失效
   线上 settings.suggest_keys 停在 'punct'(只认 , 和 .)—— 是该模式的正确
   行为,但用户不知道那是设置项在起作用,只当坏了。
   改法:默认值改为 'both'(↓/↑ 与 , . 都认)。'both' 是 'arrows' 与 'punct'
   各自行为的**严格超集**,升级不会让任何人的习惯失效,只是多了一组。
   老库用**一次性迁移**(哨兵键 mig_suggest_keys_both 把关)把已知单组值并成
   'both'。⚠️ 迁移必须只跑一次:db_init() 每次请求都执行,无条件 UPDATE 会把
   用户在后台特意选回的 'arrows' 于下一个请求改回去 —— 设置项直接失效。
   同时同步三处默认值:index.php / admin/index.php / admin/actions.php。

② 【元凶】beforeinput 只认 insertText,漏了 insertCompositionText
   输入法把标点当**组合内容**提交时,beforeinput 的 inputType 是
   insertCompositionText,旧代码直接 return → 这条路径完全失效。
   修法:两种 inputType 都认。

③ 【真机实测的硬限制】insertCompositionText 的 beforeinput cancelable = false
   Chromium 里这条事件**不允许** preventDefault(CDP 实测记录:
   beforeinput it:insertCompositionText comp:true data:"," pd:false cc:false),
   所以「命中就拦」对这条路根本无效 —— 标点会被真的插进搜索框。
   修法:加「事后收口」——beforeinput 记下快照与该移到的目标下标,
   等这一帧事件洪峰过去(setTimeout 0)再把标点从输入框里撤掉、并补上选词移动。
   目标下标必须**当场算好**:收口跑在宏任务里,那时 input 的 `active = -1`
   已经把选中清掉了,再从 active 起算会每次都回到第一条(不累加)。
   顺带把标点判定抽成共用函数 punctDir/keyPunctDir(e.key 与 e.code 互为兜底),
   并修掉「方向键也无差别置去重闸门」——方向键不产生 beforeinput,置了纯属副作用,
   反而会误挡紧随其后的真实标点。

测试与验证(全部实跑,非推断):
- jsdom 交互测试 89 项全过,新增「真实浏览器形态」场景(cancelable=false +
  组合上屏),并**纠正了旧版一条在断言 bug 本身的断言**(原写「组合上屏不该被拦」)。
- 反向验证 6 变体全部精确挂掉对应断言(含新增的「只认 insertText」与
  「方向键置闸门」两个变体);旧变体引用的 isComma/isPeriod 已随重构更新。
- **真实浏览器**(Playwright + msedge + CDP IME)21/21:上下键 / 半角 / 全角
  insertText / IME 组合上屏四条路径全部能选词。
- 真实浏览器反向验证:基准全绿,删掉「事后收口」或改回只认 insertText 都精确报红。
- 迁移语义测试 16 项全过(真实 db_init,含「用户改回 arrows 后不再被覆盖」)。
- 静态断言空跑 155 条全过;线上部署 0 FAIL,诊断页 57 条断言全过。
- 线上真机直连 n.yangyq.net 21/21。
2026-09-29 08:10:22 +08:00

207 lines
8.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 简航 SimpleNav —— 前台首页
* 顶部:品牌 + 搜索引擎切换 + 搜索框(带联想下拉)
* 中部:常用 / 工具等网格板块
* 下部:合集(标签页折叠 或 全部平铺,后台可切换)
*/
declare(strict_types=1);
require_once __DIR__ . '/functions.php';
send_no_cache();
$pdo = db();
// 前台数据
$gridGroups = $pdo->query("SELECT * FROM groups WHERE is_visible=1 AND display='grid' ORDER BY sort_order, id")->fetchAll();
$tabGroups = $pdo->query("SELECT * FROM groups WHERE is_visible=1 AND display='tabs' ORDER BY sort_order, id")->fetchAll();
$engines = $pdo->query("SELECT * FROM engines WHERE is_active=1 ORDER BY sort_order, id")->fetchAll();
// 合集区的显示方式(后台「设置 → 合集显示方式」):
// tabs = 标签页折叠(默认)—— 一次只平铺当前这一个合集
// flat = 全部平铺 —— 每个合集各占一块,一眼看全、不用点
$collLayout = setting('collections_layout', 'tabs') === 'flat' ? 'flat' : 'tabs';
/** 站点链接与顶部搜索框的打开方式(后台「设置 → 打开方式」)
* 返回 `_blank`(新标签页,默认,等同改造前的旧行为)或 `_self`(当前页面)。
* ⚠️ 搜索框与站点格必须共用这一个来源,不要在任何一处写死 target。 */
function open_target(): string
{
return setting('open_blank', '1') === '1' ? '_blank' : '_self';
}
$openTarget = open_target();
$openMode = $openTarget === '_blank' ? 'blank' : 'self';
// 联想词的选中按键(后台「设置 → 联想词按键」):
// both = ↓/↑ 与 , . 都认(默认) / arrows = 只认 ↓/↑ / punct = 只认 , 和 .
// 白名单回退:任何非法值都回到 both(默认值,与 db.php 一致),别让手改库/被篡改的表单把交互搞坏。
$suggestKeys = setting('suggest_keys', 'both');
if (!in_array($suggestKeys, ['arrows', 'punct', 'both'], true)) { $suggestKeys = 'both'; }
// 默认引擎
$defSlug = setting('default_engine', 'bing');
$default = null;
foreach ($engines as $en) {
if ($en['slug'] === $defSlug) { $default = $en; break; }
}
if ($default === null) { $default = $engines[0] ?? null; }
$primary = setting('primary_color', '#2F6BF7');
// 副标题(后台「设置 → 副标题」)。
// ⚠️ 允许为空:用户清空后就**不显示**副标题,不要回退成默认文案 ——
// 这正是 bug 的现场(原先空串被当成「未设置」,删了照样显示)。
// 空串原样取出(默认值取 ''),渲染处再按需判断是否输出。
$subtitle = setting('subtitle', '');
function sites_of(int $gid): array
{
$st = db()->prepare("SELECT * FROM sites WHERE group_id=? AND is_visible=1 ORDER BY sort_order, id");
$st->execute([$gid]);
return $st->fetchAll();
}
function site_label(array $s): string
{
return $s['short_name'] !== '' ? $s['short_name'] : ($s['name'] !== '' ? $s['name'] : domain_of($s['url']));
}
/** 渲染一个网站格(网格板块与合集共用) */
function site_tile(array $s): void
{
$label = site_label($s);
$src = icon_src($s['icon']);
// 打开方式与顶部搜索框同源(见 open_target()):新标签页 or 当前页面
$tgt = open_target();
?>
<li class="site">
<a href="<?= e($s['url']) ?>" target="<?= e($tgt) ?>"<?= $tgt === '_blank' ? ' rel="noopener"' : '' ?> title="<?= e($s['name'] ?: $label) ?>">
<?php if ($src): ?>
<img class="icon" src="<?= e($src) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="ico-letter" style="background:<?= e(letter_color($label)) ?>"><?= e(mb_substr($label, 0, 1)) ?></span>
<?php endif; ?>
<span class="name"><?= e($label) ?></span>
</a>
</li>
<?php
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e(site_name()) ?><?= $subtitle !== '' ? ' · ' . e($subtitle) : '' ?></title>
<meta name="description" content="<?= e(site_name()) ?> —— 个人网站导航">
<meta name="theme-color" content="<?= e($primary) ?>">
<!-- 站点图标(favicon / apple-touch / PWA manifest):有自定义就用自定义,
没有就完全退回内置资源。三处页面共用 site_icon_head(),避免半生效。 -->
<?= site_icon_head() ?>
<link rel="stylesheet" href="<?= e(asset('assets/css/style.css')) ?>">
<?php if ($primary !== '#2F6BF7'): ?>
<style>:root { --primary: <?= e($primary) ?>; }</style>
<?php endif; ?>
</head>
<body data-open="<?= e($openMode) ?>">
<div class="container">
<!-- 顶部品牌区 -->
<header class="hero" data-site-icon="<?= e(site_icon_mode()) ?>">
<img class="logo" src="<?= e(site_icon_url('logo', 512) ?: asset('assets/img/logo.svg')) ?>" alt="<?= e(site_name()) ?>">
<h1><?= e(site_name()) ?></h1>
<?php if ($subtitle !== ''): ?>
<p><?= e($subtitle) ?></p>
<?php endif; ?>
</header>
<!-- 顶部搜索 -->
<div id="search" class="s-search">
<div class="search-type" id="engine-tabs">
<?php foreach ($engines as $en): ?>
<label class="s-engine<?= $default && $en['slug'] === $default['slug'] ? ' active' : '' ?>"
data-search="<?= e($en['search_url']) ?>"
data-param="<?= e($en['param']) ?>"
data-slug="<?= e($en['slug']) ?>"
data-placeholder="<?= e($en['name']) ?>搜索"
style="--ec:<?= e($en['color']) ?>">
<span><?= e($en['name']) ?></span>
</label>
<?php endforeach; ?>
<?php if (!$engines): ?>
<label class="s-engine active" data-search="https://cn.bing.com/search" data-param="q" data-slug="" data-placeholder="搜索"><span>搜索</span></label>
<?php endif; ?>
</div>
<form id="search-form" action="<?= e($default['search_url'] ?? 'https://cn.bing.com/search') ?>" method="get" target="<?= e($openTarget) ?>"<?= $openTarget === '_blank' ? ' rel="noopener"' : '' ?>
data-slug="<?= e($default['slug'] ?? '') ?>" data-keys="<?= e($suggestKeys) ?>">
<input type="text" id="search-text" name="<?= e($default['param'] ?? 'q') ?>"
placeholder="<?= e(($default['name'] ?? '搜索') . '搜索') ?>" autocomplete="off">
<button class="submit" type="submit" aria-label="搜索">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<path d="M20 20l-3.6-3.6"></path>
</svg>
</button>
<ul id="word"></ul>
</form>
</div>
<!-- 网格板块:常用 / 工具 ... -->
<?php foreach ($gridGroups as $g): $items = sites_of((int)$g['id']); if (!$items) continue; ?>
<ul class="mylist">
<li class="title"><?= e($g['name']) ?></li>
<?php foreach ($items as $s) { site_tile($s); } ?>
</ul>
<?php endforeach; ?>
<!-- 合集:标签页折叠 或 全部平铺(后台「设置 → 合集显示方式」切换) -->
<?php
// 先收一遍「有内容的合集」,空合集不占位(标签页模式下也不会多出一个空标签)
$collGroups = [];
foreach ($tabGroups as $g) {
$items = sites_of((int)$g['id']);
if ($items) { $collGroups[] = ['g' => $g, 'items' => $items]; }
}
?>
<?php if ($collGroups): ?>
<div class="collections<?= $collLayout === 'flat' ? ' flat' : '' ?>" data-layout="<?= e($collLayout) ?>">
<div class="collections-title">合集</div>
<?php if ($collLayout === 'flat'): ?>
<?php foreach ($collGroups as $c): ?>
<ul class="mylist">
<li class="title"><?= e($c['g']['name']) ?></li>
<?php foreach ($c['items'] as $s) { site_tile($s); } ?>
</ul>
<?php endforeach; ?>
<?php else: ?>
<div class="tabs" id="coll-tabs" role="tablist">
<?php foreach ($collGroups as $i => $c): ?>
<button type="button" class="tab<?= $i === 0 ? ' active' : '' ?>" role="tab"
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
data-target="coll-<?= (int)$c['g']['id'] ?>"><?= e($c['g']['name']) ?></button>
<?php endforeach; ?>
</div>
<div class="tab-panels">
<?php foreach ($collGroups as $i => $c): ?>
<ul class="mylist tab-panel<?= $i === 0 ? ' active' : '' ?>" id="coll-<?= (int)$c['g']['id'] ?>" role="tabpanel">
<?php foreach ($c['items'] as $s) { site_tile($s); } ?>
</ul>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<footer class="foot">
<a href="<?= e(url('admin/')) ?>">管理</a>
<span>·</span>
<span><?= e(site_name()) ?></span>
</footer>
</div>
<script>window.SIMPLENAV = { suggest: "<?= e(url('suggest.php')) ?>", suggestKeys: "<?= e($suggestKeys) ?>" };</script>
<script src="<?= e(asset('assets/js/app.js')) ?>"></script>
</body>
</html>