pcp-tgu: редактор — таймлайн зум/драг/секундная шкала, подтверждения в деталях плана, без авто-кнопок

- детали плана: подтверждение (confirm) на «Отправить» и «Удалить» черновика
- редактор: убраны кнопки «Применить»/«Отмена», undo/redo и сдвиг ◀▶ из тулбара
- окно: предел отдаления план±1ч, дефолт тоже план±1ч; зум кнопками и колесом мыши (центр — курсор)
- таймлайн: drag по горизонтали как карту; минимум окна ~10с
- ось времени: адаптивные деления от секунд до недель, секунды в подписи при мелком масштабе
This commit is contained in:
Дмитрий Соловьев
2026-06-04 14:20:01 +03:00
parent 9e7cae4a56
commit 5235142fa6
6 changed files with 211 additions and 149 deletions
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { formatDate, formatDateTime, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation";
import { formatDate, formatDateTime, formatTick, formatTime, kindColor, kindLabel, niceTicks } from "./editorPresentation";
import type {
EditorContextLane,
EditorTimelineWindow,
@@ -20,6 +20,10 @@ type EditorTimelineProps = {
visibilityTracks: EditorVisibilityTrack[];
onSelectWork: (workId: string) => void;
onClearSelection: () => void;
/** Масштаб колесом мыши: factor (>1 — отдалить, <1 — приблизить) и время под курсором. */
onWheelZoom?: (factor: number, anchorMs: number) => void;
/** Сдвиг окна перетаскиванием: новое значение левой границы окна (мс). */
onPanTo?: (fromMs: number) => void;
};
const LABEL_WIDTH = 168;
@@ -40,15 +44,21 @@ export function EditorTimeline({
contextLanes,
visibilityTracks,
onSelectWork,
onClearSelection
onClearSelection,
onWheelZoom,
onPanTo
}: EditorTimelineProps) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
const [bodyWidth, setBodyWidth] = useState(900);
const [bodyHeight, setBodyHeight] = useState(260);
const [grabbing, setGrabbing] = useState(false);
const span = Math.max(1, timelineWindow.toMs - timelineWindow.fromMs);
const pxPerMs = bodyWidth / span;
const toX = (timeMs: number) => (timeMs - timelineWindow.fromMs) * pxPerMs;
const ticks = useMemo(() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72), [pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]);
const { ticks, step: tickStep } = useMemo(
() => niceTicks(timelineWindow.fromMs, timelineWindow.toMs, pxPerMs, 72),
[pxPerMs, timelineWindow.fromMs, timelineWindow.toMs]
);
const days = useMemo(() => buildDays(timelineWindow), [timelineWindow]);
// Фиксированная высота частей ниже активной дорожки (треки видимости + контекст КА).
const belowActiveHeight =
@@ -81,8 +91,81 @@ export function EditorTimeline({
return () => observer.disconnect();
}, []);
// Свежие значения окна/геометрии для нативного wheel-слушателя (он навешивается один раз).
const wheelStateRef = useRef({ fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom });
wheelStateRef.current = { fromMs: timelineWindow.fromMs, pxPerMs, bodyWidth, onWheelZoom };
useEffect(() => {
const element = wrapperRef.current;
if (!element) return;
// Нативный слушатель с passive: false — иначе preventDefault не сработает и страница
// прокрутится вместо масштабирования таймлайна.
const handleWheel = (event: WheelEvent) => {
const state = wheelStateRef.current;
if (!state.onWheelZoom) return;
event.preventDefault();
const rect = element.getBoundingClientRect();
const x = event.clientX - rect.left - LABEL_WIDTH;
const clampedX = Math.min(Math.max(x, 0), state.bodyWidth);
const anchorMs = state.fromMs + clampedX / state.pxPerMs;
const factor = event.deltaY > 0 ? 1.1 : 0.9;
state.onWheelZoom(factor, anchorMs);
};
element.addEventListener("wheel", handleWheel, { passive: false });
return () => element.removeEventListener("wheel", handleWheel);
}, []);
// Перетаскивание окна по горизонтали (как карту). На mousedown фиксируем точку и левую
// границу окна, дальше двигаем абсолютно (без накопления дрейфа). Сдвиг > порога помечаем
// как drag, чтобы погасить последующий click (иначе выделение/сброс включения).
const dragRef = useRef<{ startX: number; startFromMs: number; pxPerMs: number; moved: boolean } | null>(null);
const suppressClickRef = useRef(false);
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.button !== 0 || !onPanTo || pxPerMs <= 0) return;
const drag = { startX: event.clientX, startFromMs: timelineWindow.fromMs, pxPerMs, moved: false };
dragRef.current = drag;
const onMove = (move: MouseEvent) => {
const deltaPx = move.clientX - drag.startX;
if (Math.abs(deltaPx) > 3) {
drag.moved = true;
setGrabbing(true);
}
// Тянем содержимое вправо → показываем более раннее время (окно едет влево).
onPanTo(drag.startFromMs - deltaPx / drag.pxPerMs);
};
const onUp = () => {
if (drag.moved) suppressClickRef.current = true;
dragRef.current = null;
setGrabbing(false);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
// Капче-фаза: если только что был drag — гасим click до того, как он дойдёт до полос/фона.
const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
event.stopPropagation();
event.preventDefault();
}
};
return (
<div className="tgu-editor-timeline" ref={wrapperRef} onClick={onClearSelection}>
<div
className="tgu-editor-timeline"
ref={wrapperRef}
onClick={onClearSelection}
onClickCapture={handleClickCapture}
onMouseDown={handleMouseDown}
style={onPanTo ? { cursor: grabbing ? "grabbing" : "grab" } : undefined}
>
<div className="tgu-editor-timeline__surface" style={{ width: LABEL_WIDTH + bodyWidth, height: Math.max(220, surfaceHeight) }}>
<div className="tgu-editor-timeline__axis-label" style={{ width: LABEL_WIDTH }}>
Время
@@ -96,7 +179,7 @@ export function EditorTimeline({
))}
{ticks.map((tick) => (
<div className="tgu-editor-timeline__tick-label" key={tick} style={{ left: toX(tick) }}>
{formatTime(tick)}
{formatTick(tick, tickStep)}
</div>
))}
</div>