Skip to content

Commit 103703e

Browse files
committed
feat(session): 加共享时间轴尺
1 parent 7d63a1b commit 103703e

3 files changed

Lines changed: 183 additions & 0 deletions

File tree

‎app/src/views/session/TimeRail.css‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* TimeRail.css — 时间轨的**视觉接缝**(批 6 T25 Step 1)。只写 L1 原语覆盖不到的东西:
3+
* 刻度线的定位与引线;文案与墨度仍归 `ui/primitives`。
4+
*
5+
* @ai-context 颜色一律走 token(规格 §4.2 的语义注释逐字):刻度底 = `--ed-border-strong`
6+
* (「输入框 / 刻度底 / 引线」)⇒ 本文件 **0 个色值字面量**、0 个裸圆角、0 个裸字号。
7+
* @ai-context **零 `animation` / 零 `transition` / 零 `@media`**:刻度尺是静态的 ⇒ 既不需要进
8+
* `motion.css` 的 reduced-motion 名单(`motion-coverage.test.ts:141` 的域只含
9+
* `ui/primitives/*.css`),也不会新增第二条媒体查询(`style-seams.test.ts` 的「全仓只许一条」)。
10+
* @ai-context **零 `z-index`**(`zIndex.guard.test.ts` 的域含全 `app/src` 的 `.css`)
11+
* —— 层序由 `position: relative/absolute` 的文档序承担,本轨不与任何浮层竞争。
12+
* 副作用:无(纯样式表;无 I/O、无动画、无全局选择器 —— 每条规则都锚在 `.ed-timerail` 子树)。
13+
*/
14+
15+
.ed-timerail {
16+
display: flex;
17+
flex-direction: column;
18+
gap: 4px;
19+
margin-bottom: 10px;
20+
}
21+
22+
/* 读数行(总长):沿基线对齐,不参与轨道滚动 */
23+
.ed-timerail__row {
24+
display: flex;
25+
align-items: center;
26+
gap: 8px;
27+
}
28+
29+
/* 滚动视口:轨道比视口宽时横向滚动(播放头的定位目标也是这个元素,见第二步提交) */
30+
.ed-timerail__viewport {
31+
overflow-x: auto;
32+
border-bottom: 1px solid var(--ed-border);
33+
}
34+
35+
/* 轨道:宽度 = 总时长换算出的像素(行内 style 给宽度,是布局而非视觉权威) */
36+
.ed-timerail__track {
37+
position: relative;
38+
height: 19px;
39+
min-width: 100%;
40+
}
41+
42+
/* 刻度:一条竖线 + 其右侧的时间码(时间码文本走 `Text` 原语,墨度由它定) */
43+
.ed-timerail__tick {
44+
position: absolute;
45+
top: 12px;
46+
bottom: 0;
47+
width: 1px;
48+
background: var(--ed-border-strong);
49+
}
50+
51+
.ed-timerail__tick > span {
52+
position: absolute;
53+
top: -12px;
54+
left: 2px;
55+
white-space: nowrap;
56+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* TimeRail.test.tsx — 共享时间轴尺的**行为级契约**(批 6 T25 Step 1 · R5.5 #5 的承载面)。
4+
*
5+
* @ai-context 本件覆盖「尺」这一半:刻度的**序列**(逐序相等,不是「约等于」)· 刻度文案
6+
* = `fmtMs` 的输出 · 轨道宽度 = 总长的像素换算(换总长就换宽度)。播放头、`<audio>` 属性契约
7+
* 与降级提示行是 T25 的第二步(同一个文件里随后追加,见 `task-25-report.md` 的 V1–V7)。
8+
* @ai-context 仪器边界:jsdom **不排版** ⇒ 只判结构事实(`data-tick-ms` 序列、行内宽度、
9+
* DOM 归属),**不判**「刻度看起来在哪」。
10+
* 副作用:只挂 React 树;不写盘、不发请求、不碰媒体。
11+
*/
12+
import { cleanup, render } from "@testing-library/react";
13+
import { afterEach, describe, expect, it } from "vitest";
14+
import TimeRail, { pxOf, ticksOf } from "./TimeRail";
15+
16+
const RAIL = '[data-testid="session-timerail"]';
17+
const VIEWPORT = '[data-testid="session-timerail-viewport"]';
18+
19+
function mount(totalMs: number) {
20+
const view = render(<TimeRail totalMs={totalMs} />);
21+
const ticks = [...view.container.querySelectorAll("[data-tick-ms]")] as HTMLElement[];
22+
return { ...view, ticks };
23+
}
24+
afterEach(cleanup);
25+
26+
describe("尺的刻度:序列与文案都逐字(自己实现一个格式化会当场露馅)", () => {
27+
it("130000ms ⇒ 刻度逐序 [0,60000,120000]、文案逐序 [00:00,01:00,02:00];单测 `ticksOf`", () => {
28+
const { ticks } = mount(130_000);
29+
expect(ticks.map((el) => Number(el.dataset.tickMs)), "刻度序列漂了(或漏了末格前的一格)").toEqual([0, 60_000, 120_000]);
30+
expect(ticks.map((el) => el.textContent), "刻度文案不是 `fmtMs` 的输出").toEqual(["00:00", "01:00", "02:00"]);
31+
expect(ticksOf(130_000)).toEqual([0, 60_000, 120_000]);
32+
expect(ticksOf(0)).toEqual([0]);
33+
// 反例守卫:过一小时的会话出 h:mm:ss(`Math.floor(ms/1000)+"s"` 给不出它)
34+
cleanup();
35+
const hour = mount(3_725_000);
36+
expect(hour.ticks.at(-1)?.textContent).toBe("1:02:00");
37+
});
38+
});
39+
40+
describe("轨的宽度 = 总长的像素换算(不是常量、也不是视口宽)", () => {
41+
it("120000ms ⇒ 120px;换 30000ms ⇒ 30px(常数实现当场红),且刻度都在轨子树里", () => {
42+
const { container } = mount(120_000);
43+
const track = container.querySelector(".ed-timerail__track");
44+
if (track === null) throw new Error("没有渲染出轨道");
45+
expect((track as HTMLElement).style.width).toBe("120px");
46+
expect(track.querySelectorAll("[data-tick-ms]").length, "刻度不在轨道子树里(尺就没画出来)").toBe(3);
47+
expect(container.querySelector(RAIL)?.contains(container.querySelector(VIEWPORT))).toBe(true);
48+
expect([0, 52_500, 90_000, 120_000].map(pxOf)).toEqual([0, 52.5, 90, 120]);
49+
50+
cleanup();
51+
const short = mount(30_000);
52+
expect((short.container.querySelector(".ed-timerail__track") as HTMLElement).style.width, "轨宽是常量/写死的").toBe("30px");
53+
expect(short.ticks.map((el) => el.textContent)).toEqual(["00:00"]);
54+
});
55+
});

‎app/src/views/session/TimeRail.tsx‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* TimeRail — 「时间码回跳」的**共享时间轴尺**(批 6 T25 Step 1 · R5.5 #5)。
3+
*
4+
* @ai-context 为什么住在这里(规格 §7.1 依赖方向 + `views/architecture.guard.test.ts` 的 A3③
5+
* 整目录硬边界):本件属 `views/**` ⇒ **零 `@tauri-apps`**(含 `import type`)。三条轨共用
6+
* **同一条 ms 轴**,但此前只能靠「DOM 顺序」间接表达(批 5 的三轨是并排列、无共享刻度)⇒
7+
* 本件把那根轴**画出来**:一条横向的刻度尺,总长由调用方派生后传入。
8+
* @ai-context **总长的真源在调用方**(`SessionTriTrackView.totalMsOf`):本件只做
9+
* 「ms → 像素」的换算,**不猜时长**(不发明数字 —— 刻度的唯一来源是 `TICK_MS`)。
10+
* @ai-context **刻度文案一律经 `fmtMs`**(时间码的唯一出口,与三轨条目同源):本件不自己实现
11+
* 一个格式化函数。
12+
* @ai-context **零 `animation` / 零 `transition` / 零 `z-index`**:本件是**静态**刻度尺
13+
* (播放头与降级提示归 T25 的第二步提交/后续动效;「掠过几帧缩略」归 T31)。
14+
* 副作用:无(纯展示;无 I/O、无订阅、无定时器、无全局状态、无本地 state)。
15+
* 边界:① `totalMs <= 0` ⇒ 轨道为空但仍在 DOM(轨的缺席与否由调用方决定);② 刻度是
16+
* **定值序列**(`0` 起每 `TICK_MS` 一格)⇒ 与数据规模无关,不随会话长度抖动。
17+
*/
18+
import type { CSSProperties, ReactElement } from "react";
19+
import { Text } from "../../ui/primitives";
20+
import { fmtMs } from "../../utils/fmt";
21+
import "./TimeRail.css";
22+
23+
/** 1 px = 1000 ms(1 秒 1 像素):判据点 `0 / 52.5 / 90 / 120` px 即 `0 / 52500 / 90000 / 120000` ms */
24+
export const MS_PER_PX = 1000;
25+
26+
/** 刻度间隔(1 分钟一格)—— 刻度的**唯一**来源(不在 JSX 里现写数字) */
27+
export const TICK_MS = 60_000;
28+
29+
/** ms → 轨道内像素(纯换算;本件唯一的 ms → 位置映射) */
30+
export function pxOf(ms: number): number {
31+
return ms / MS_PER_PX;
32+
}
33+
34+
/** 刻度序列:0 起、每 `TICK_MS` 一格,末格不超出总长(纯函数 ⇒ 可单测、可逐序对拍) */
35+
export function ticksOf(totalMs: number): readonly number[] {
36+
const out: number[] = [];
37+
for (let ms = 0; ms <= totalMs; ms += TICK_MS) out.push(ms);
38+
return out;
39+
}
40+
41+
/** 刻度线自身的定位(布局,不是视觉权威):横坐标由 `pxOf` 给 */
42+
const TICK_STYLE = (ms: number): CSSProperties => ({ left: pxOf(ms) });
43+
44+
interface Props {
45+
/** 时间轨总长(毫秒,**会话轴**)—— 真源由调用方派生(`SessionTriTrackView.totalMsOf`) */
46+
readonly totalMs: number;
47+
}
48+
49+
/** 共享时间轴尺(见文件头)。 */
50+
export default function TimeRail({ totalMs }: Props): ReactElement {
51+
/** 轨道长度:项目上限 = 总长本身(播放头加入后才会出现「比总长更靠右」的位置) */
52+
const trackMs = Math.max(totalMs, 0);
53+
54+
return (
55+
<div className="ed-timerail" data-testid="session-timerail">
56+
<div className="ed-timerail__row">
57+
<Text as="span" size={5} tone="ink-3">{`时间轨 0:00 – ${fmtMs(trackMs)}`}</Text>
58+
</div>
59+
<div className="ed-timerail__viewport" data-testid="session-timerail-viewport">
60+
<div className="ed-timerail__track" style={{ width: pxOf(trackMs) }}>
61+
{ticksOf(trackMs).map((ms) => (
62+
<span key={ms} className="ed-timerail__tick" style={TICK_STYLE(ms)} data-tick-ms={ms}>
63+
<Text as="span" size={5} tone="ink-3">
64+
{fmtMs(ms)}
65+
</Text>
66+
</span>
67+
))}
68+
</div>
69+
</div>
70+
</div>
71+
);
72+
}

0 commit comments

Comments
 (0)