Browse Source

多角度 拖拽问题

feature/071
yun 1 week ago
parent
commit
30118f2980
  1. 6
      src/app/globals.css
  2. 117
      src/components/MultiAngleEditor.tsx
  3. 9
      src/components/WorkflowCanvas.tsx
  4. 19
      src/components/__tests__/FloatingNodeHeader.test.tsx
  5. 33
      src/components/__tests__/MultiAngleEditor.test.tsx
  6. 3
      src/components/nodes/FloatingNodeHeader.tsx

6
src/app/globals.css

@ -181,6 +181,12 @@ body {
.react-flow__node { .react-flow__node {
font-family: inherit; font-family: inherit;
z-index: 2;
}
.react-flow__node.selected,
.react-flow__node.dragging {
z-index: 10000;
} }
/* Skip hit-testing on images inside nodes parent containers handle all /* Skip hit-testing on images inside nodes parent containers handle all

117
src/components/MultiAngleEditor.tsx

@ -2,7 +2,8 @@
import { import {
useCallback, useCallback,
type MouseEvent, useRef,
useState,
type PointerEvent, type PointerEvent,
type ReactNode, type ReactNode,
} from "react"; } from "react";
@ -41,6 +42,7 @@ export const MULTI_ANGLE_ZOOM_MAX = 10;
export const FISHEYE_MULTI_ANGLE_PROMPT = export const FISHEYE_MULTI_ANGLE_PROMPT =
"Ultra-close fisheye lens, strong ultra-wide perspective, circular frame edge, dark vignette, visible barrel distortion, enlarged subject center, stretched curved edges"; "Ultra-close fisheye lens, strong ultra-wide perspective, circular frame edge, dark vignette, visible barrel distortion, enlarged subject center, stretched curved edges";
export const TILT_MULTI_ANGLE_PROMPT = "Dutch angle, tilted frame"; export const TILT_MULTI_ANGLE_PROMPT = "Dutch angle, tilted frame";
const CUBE_DRAG_DEGREES_PER_PIXEL = 0.8;
const MULTI_ANGLE_PRESET_LABEL_KEYS: Record<MultiAnglePresetId, TranslationKey> = { const MULTI_ANGLE_PRESET_LABEL_KEYS: Record<MultiAnglePresetId, TranslationKey> = {
custom: "multiAngle.preset.custom", custom: "multiAngle.preset.custom",
@ -124,6 +126,10 @@ function roundToStep(value: number, precision = 1): number {
return Math.round(value * factor) / factor; return Math.round(value * factor) / factor;
} }
function normalizeHorizontalDragAngle(value: number): number {
return Math.round(((value % 360) + 360) % 360);
}
function getNextMultiAngleVertical(current: number, direction: -1 | 1): number { function getNextMultiAngleVertical(current: number, direction: -1 | 1): number {
return Math.round(clamp(current + direction, MULTI_ANGLE_VERTICAL_MIN, MULTI_ANGLE_VERTICAL_MAX)); return Math.round(clamp(current + direction, MULTI_ANGLE_VERTICAL_MIN, MULTI_ANGLE_VERTICAL_MAX));
} }
@ -178,6 +184,14 @@ export function MultiAngleEditor({
actions?: ReactNode; actions?: ReactNode;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [isCubeDragging, setIsCubeDragging] = useState(false);
const cubeDragRef = useRef<{
pointerId: number;
startX: number;
startY: number;
startHorizontal: number;
startVertical: number;
} | null>(null);
const previewScale = 0.58 + (settings.zoom / MULTI_ANGLE_ZOOM_MAX) * 0.64; const previewScale = 0.58 + (settings.zoom / MULTI_ANGLE_ZOOM_MAX) * 0.64;
const cubeRotateX = -settings.vertical; const cubeRotateX = -settings.vertical;
const cubeRotateY = settings.horizontal; const cubeRotateY = settings.horizontal;
@ -198,72 +212,62 @@ export function MultiAngleEditor({
onChange({ zoom: roundToStep(clamp(value, MULTI_ANGLE_ZOOM_MIN, MULTI_ANGLE_ZOOM_MAX), 1) }); onChange({ zoom: roundToStep(clamp(value, MULTI_ANGLE_ZOOM_MIN, MULTI_ANGLE_ZOOM_MAX), 1) });
}; };
const updateFromGlobePoint = useCallback(
(target: HTMLDivElement, clientX: number, clientY: number) => {
const rect = target.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const pointerY = clamp(1 - (clientY - rect.top) / rect.height, 0, 1);
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const rawAngle = (Math.atan2(clientY - centerY, clientX - centerX) * 180) / Math.PI;
const normalizedAngle = (90 - rawAngle + 360) % 360;
const horizontal = Math.round(clamp(normalizedAngle, MULTI_ANGLE_HORIZONTAL_MIN, MULTI_ANGLE_HORIZONTAL_MAX));
onChange({
horizontal,
vertical: Math.round(MULTI_ANGLE_VERTICAL_MIN + pointerY * (MULTI_ANGLE_VERTICAL_MAX - MULTI_ANGLE_VERTICAL_MIN)),
});
},
[onChange]
);
const handleGlobePointerDown = useCallback( const handleGlobePointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => { (event: PointerEvent<HTMLDivElement>) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
event.currentTarget.setPointerCapture?.(event.pointerId); event.currentTarget.setPointerCapture?.(event.pointerId);
updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY); cubeDragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startHorizontal: settings.horizontal,
startVertical: settings.vertical,
};
setIsCubeDragging(true);
}, },
[updateFromGlobePoint] [settings.horizontal, settings.vertical]
); );
const handleGlobePointerMove = useCallback( const handleGlobePointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => { (event: PointerEvent<HTMLDivElement>) => {
if ((event.buttons & 1) !== 1) return; const dragState = cubeDragRef.current;
if (!dragState || dragState.pointerId !== event.pointerId) return;
if (event.pointerType === "mouse" && (event.buttons & 1) !== 1) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY); const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
onChange({
horizontal: normalizeHorizontalDragAngle(dragState.startHorizontal + deltaX * CUBE_DRAG_DEGREES_PER_PIXEL),
vertical: Math.round(
clamp(
dragState.startVertical - deltaY * CUBE_DRAG_DEGREES_PER_PIXEL,
MULTI_ANGLE_VERTICAL_MIN,
MULTI_ANGLE_VERTICAL_MAX
)
),
});
}, },
[updateFromGlobePoint] [onChange]
); );
const handleGlobePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => { const handleGlobePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
event.stopPropagation(); event.stopPropagation();
event.currentTarget.releasePointerCapture?.(event.pointerId); event.currentTarget.releasePointerCapture?.(event.pointerId);
if (cubeDragRef.current?.pointerId === event.pointerId) {
cubeDragRef.current = null;
setIsCubeDragging(false);
}
}, []); }, []);
const handleGlobeMouseDown = useCallback( const handleGlobePointerCancel = useCallback((event: PointerEvent<HTMLDivElement>) => {
(event: MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY);
},
[updateFromGlobePoint]
);
const handleGlobeMouseMove = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
if ((event.buttons & 1) !== 1) return;
event.preventDefault();
event.stopPropagation();
updateFromGlobePoint(event.currentTarget, event.clientX, event.clientY);
},
[updateFromGlobePoint]
);
const handleGlobeMouseUp = useCallback((event: MouseEvent<HTMLDivElement>) => {
event.stopPropagation(); event.stopPropagation();
if (cubeDragRef.current?.pointerId === event.pointerId) {
cubeDragRef.current = null;
setIsCubeDragging(false);
}
}, []); }, []);
return ( return (
@ -303,9 +307,8 @@ export function MultiAngleEditor({
onPointerDown={handleGlobePointerDown} onPointerDown={handleGlobePointerDown}
onPointerMove={handleGlobePointerMove} onPointerMove={handleGlobePointerMove}
onPointerUp={handleGlobePointerUp} onPointerUp={handleGlobePointerUp}
onMouseDown={handleGlobeMouseDown} onPointerCancel={handleGlobePointerCancel}
onMouseMove={handleGlobeMouseMove} onLostPointerCapture={handleGlobePointerCancel}
onMouseUp={handleGlobeMouseUp}
> >
<button <button
type="button" type="button"
@ -350,12 +353,14 @@ export function MultiAngleEditor({
<div className="absolute inset-0 z-10 flex items-center justify-center px-8 py-10 [perspective:720px]"> <div className="absolute inset-0 z-10 flex items-center justify-center px-8 py-10 [perspective:720px]">
<div <div
className="relative h-[104px] w-[104px] transition-transform duration-150 [transform-style:preserve-3d]" className={`relative h-[120px] w-[120px] [transform-style:preserve-3d] ${
isCubeDragging ? "" : "transition-transform duration-150"
}`}
style={{ style={{
transform: `scale(${previewScale}) rotateX(${cubeRotateX}deg) rotateY(${cubeRotateY}deg)`, transform: `scale(${previewScale}) rotateX(${cubeRotateX}deg) rotateY(${cubeRotateY}deg)`,
}} }}
> >
<div className={`${cubeFaceClass} overflow-hidden border-[var(--accent)]/70 bg-[var(--surface-2)]/55 shadow-[0_18px_42px_rgba(0,0,0,0.42)] [transform:translateZ(52px)]`}> <div className={`${cubeFaceClass} overflow-hidden border-[var(--accent)]/70 bg-[var(--surface-2)]/55 shadow-[0_18px_42px_rgba(0,0,0,0.42)] [transform:translateZ(60px)]`}>
{previewImage ? ( {previewImage ? (
<img <img
src={previewImage} src={previewImage}
@ -368,19 +373,19 @@ export function MultiAngleEditor({
</div> </div>
)} )}
</div> </div>
<div className={`${cubeFaceClass} bg-[var(--surface-3)]/34 [transform:rotateY(90deg)_translateZ(52px)]`}> <div className={`${cubeFaceClass} bg-[var(--surface-3)]/34 [transform:rotateY(90deg)_translateZ(60px)]`}>
</div> </div>
<div className={`${cubeFaceClass} bg-[var(--surface-1)]/32 [transform:rotateY(-90deg)_translateZ(52px)]`}> <div className={`${cubeFaceClass} bg-[var(--surface-1)]/32 [transform:rotateY(-90deg)_translateZ(60px)]`}>
</div> </div>
<div className={`${cubeFaceClass} bg-[var(--surface-3)]/30 [transform:rotateX(90deg)_translateZ(52px)]`}> <div className={`${cubeFaceClass} bg-[var(--surface-3)]/30 [transform:rotateX(90deg)_translateZ(60px)]`}>
</div> </div>
<div className={`${cubeFaceClass} bg-[var(--surface-1)]/26 [transform:rotateX(-90deg)_translateZ(52px)]`}> <div className={`${cubeFaceClass} bg-[var(--surface-1)]/26 [transform:rotateX(-90deg)_translateZ(60px)]`}>
</div> </div>
<div className={`${cubeFaceClass} bg-[var(--surface-2)]/28 [transform:rotateY(180deg)_translateZ(52px)]`}> <div className={`${cubeFaceClass} bg-[var(--surface-2)]/28 [transform:rotateY(180deg)_translateZ(60px)]`}>
</div> </div>
</div> </div>

9
src/components/WorkflowCanvas.tsx

@ -3225,15 +3225,6 @@ export function WorkflowCanvas() {
const nodeTitle = getNodeTitle(node); const nodeTitle = getNodeTitle(node);
const mediaMeta = getNodeHeaderMediaMeta(node, nodeTitle, edges); const mediaMeta = getNodeHeaderMediaMeta(node, nodeTitle, edges);
const customTitle = typeof node.data?.customTitle === "string" ? node.data.customTitle : undefined; const customTitle = typeof node.data?.customTitle === "string" ? node.data.customTitle : undefined;
const isCompactGenerationHeader =
smartImageGenerate ||
smartAudioGenerate ||
smartVideoGenerate ||
smartTextGenerate ||
node.type === "nanoBanana" ||
node.type === "generateVideo" ||
node.type === "generateAudio" ||
node.type === "llmGenerate";
return ( return (
<FloatingNodeHeader <FloatingNodeHeader
key={`header-${node.id}`} key={`header-${node.id}`}

19
src/components/__tests__/FloatingNodeHeader.test.tsx

@ -38,8 +38,8 @@ describe("FloatingNodeHeader", () => {
expect(screen.getByText("496 x 864")).toBeInTheDocument(); expect(screen.getByText("496 x 864")).toBeInTheDocument();
}); });
it("keeps node titles below node tools and inline composer layers", () => { it("does not assign independent stacking to node titles", () => {
const { rerender } = render( render(
<FloatingNodeHeader <FloatingNodeHeader
id="image-1" id="image-1"
type={"imageInput" as NodeType} type={"imageInput" as NodeType}
@ -50,20 +50,7 @@ describe("FloatingNodeHeader", () => {
/> />
); );
expect(screen.getByText("Image Input").closest(".absolute")).toHaveStyle({ zIndex: "80" }); expect(screen.getByText("Image Input").closest(".absolute")).not.toHaveStyle({ zIndex: "10001" });
rerender(
<FloatingNodeHeader
id="image-1"
type={"imageInput" as NodeType}
position={{ x: 120, y: 220 }}
width={320}
selected
title="Image Input"
/>
);
expect(screen.getByText("Image Input").closest(".absolute")).toHaveStyle({ zIndex: "90" });
}); });
it("does not render a standalone run action in the floating toolbar", () => { it("does not render a standalone run action in the floating toolbar", () => {

33
src/components/__tests__/MultiAngleEditor.test.tsx

@ -95,26 +95,18 @@ describe("MultiAngleEditor", () => {
expect(screen.getByDisplayValue(/barrel distortion/)).toBeInTheDocument(); expect(screen.getByDisplayValue(/barrel distortion/)).toBeInTheDocument();
}); });
it("syncs globe pointer movement with custom parameters", async () => { it("syncs cube drag movement with custom parameters", async () => {
render(<MultiAngleHarness />); render(<MultiAngleHarness />);
const globe = screen.getByRole("application", { name: "Multi-angle sight control" }); const globe = screen.getByRole("application", { name: "Multi-angle sight control" });
globe.getBoundingClientRect = () => ({
x: 0,
y: 0,
top: 0,
left: 0,
right: 200,
bottom: 200,
width: 200,
height: 200,
toJSON: () => ({}),
});
fireEvent.mouseDown(globe, { clientX: 200, clientY: 100, buttons: 1 }); fireEvent.pointerDown(globe, { pointerId: 1, pointerType: "mouse", clientX: 100, clientY: 100, buttons: 1 });
fireEvent.pointerMove(globe, { pointerId: 1, pointerType: "mouse", clientX: 150, clientY: 80, buttons: 1 });
await waitFor(() => expect(screen.getByLabelText("Horizontal")).toHaveValue("40"));
expect(screen.getByLabelText("Vertical")).toHaveValue("-14");
await waitFor(() => expect(screen.getByLabelText("Horizontal")).toHaveValue("90")); fireEvent.pointerUp(globe, { pointerId: 1, pointerType: "mouse" });
expect(screen.getByLabelText("Vertical")).toHaveValue("15");
fireEvent.change(screen.getByLabelText("Horizontal"), { target: { value: "360" } }); fireEvent.change(screen.getByLabelText("Horizontal"), { target: { value: "360" } });
fireEvent.change(screen.getByLabelText("Vertical"), { target: { value: "57" } }); fireEvent.change(screen.getByLabelText("Vertical"), { target: { value: "57" } });
@ -125,6 +117,17 @@ describe("MultiAngleEditor", () => {
expect(globe).toHaveAttribute("data-zoom", "8.6"); expect(globe).toHaveAttribute("data-zoom", "8.6");
}); });
it("wraps horizontal cube drag across zero degrees", async () => {
render(<MultiAngleHarness />);
const globe = screen.getByRole("application", { name: "Multi-angle sight control" });
fireEvent.pointerDown(globe, { pointerId: 1, pointerType: "mouse", clientX: 100, clientY: 100, buttons: 1 });
fireEvent.pointerMove(globe, { pointerId: 1, pointerType: "mouse", clientX: 80, clientY: 100, buttons: 1 });
await waitFor(() => expect(screen.getByLabelText("Horizontal")).toHaveValue("344"));
});
it("builds a platform-neutral multi-angle prompt", () => { it("builds a platform-neutral multi-angle prompt", () => {
const prompt = buildMultiAnglePrompt({ const prompt = buildMultiAnglePrompt({
...DEFAULT_MULTI_ANGLE_SETTINGS, ...DEFAULT_MULTI_ANGLE_SETTINGS,

3
src/components/nodes/FloatingNodeHeader.tsx

@ -14,8 +14,6 @@ export interface NodeHeaderMediaMeta {
} }
const EXPANDABLE_TYPES = new Set(['prompt', 'promptConstructor', 'annotation']); const EXPANDABLE_TYPES = new Set(['prompt', 'promptConstructor', 'annotation']);
const FLOATING_NODE_HEADER_Z_INDEX = 80;
const FLOATING_NODE_HEADER_SELECTED_Z_INDEX = 90;
function HeaderMediaIcon({ kind }: { kind: NodeHeaderMediaMeta["kind"] }) { function HeaderMediaIcon({ kind }: { kind: NodeHeaderMediaMeta["kind"] }) {
if (kind === "video") { if (kind === "video") {
@ -298,7 +296,6 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
left: `${position.x}px`, left: `${position.x}px`,
top: `${position.y - 26}px`, top: `${position.y - 26}px`,
width: `${width}px`, width: `${width}px`,
zIndex: selected ? FLOATING_NODE_HEADER_SELECTED_Z_INDEX : FLOATING_NODE_HEADER_Z_INDEX,
}} }}
> >
<div <div

Loading…
Cancel
Save