mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 21:41:02 +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
193 lines
6.3 KiB
JavaScript
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;
|
|
}
|