Files
reader/web/tools/icons/union.mjs
hectorqin 3c3e461a49 feat(web): 界面图标换成自带字体,补 UI/UX 规范并统一设计令牌
按钮里放的是 Unicode 字符——`☰` `⚙` `✕` `⟨` `⟩` `+` `🗂` `📁` `⮝`——
和 emoji。八个字形来自八个字族,光学重量各不相同;`🗂` `📁` `⏮` 在部分平台
被渲染成彩色的,而一个彩色 emoji 出现在单色工具栏里,是「没有设计过」最直接的
信号。不同平台的字体回退还不同,同一个界面在 Android 和桌面浏览器上长得不一样。
此外样式表里所有按钮都没有 `:focus-visible` 规则,键盘走一遍看不到焦点在哪。

所以这次做三件事:图标改成自带字体、把缺失的交互状态补齐、把散落的字面量收成
设计令牌,并把规范写成文档。

图标用字体而不是内联 SVG,理由是**一致性可以被强制**:字体把字形变成字符,它
继承 `color` 和 `font-size`,没有自己的几何,因此没有可以漂移的地方;而三十个
内联 SVG 组件里,颜色、尺寸、线宽每一项都是单个字形可以漂移的位置。字体是
bundle 里的一个 16KB 相对路径文件,`file://` 下能加载,与「一份产物两个宿主」
的既有约束一致。

字形由 `web/tools/icons/` 下的脚本从描边路径编译:路径 → 折线 → 圆头矩形 →
精确布尔并(Clipper)→ TTF。先做过一版「栅格化 + 边界追踪」,失败了:笔画宽度
只有一个栅格单元,而逐单元的边界规则无法表达「边界在一格宽的图形上左右交替」,
结果是一个圆被追踪成 58 段碎片。精确布尔没有这种情形。

放弃的方案:内联 SVG(前面说的漂移问题)、把图标做成 React 组件并逐个手绘
(每个都是独立的几何,光学重量必然不一致)、从 CDN 引用图标字体(本产品必须
在无外网环境下工作)。

界面改动:
- `Icon` 组件 + 37 个字形,码位表由生成器写出,测试直接解析字体 `cmap` 核对
- `IconButton` 的 `icon` 是字形名而非任意 `children`,传错名字是编译错误;
  `label` 必填,字形始终 `aria-hidden`
- 补上全部按钮的 `:focus-visible`(本次之前完全缺失)
- 间距/字号/动效收成令牌;`prefers-reduced-motion` 通过折叠时长令牌实现,
  而不是给每条动画各加一个媒体查询
- 悬停包进 `@media (hover: hover) and (pointer: fine)`,避免触屏上按钮停在悬停态

验证:`web` 336 个测试通过(新增 9 个图标契约测试);`typecheck`、`build`、
`check-shell.sh`、`verify-web-assets.sh` 全过;字体已确认被打进 Android assets。
真浏览器(Chromium + 真实服务端)实测了书架、书库管理、阅读器三个屏幕的浅色与
深色主题,以及键盘 Tab 下的焦点环。产物 259.5KB → 259.5KB(gzip 81.4KB),
新增 16KB 字体、CSS 减到 24.4KB。

Ref: #29
2026-09-18 07:08:02 +08:00

57 lines
2.0 KiB
JavaScript

/**
* Exact polygon union via Clipper.
*
* Clipper rather than a hand-rolled boolean because the input is adversarial for
* naive clipping: a stroked polyline is a chain of shapes that meet at exactly one
* endpoint each, so consecutive pieces touch at a single point and frequently share
* collinear edges. That is the case a simple sweep-line reports as "unable to
* complete output ring"; Clipper handles touching and collinear input by
* construction.
*
* Two details are load-bearing:
*
* - Clipper works in **integers**. Coordinates are scaled up before clipping and
* down after. Clipping in floats and rounding the output is what leaves rings
* that do not close.
* - The result is read from a **PolyTree**, not a flat path list, because a tree
* says which rings are holes and which are outers. A flat list leaves that to be
* guessed from winding, and a guessed hole renders solid.
*/
import ClipperLib from 'clipper-lib';
const SCALE = 1e6;
export function unionPolygons(rings) {
const clipper = new ClipperLib.Clipper();
const paths = rings.map((ring) => ring.map(([x, y]) => ({
X: Math.round(x * SCALE),
Y: Math.round(y * SCALE),
})));
clipper.AddPaths(paths, ClipperLib.PolyType.ptSubject, true);
const solution = new ClipperLib.PolyTree();
clipper.Execute(
ClipperLib.ClipType.ctUnion,
solution,
// `pftNonZero` unions rings wound consistently; `pftEvenOdd` would subtract
// every overlap instead, which is the opposite of what a stroke union means.
ClipperLib.PolyFillType.pftNonZero,
ClipperLib.PolyFillType.pftNonZero,
);
return readTree(solution);
}
/** PolyTree -> [{ ring, depth }], depth 0 being an outer boundary. */
function readTree(tree) {
const out = [];
const walk = (node, depth) => {
for (const child of node.Childs()) {
const ring = child.Contour().map((point) => [point.X / SCALE, point.Y / SCALE]);
if (ring.length >= 3) out.push({ ring, depth });
walk(child, depth + 1);
}
};
walk(tree, 0);
return out;
}