mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-12 05:50:54 +08:00
按钮里放的是 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
57 lines
2.0 KiB
JavaScript
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;
|
|
}
|