pcp-tgu: редактор — таймлайн зум/драг/секундная шкала, подтверждения в деталях плана, без авто-кнопок
- детали плана: подтверждение (confirm) на «Отправить» и «Удалить» черновика - редактор: убраны кнопки «Применить»/«Отмена», undo/redo и сдвиг ◀▶ из тулбара - окно: предел отдаления план±1ч, дефолт тоже план±1ч; зум кнопками и колесом мыши (центр — курсор) - таймлайн: drag по горизонтали как карту; минимум окна ~10с - ось времени: адаптивные деления от секунд до недель, секунды в подписи при мелком масштабе
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user