auto-save 2026-05-14 02:36 (~2)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<T extends string | number> = { 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<HTMLDivElement | null>
|
||||
label?: string
|
||||
}) {
|
||||
const railRef = useRef<HTMLDivElement>(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<ScrollRailState>({
|
||||
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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
ref={railRef}
|
||||
role="scrollbar"
|
||||
aria-label={label}
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={rail.max}
|
||||
aria-valuenow={rail.now}
|
||||
tabIndex={0}
|
||||
className={`thumbnail-scroll-rail nodrag nopan relative mt-2 h-5 rounded-full border shadow-[0_10px_24px_rgba(0,0,0,0.28)] outline-none transition ${
|
||||
dragging
|
||||
? "cursor-grabbing border-violet-200/80 bg-violet-950/80 ring-2 ring-violet-300/70"
|
||||
: "cursor-grab border-white/20 bg-black/50 hover:border-violet-300/75 hover:bg-violet-950/60 focus-visible:border-violet-200 focus-visible:ring-2 focus-visible:ring-violet-300/70"
|
||||
}`}
|
||||
onPointerDown={(e) => {
|
||||
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" })
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-[3px] top-[3px] rounded-full bg-violet-300 shadow-[0_0_0_1px_rgba(255,255,255,0.45),0_0_18px_rgba(167,139,250,0.65)] transition-colors"
|
||||
style={{ left: `${rail.leftPct}%`, width: `${rail.widthPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FloatingThumbnailStrip({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode
|
||||
label?: string
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 right-0" style={{ bottom: "calc(100% + 12px)" }}>
|
||||
<div ref={scrollRef} className="thumbnail-strip flex items-end gap-1.5 overflow-x-auto">
|
||||
{children}
|
||||
</div>
|
||||
<ThumbnailScrollRail scrollRef={scrollRef} label={label} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
1. InputNode — TK 链接 / 上传
|
||||
============================================================ */
|
||||
@@ -141,10 +349,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
{/* 多视频缩略图浮条 — 「+」在最左,job 按时间倒序(最新靠左高亮),统一高度 64,宽度按视频原比例,一行横滚。
|
||||
浮条宽度 = 节点宽度(节点拖宽后浮条同步变宽,可见更多缩略图,少滚动)。 */}
|
||||
{!videoExpanded && d.jobs.length > 0 && (
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
|
||||
style={{ bottom: "calc(100% + 12px)" }}
|
||||
>
|
||||
<FloatingThumbnailStrip label="输入视频缩略图横向滑动条">
|
||||
{/* + 再上传一个(放在最前面) */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -207,7 +412,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FloatingThumbnailStrip>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
@@ -549,10 +754,7 @@ export function VisualLabNode({ data, selected }: any) {
|
||||
return (
|
||||
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
{previews.length > 0 && (
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
|
||||
style={{ bottom: "calc(100% + 12px)" }}
|
||||
>
|
||||
<FloatingThumbnailStrip label="画面工作台缩略图横向滑动条">
|
||||
{previews.map((p) => {
|
||||
const isSelected = p.kind !== "video" && d.selectedFrames.has(p.frameIdx)
|
||||
return (
|
||||
@@ -706,7 +908,7 @@ export function VisualLabNode({ data, selected }: any) {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FloatingThumbnailStrip>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
@@ -808,10 +1010,7 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
<div ref={rootRef} className="relative" style={{ width: "100%", height: "100%" }}>
|
||||
{/* 缩略图浮条 — 单行横滚 + 固定高度,跟节点宽度对齐;超出横滚(视觉类节点统一规则) */}
|
||||
{frames.length > 0 && jobId && (
|
||||
<div
|
||||
className="absolute left-0 right-0 flex items-end gap-1.5 overflow-x-auto pb-1.5"
|
||||
style={{ bottom: "calc(100% + 12px)" }}
|
||||
>
|
||||
<FloatingThumbnailStrip label="关键帧缩略图横向滑动条">
|
||||
{frames.map((f) => {
|
||||
const isSel = d.selectedFrames.has(f.index)
|
||||
return (
|
||||
@@ -903,7 +1102,7 @@ export function KeyframeNode({ data, selected }: any) {
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</FloatingThumbnailStrip>
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
|
||||
Reference in New Issue
Block a user