From 2b7eb003c4a8680c45b24664e96da52e496d6556 Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 14 May 2026 02:36:34 +0800 Subject: [PATCH] auto-save 2026-05-14 02:36 (~2) --- .memory/worklog.json | 13 ++ web/components/nodes/index.tsx | 231 ++++++++++++++++++++++++++++++--- 2 files changed, 228 insertions(+), 16 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 31b740d..5258033 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -2914,6 +2914,19 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 02:25 (~2)", "files_changed": 3 + }, + { + "ts": "2026-05-14T02:31:01+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 02:30 (+2, ~4)", + "hash": "95fbb0c", + "files_changed": 6 + }, + { + "ts": "2026-05-13T18:33:11Z", + "type": "session-heartbeat", + "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 02:30 (+2, ~4)", + "files_changed": 1 } ] } diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index 9a937da..2bedfd9 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -1,5 +1,10 @@ "use client" -import { useEffect, useRef, useState } from "react" +import { + useEffect, useRef, useState, + type PointerEvent as ReactPointerEvent, + type ReactNode, + type RefObject, +} from "react" import { createPortal } from "react-dom" import { type NodeProps, useReactFlow } from "@xyflow/react" import { @@ -87,6 +92,18 @@ function asrStatus(job: Job | null): NodeStatus { type PreviewAnchor = { id: T; x: number; y: number } +type ScrollRailState = { + visible: boolean + leftPct: number + widthPct: number + now: number + max: number +} + +function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, value)) +} + function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) { if (!root) return { x: 160, y: 0 } const rootRect = root.getBoundingClientRect() @@ -100,6 +117,197 @@ function canvasThumbnailAnchor(root: HTMLDivElement | null, target: HTMLElement) } } +function ThumbnailScrollRail({ + scrollRef, + label = "缩略图横向滑动条", +}: { + scrollRef: RefObject + label?: string +}) { + const railRef = useRef(null) + const dragRef = useRef<{ + pointerId: number + startX: number + startScrollLeft: number + maxScroll: number + trackRange: number + } | null>(null) + const [dragging, setDragging] = useState(false) + const [rail, setRail] = useState({ + visible: false, + leftPct: 0, + widthPct: 100, + now: 0, + max: 0, + }) + + useEffect(() => { + const el = scrollRef.current + if (!el) return + + const update = () => { + const max = Math.max(0, el.scrollWidth - el.clientWidth) + const visible = max > 2 + const widthPct = visible + ? Math.max(18, Math.min(92, (el.clientWidth / Math.max(el.scrollWidth, 1)) * 100)) + : 100 + const leftPct = visible ? (el.scrollLeft / max) * (100 - widthPct) : 0 + const next = { + visible, + leftPct: Number(leftPct.toFixed(2)), + widthPct: Number(widthPct.toFixed(2)), + now: Math.round(el.scrollLeft), + max: Math.round(max), + } + setRail((prev) => ( + prev.visible === next.visible && + Math.abs(prev.leftPct - next.leftPct) < 0.1 && + Math.abs(prev.widthPct - next.widthPct) < 0.1 && + prev.now === next.now && + prev.max === next.max + ) ? prev : next) + } + + const raf = window.requestAnimationFrame(update) + el.addEventListener("scroll", update, { passive: true }) + const resizeObserver = new ResizeObserver(update) + resizeObserver.observe(el) + const mutationObserver = new MutationObserver(update) + mutationObserver.observe(el, { childList: true, subtree: true, attributes: true }) + + return () => { + window.cancelAnimationFrame(raf) + el.removeEventListener("scroll", update) + resizeObserver.disconnect() + mutationObserver.disconnect() + } + }, [scrollRef]) + + if (!rail.visible) return null + + const syncScrollFromPointer = (e: ReactPointerEvent) => { + const drag = dragRef.current + const el = scrollRef.current + if (!drag || !el) return + const delta = e.clientX - drag.startX + el.scrollLeft = clamp( + drag.startScrollLeft + (delta / drag.trackRange) * drag.maxScroll, + 0, + drag.maxScroll, + ) + } + + return ( +
{ + const el = scrollRef.current + const track = railRef.current + if (!el || !track) return + const maxScroll = Math.max(0, el.scrollWidth - el.clientWidth) + if (maxScroll <= 0) return + + e.preventDefault() + e.stopPropagation() + + const rect = track.getBoundingClientRect() + const thumbWidth = rect.width * (rail.widthPct / 100) + const trackRange = Math.max(1, rect.width - thumbWidth) + const pointerX = e.clientX - rect.left + const thumbLeft = (el.scrollLeft / maxScroll) * trackRange + let startScrollLeft = el.scrollLeft + + if (pointerX < thumbLeft || pointerX > thumbLeft + thumbWidth) { + startScrollLeft = clamp(((pointerX - thumbWidth / 2) / trackRange) * maxScroll, 0, maxScroll) + el.scrollLeft = startScrollLeft + } + + dragRef.current = { + pointerId: e.pointerId, + startX: e.clientX, + startScrollLeft, + maxScroll, + trackRange, + } + setDragging(true) + e.currentTarget.setPointerCapture(e.pointerId) + }} + onPointerMove={(e) => { + if (dragRef.current?.pointerId !== e.pointerId) return + e.preventDefault() + e.stopPropagation() + syncScrollFromPointer(e) + }} + onPointerUp={(e) => { + if (dragRef.current?.pointerId !== e.pointerId) return + e.preventDefault() + e.stopPropagation() + dragRef.current = null + setDragging(false) + e.currentTarget.releasePointerCapture(e.pointerId) + }} + onPointerCancel={(e) => { + if (dragRef.current?.pointerId !== e.pointerId) return + dragRef.current = null + setDragging(false) + }} + onKeyDown={(e) => { + const el = scrollRef.current + if (!el) return + const page = Math.max(80, el.clientWidth * 0.65) + const small = Math.max(32, el.clientWidth * 0.18) + let next: number | null = null + if (e.key === "ArrowLeft") next = el.scrollLeft - small + if (e.key === "ArrowRight") next = el.scrollLeft + small + if (e.key === "PageUp") next = el.scrollLeft - page + if (e.key === "PageDown") next = el.scrollLeft + page + if (e.key === "Home") next = 0 + if (e.key === "End") next = rail.max + if (next === null) return + e.preventDefault() + e.stopPropagation() + el.scrollTo({ left: clamp(next, 0, rail.max), behavior: "smooth" }) + }} + > +
+
+ ) +} + +function FloatingThumbnailStrip({ + children, + label, +}: { + children: ReactNode + label?: string +}) { + const scrollRef = useRef(null) + + return ( +
+
+ {children} +
+ +
+ ) +} + /* ============================================================ 1. InputNode — TK 链接 / 上传 ============================================================ */ @@ -141,10 +349,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an {/* 多视频缩略图浮条 — 「+」在最左,job 按时间倒序(最新靠左高亮),统一高度 64,宽度按视频原比例,一行横滚。 浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */} {!videoExpanded && d.jobs.length > 0 && ( -
+ {/* + 再上传一个(放在最前面) */}
) })} -
+ )} {(() => { @@ -549,10 +754,7 @@ export function VisualLabNode({ data, selected }: any) { return (
{previews.length > 0 && ( -
+ {previews.map((p) => { const isSelected = p.kind !== "video" && d.selectedFrames.has(p.frameIdx) return ( @@ -706,7 +908,7 @@ export function VisualLabNode({ data, selected }: any) {
) })} -
+ )} {(() => { @@ -808,10 +1010,7 @@ export function KeyframeNode({ data, selected }: any) {
{/* 缩略图浮条 — 单行横滚 + 固定高度,跟节点宽度对齐;超出横滚(视觉类节点统一规则) */} {frames.length > 0 && jobId && ( -
+ {frames.map((f) => { const isSel = d.selectedFrames.has(f.index) return ( @@ -903,7 +1102,7 @@ export function KeyframeNode({ data, selected }: any) {
) })} -
+ )} {(() => {