mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 13:31:09 +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
73 lines
2.7 KiB
JavaScript
73 lines
2.7 KiB
JavaScript
/**
|
|
* Stroke expansion.
|
|
*
|
|
* The set is authored as stroked polylines, and a font renders filled outlines. The
|
|
* bridge is to give each segment its own closed ring — a rectangle with a
|
|
* semicircular cap at each end — and let a boolean union merge the rings.
|
|
*
|
|
* Rounding both ends the same way is what makes a polyline read as one continuous
|
|
* stroke: square ends leave the two halves of an arc visibly separate, and mitred
|
|
* joins put a spike on the outside of every bend at small sizes.
|
|
*
|
|
* The winding is the part that is easy to get subtly wrong. The ring has to travel
|
|
* *out along one side of the segment and back along the other*, turning through the
|
|
* head and the tail; if both caps sweep the same way the ring self-intersects and the
|
|
* union returns two half-shapes instead of one bar.
|
|
*/
|
|
|
|
/** Cap sampling: a semicircle in 8 chords is well under one font unit of error. */
|
|
const CAP_STEPS = 8;
|
|
|
|
/**
|
|
* One segment's outline as a closed ring, or `null` for a zero-length segment.
|
|
*
|
|
* Coordinates are in the same units as the input; the caller scales.
|
|
*/
|
|
export function segmentPolygon(x1, y1, x2, y2, width) {
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
const length = Math.hypot(dx, dy);
|
|
if (length === 0) return null;
|
|
|
|
const radius = width / 2;
|
|
const direction = Math.atan2(dy, dx);
|
|
|
|
const ring = [];
|
|
/*
|
|
* Start cap: sweep from the side at `direction + 90°` round through the tail
|
|
* (`direction + 180°`) to the side at `direction + 270°`. In a y-down coordinate
|
|
* system that is half a turn, and it ends where the second cap will begin.
|
|
*/
|
|
for (let i = 0; i <= CAP_STEPS; i++) {
|
|
const t = direction + Math.PI / 2 + (Math.PI * i) / CAP_STEPS;
|
|
ring.push([x1 + Math.cos(t) * radius, y1 + Math.sin(t) * radius]);
|
|
}
|
|
/*
|
|
* End cap: the other half turn, starting at `direction - 90°` so it continues from
|
|
* the first cap's last point and closes back onto its first.
|
|
*/
|
|
for (let i = 0; i <= CAP_STEPS; i++) {
|
|
const t = direction - Math.PI / 2 + (Math.PI * i) / CAP_STEPS;
|
|
ring.push([x2 + Math.cos(t) * radius, y2 + Math.sin(t) * radius]);
|
|
}
|
|
return close(ring);
|
|
}
|
|
|
|
/**
|
|
* Removes consecutive duplicates and the explicit closing point.
|
|
*
|
|
* Clipper treats a repeated first/last vertex as a degenerate edge, and degenerate
|
|
* edges are reported as "unable to complete output ring".
|
|
*/
|
|
function close(ring) {
|
|
const out = [];
|
|
for (const point of ring) {
|
|
const previous = out[out.length - 1];
|
|
if (previous && Math.abs(previous[0] - point[0]) < 1e-9 && Math.abs(previous[1] - point[1]) < 1e-9) {
|
|
continue;
|
|
}
|
|
out.push([+point[0].toFixed(4), +point[1].toFixed(4)]);
|
|
}
|
|
return out.length >= 3 ? out : null;
|
|
}
|