auto-save 2026-05-14 02:36 (~2)

This commit is contained in:
2026-05-14 02:36:34 +08:00
parent 95fbb0cbc6
commit 2b7eb003c4
2 changed files with 228 additions and 16 deletions

View File

@@ -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
}
]
}

View File

@@ -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>
)}
{(() => {