Files
reader/web/tools/icons/path.mjs
T
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

193 lines
6.3 KiB
JavaScript

/**
* A minimal SVG path -> polyline converter.
*
* Only the commands this icon set actually uses: absolute and relative M, L, H, V,
* C, Q, A and Z. Curves are flattened into short chords, because what happens next
* is stroke expansion — which is a per-segment operation on straight edges, and
* which cannot be applied to a curve without first deciding what "the stroke width
* of a curve" means.
*/
const TOKEN = /([MmLlHhVvCcSsQqTtAaZz])|(-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)/g;
export function pathToSubpaths(d) {
const tokens = [];
for (const match of d.matchAll(TOKEN)) tokens.push(match[1] ?? match[2]);
const subpaths = [];
let current = null;
let cursor = [0, 0];
let start = [0, 0];
let command = '';
let index = 0;
const flush = () => {
if (current && current.length >= 2) subpaths.push(current);
current = null;
};
const next = () => Number(tokens[index++]);
const add = (x, y) => {
if (!current) current = [];
current.push([x, y]);
};
while (index < tokens.length) {
if (/[A-Za-z]/.test(tokens[index])) command = tokens[index++];
const relative = command === command.toLowerCase();
const upper = command.toUpperCase();
const ox = relative ? cursor[0] : 0;
const oy = relative ? cursor[1] : 0;
switch (upper) {
case 'M': {
flush();
const x = next() + ox;
const y = next() + oy;
cursor = [x, y];
start = [x, y];
add(x, y);
// Extra coordinate pairs after `M` are implicit `L`s.
command = relative ? 'l' : 'L';
break;
}
case 'L': {
const x = next() + ox;
const y = next() + oy;
// A `Z`-terminated subpath repeats its first point; that repeat is a
// zero-length segment and only confuses the stroke expansion.
if (current && current.length && x === cursor[0] && y === cursor[1]) {
index -= 0;
} else {
add(x, y);
}
cursor = [x, y];
break;
}
case 'H': {
const x = next() + ox;
add(x, cursor[1]);
cursor = [x, cursor[1]];
break;
}
case 'V': {
const y = next() + oy;
add(cursor[0], y);
cursor = [cursor[0], y];
break;
}
case 'C': {
const points = [next() + ox, next() + oy, next() + ox, next() + oy, next() + ox, next() + oy];
flattenCubic(current ?? [], cursor,
[points[0], points[1]], [points[2], points[3]], [points[4], points[5]]);
cursor = [points[4], points[5]];
break;
}
case 'Q': {
const points = [next() + ox, next() + oy, next() + ox, next() + oy];
flattenQuadratic(current ?? [], cursor, [points[0], points[1]], [points[2], points[3]]);
cursor = [points[2], points[3]];
break;
}
case 'A': {
const rx = next();
const ry = next();
const rotation = next();
const large = next();
const sweep = next();
const x = next() + ox;
const y = next() + oy;
for (const point of flattenArc(cursor, [x, y], rx, ry, rotation, large, sweep)) {
add(point[0], point[1]);
}
cursor = [x, y];
break;
}
case 'Z': {
flush();
cursor = start;
command = '';
break;
}
default:
index += 1;
}
}
flush();
return subpaths;
}
function flattenCubic(list, [x0, y0], [x1, y1], [x2, y2], [x3, y3]) {
// Step count from the control polygon's length: a short curve does not need 16
// chords, and a long one needs more than 8.
const approx = Math.hypot(x1 - x0, y1 - y0) + Math.hypot(x2 - x1, y2 - y1) + Math.hypot(x3 - x2, y3 - y2);
const steps = Math.max(4, Math.min(24, Math.ceil(approx / 1.5)));
for (let s = 1; s <= steps; s++) {
const t = s / steps;
const mt = 1 - t;
list.push([
mt ** 3 * x0 + 3 * mt * mt * t * x1 + 3 * mt * t * t * x2 + t ** 3 * x3,
mt ** 3 * y0 + 3 * mt * mt * t * y1 + 3 * mt * t * t * y2 + t ** 3 * y3,
]);
}
}
function flattenQuadratic(list, [x0, y0], [x1, y1], [x2, y2]) {
const approx = Math.hypot(x1 - x0, y1 - y0) + Math.hypot(x2 - x1, y2 - y1);
const steps = Math.max(3, Math.min(16, Math.ceil(approx / 1.5)));
for (let s = 1; s <= steps; s++) {
const t = s / steps;
const mt = 1 - t;
list.push([
mt * mt * x0 + 2 * mt * t * x1 + t * t * x2,
mt * mt * y0 + 2 * mt * t * y1 + t * t * y2,
]);
}
}
/** SVG elliptical-arc endpoint parameterisation, sampled. */
function flattenArc([x1, y1], [x2, y2], rx, ry, rotation, largeArc, sweep) {
if (rx === 0 || ry === 0) return [[x2, y2]];
const phi = (rotation * Math.PI) / 180;
const cos = Math.cos(phi);
const sin = Math.sin(phi);
const dx = (x1 - x2) / 2;
const dy = (y1 - y2) / 2;
const px = cos * dx + sin * dy;
const py = -sin * dx + cos * dy;
const lambda = (px * px) / (rx * rx) + (py * py) / (ry * ry);
if (lambda > 1) {
const scale = Math.sqrt(lambda);
rx *= scale;
ry *= scale;
}
const numerator = rx * rx * ry * ry - rx * rx * py * py - ry * ry * px * px;
const denominator = rx * rx * py * py + ry * ry * px * px;
const coefficient = (largeArc !== sweep ? 1 : -1) * Math.sqrt(Math.max(0, numerator / denominator));
const cxp = (coefficient * rx * py) / ry;
const cyp = (-coefficient * ry * px) / rx;
const cx = cos * cxp - sin * cyp + (x1 + x2) / 2;
const cy = sin * cxp + cos * cyp + (y1 + y2) / 2;
const angle = (ux, uy, vx, vy) => {
const dot = ux * vx + uy * vy;
const len = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1;
const value = Math.acos(Math.min(1, Math.max(-1, dot / len)));
return ux * vy - uy * vx < 0 ? -value : value;
};
const startAngle = angle(1, 0, (px - cxp) / rx, (py - cyp) / ry);
let delta = angle((px - cxp) / rx, (py - cyp) / ry, (-px - cxp) / rx, (-py - cyp) / ry);
if (delta < 0 && sweep) delta += 2 * Math.PI;
if (delta > 0 && !sweep) delta -= 2 * Math.PI;
const steps = Math.max(8, Math.ceil((Math.abs(delta) * Math.max(rx, ry)) / 0.8));
const points = [];
for (let s = 1; s <= steps; s++) {
const t = startAngle + (delta * s) / steps;
points.push([
cos * rx * Math.cos(t) - sin * ry * Math.sin(t) + cx,
sin * rx * Math.cos(t) + cos * ry * Math.sin(t) + cy,
]);
}
return points;
}