Refine LibTV preset canvas interactions
This commit is contained in:
parent
cfd48b6b3b
commit
e04288bc88
@ -32,6 +32,7 @@ import {
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
type WheelEvent as ReactWheelEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@ -234,6 +235,9 @@ const TOOLBAR_ITEMS: Array<{ id: Exclude<ActivePanel, null>; label: string; icon
|
||||
];
|
||||
|
||||
const DEFAULT_VIEWPORT = { x: 0, y: 0, zoom: 1 };
|
||||
const PRESET_VIEWPORT = { x: 144, y: 100, zoom: 0.77 };
|
||||
const PRESET_WORKFLOW_CENTER = { x: 1024, y: 512 };
|
||||
const CANVAS_EXTENT = 20000;
|
||||
|
||||
const TARGET_AVATAR =
|
||||
"https://liblibai-online.liblib.cloud/web/1656e2f1b3c74f1ba823b610f1b2cbdf/0c3eb5e2-1dc0-405c-a823-07b2570237f8.jpg?x-oss-process=image/resize,w_100,m_lfit/format,webp";
|
||||
@ -753,27 +757,27 @@ function createStarterWorkflow(
|
||||
|
||||
if (starterTitle === "角色三视图") {
|
||||
const preset = "character-three-view";
|
||||
const distance = 370;
|
||||
const distance = 100;
|
||||
const left = createPresetMediaNode({
|
||||
action: "IMAGE_RESOURCE",
|
||||
height: 446,
|
||||
height: 520,
|
||||
mediaType: "image",
|
||||
mediaUrl: CHARACTER_IMAGE_URL,
|
||||
preset,
|
||||
title: "角色图",
|
||||
width: 734,
|
||||
x: Math.round(center.x - 734 - distance / 2),
|
||||
width: 874,
|
||||
x: Math.round(center.x - 874 - distance / 2),
|
||||
y: Math.round(center.y - 320),
|
||||
});
|
||||
const right = createPresetMediaNode({
|
||||
action: "IMAGE_GENERATE",
|
||||
generated: true,
|
||||
height: 446,
|
||||
height: 520,
|
||||
mediaType: "image",
|
||||
mediaUrl: CHARACTER_THREE_VIEW_URL,
|
||||
preset,
|
||||
title: "角色三视图",
|
||||
width: 556,
|
||||
width: 660,
|
||||
x: Math.round(center.x + distance / 2),
|
||||
y: Math.round(center.y - 320),
|
||||
});
|
||||
@ -1121,14 +1125,11 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
|
||||
const createStarter = useCallback(
|
||||
(starter: (typeof STARTER_CARDS)[number]) => {
|
||||
const center =
|
||||
typeof window === "undefined"
|
||||
? { x: 1000, y: 520 }
|
||||
: toCanvasPoint(window.innerWidth / 2, window.innerHeight / 2);
|
||||
const workflow = createStarterWorkflow(starter.title, center);
|
||||
const workflow = createStarterWorkflow(starter.title, PRESET_WORKFLOW_CENTER);
|
||||
if (workflow) {
|
||||
addNodes(workflow.nodes, workflow.edges);
|
||||
setSelectedNodeId(workflow.selectedId);
|
||||
setViewport(PRESET_VIEWPORT);
|
||||
setCanvasCommand(null);
|
||||
setNodeContextMenu(null);
|
||||
setActivePanel(null);
|
||||
@ -1156,7 +1157,7 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
}));
|
||||
addNodes(newNodes, newEdges);
|
||||
},
|
||||
[addNodes, toCanvasPoint]
|
||||
[addNodes]
|
||||
);
|
||||
|
||||
const updateNodeDraft = useCallback((key: string, value: unknown) => {
|
||||
@ -1253,6 +1254,37 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const zoomAtPoint = useCallback((delta: number, clientX: number, clientY: number) => {
|
||||
setViewport((current) => {
|
||||
const nextZoom = Math.min(2.4, Math.max(0.3, Number((current.zoom + delta).toFixed(2))));
|
||||
if (nextZoom === current.zoom) return current;
|
||||
const worldX = (clientX - current.x) / current.zoom;
|
||||
const worldY = (clientY - current.y) / current.zoom;
|
||||
return {
|
||||
x: Math.round(clientX - worldX * nextZoom),
|
||||
y: Math.round(clientY - worldY * nextZoom),
|
||||
zoom: nextZoom,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onCanvasWheel = useCallback(
|
||||
(event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
if ((event.target as HTMLElement).closest("[data-canvas-overlay]")) return;
|
||||
event.preventDefault();
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
zoomAtPoint(event.deltaY > 0 ? -0.08 : 0.08, event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
setViewport((current) => ({
|
||||
...current,
|
||||
x: Math.round(current.x - event.deltaX),
|
||||
y: Math.round(current.y - event.deltaY),
|
||||
}));
|
||||
},
|
||||
[zoomAtPoint]
|
||||
);
|
||||
|
||||
const copyShareLink = useCallback(async () => {
|
||||
const url =
|
||||
typeof window === "undefined"
|
||||
@ -1280,13 +1312,14 @@ export function LiblibVideoCanvasReplica({ canvasId, isLoading = false }: Liblib
|
||||
onPointerDown={startCanvasDrag}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onWheel={onCanvasWheel}
|
||||
ref={canvasRef}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle,rgba(255,255,255,0.18)_1px,transparent_1px)] bg-[length:16px_16px] bg-[position:2px_2px] opacity-[0.28]" />
|
||||
|
||||
<div
|
||||
className="absolute left-0 top-0 h-[4000px] w-[4000px] origin-top-left"
|
||||
style={{ transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})` }}
|
||||
className="absolute left-0 top-0 origin-top-left"
|
||||
style={{ height: CANVAS_EXTENT, transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`, width: CANVAS_EXTENT }}
|
||||
>
|
||||
<CloneEdges edges={edges} nodes={nodes} />
|
||||
{nodes.map((node) => (
|
||||
@ -1976,7 +2009,7 @@ function CharacterPresetToolbar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[90px] flex h-[72px] -translate-x-1/2 items-center gap-6 rounded-[18px] border border-white/8 bg-[#262626]/96 px-7 text-[18px] text-white/82 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]">
|
||||
<div className="pointer-events-auto absolute left-1/2 top-[168px] flex h-[58px] -translate-x-1/2 items-center gap-5 rounded-[14px] border border-white/8 bg-[#262626]/96 px-5 text-[16px] text-white/82 shadow-[0_20px_64px_rgba(0,0,0,0.42)] backdrop-blur-[10px]">
|
||||
<SceneTool active={toolbarMenu === "panorama"} kind="panorama" label="全景" badge="NEW" onClick={() => setToolbarMenu((current) => current === "panorama" ? null : "panorama")} />
|
||||
<SceneTool active={toolbarMenu === "orbit"} kind="orbit" label="多角度" onClick={() => setToolbarMenu((current) => current === "orbit" ? null : "orbit")} />
|
||||
<SceneTool active={toolbarMenu === "lighting"} kind="lighting" label="打光" onClick={() => setToolbarMenu((current) => current === "lighting" ? null : "lighting")} />
|
||||
@ -2291,8 +2324,14 @@ function PresetComposerPanel({
|
||||
thumbnailUrl?: string;
|
||||
title: string;
|
||||
}) {
|
||||
const [draftPrompt, setDraftPrompt] = useState(prompt);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftPrompt(prompt);
|
||||
}, [prompt]);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto absolute bottom-[46px] left-1/2 h-[238px] w-[972px] -translate-x-1/2 rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
|
||||
<div className="pointer-events-auto absolute bottom-[64px] left-1/2 h-[238px] w-[895px] -translate-x-1/2 rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
|
||||
<button className="absolute right-5 top-5 text-[26px] text-white/45" onClick={() => toast.info("展开提示词编辑器待接入。")} type="button">↗</button>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<span className="rounded-[10px] border border-white/10 bg-[#292929] px-3 py-2 text-[14px] text-white/62">{title}</span>
|
||||
@ -2302,7 +2341,12 @@ function PresetComposerPanel({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[20px] text-white/54">{prompt}</div>
|
||||
<textarea
|
||||
className="h-[78px] w-full resize-none bg-transparent text-[20px] leading-8 text-white/64 outline-none placeholder:text-white/38"
|
||||
onChange={(event) => setDraftPrompt(event.target.value)}
|
||||
placeholder="描述你想要生成的画面内容,按 / 呼出指令,@引用素材"
|
||||
value={draftPrompt}
|
||||
/>
|
||||
<div className="absolute bottom-5 left-5 right-5 flex items-center gap-6 text-[17px] text-white/78">
|
||||
<ComposerControl label="Lib Nano Pro" icon="◉" dropdown onClick={() => toast.info("模型选择接口待接入。")} />
|
||||
<ComposerControl label="16:9" icon="▭" dropdown onClick={() => toast.info("比例选择接口待接入。")} />
|
||||
@ -2385,12 +2429,12 @@ function SceneTool({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn("flex h-10 items-center gap-2 rounded-[8px] px-1 text-white/82 hover:bg-white/8", active && "bg-white/10 text-white")}
|
||||
className={cn("flex h-10 items-center gap-2 whitespace-nowrap rounded-[8px] px-1 text-white/82 hover:bg-white/8", active && "bg-white/10 text-white")}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<SceneToolIcon kind={kind} />
|
||||
<span>{label}</span>
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
{badge ? (
|
||||
<span className="rounded-[7px] bg-[#0e6371] px-2 py-0.5 text-[14px] font-bold tracking-wide text-[#42eaff]">
|
||||
{badge}
|
||||
@ -2528,9 +2572,10 @@ function CharacterImagePanel({ imageUrl, label, wide = false }: { imageUrl: stri
|
||||
|
||||
function PromptComposer() {
|
||||
const [menu, setMenu] = useState<ComposerMenu>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto absolute bottom-[46px] left-1/2 h-[310px] w-[972px] -translate-x-1/2 rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
|
||||
<div className="pointer-events-auto absolute bottom-[64px] left-1/2 h-[286px] w-[895px] -translate-x-1/2 rounded-[18px] border border-white/8 bg-[#262626]/98 p-5 text-white shadow-[0_24px_84px_rgba(0,0,0,0.48)]">
|
||||
<button className="absolute right-5 top-5 text-[26px] text-white/45" onClick={() => toast.info("展开提示词编辑器待接入。")} type="button">↗</button>
|
||||
<div className="flex items-center gap-3">
|
||||
{[
|
||||
@ -2553,9 +2598,12 @@ function PromptComposer() {
|
||||
</div>
|
||||
{menu ? <ComposerPopover menu={menu} /> : null}
|
||||
|
||||
<div className="mt-6 text-[22px] text-white/46">
|
||||
描述你想要生成的画面内容,按 / 呼出指令,@引用素材
|
||||
</div>
|
||||
<textarea
|
||||
className="mt-5 h-[86px] w-full resize-none bg-transparent text-[20px] leading-8 text-white/64 outline-none placeholder:text-white/42"
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="描述你想要生成的画面内容,按 / 呼出指令,@引用素材"
|
||||
value={prompt}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-5 left-5 right-5 flex items-center gap-6 text-[18px] text-white/78">
|
||||
<ComposerControl active={menu === "model"} label="Lib Nano Pro" icon="◉" dropdown onClick={() => setMenu((current) => current === "model" ? null : "model")} />
|
||||
@ -2839,7 +2887,7 @@ function NodeContextMenu({
|
||||
function CloneEdges({ edges, nodes }: { edges: CloneEdge[]; nodes: CloneNode[] }) {
|
||||
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
|
||||
return (
|
||||
<svg className="pointer-events-none absolute left-0 top-0 h-[4000px] w-[4000px] overflow-visible">
|
||||
<svg className="pointer-events-none absolute left-0 top-0 overflow-visible" style={{ height: CANVAS_EXTENT, width: CANVAS_EXTENT }}>
|
||||
{edges.map((edge) => {
|
||||
const source = nodeMap.get(edge.source);
|
||||
const target = nodeMap.get(edge.target);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user