Browse Source

Merge branch 'feature/interaction' of https://git.yuanzoo.cn/weitao/popiart-node-canvas into feature/interaction

feature/group
huangmin 1 month ago
parent
commit
e900a8ea37
  1. 1
      next.config.ts
  2. 1
      src/app/api/popi/media/upload/route.ts
  3. 59
      src/components/GroupsOverlay.tsx
  4. 25
      src/components/WorkflowCanvas.tsx
  5. 6
      src/components/__tests__/GenerationComposer.test.tsx
  6. 29
      src/components/__tests__/GroupsOverlay.test.tsx
  7. 18
      src/components/composer/GenerationComposer.tsx
  8. 8
      src/components/nodes/AnnotationNode.tsx
  9. 8
      src/components/nodes/ImageInputNode.tsx
  10. 2
      src/i18n/index.tsx

1
next.config.ts

@ -4,6 +4,7 @@ const nextConfig: NextConfig = {
reactStrictMode: true, reactStrictMode: true,
output: "standalone", output: "standalone",
experimental: { experimental: {
proxyClientMaxBodySize: "32mb",
serverActions: { serverActions: {
bodySizeLimit: "100mb", // Increased for large media files bodySizeLimit: "100mb", // Increased for large media files
}, },

1
src/app/api/popi/media/upload/route.ts

@ -189,7 +189,6 @@ export async function POST(request: NextRequest) {
? body.fileName.trim() ? body.fileName.trim()
: `canvas-media-${Date.now()}.${extension}`; : `canvas-media-${Date.now()}.${extension}`;
const fileName = rawFileName.includes(".") ? rawFileName : `${rawFileName}.${extension}`; const fileName = rawFileName.includes(".") ? rawFileName : `${rawFileName}.${extension}`;
const upstream = await fetch(getPopiBaseUrl(request) + POPI_MEDIA_UPLOAD_BASE64_PATH, { const upstream = await fetch(getPopiBaseUrl(request) + POPI_MEDIA_UPLOAD_BASE64_PATH, {
method: "POST", method: "POST",
headers: { headers: {

59
src/components/GroupsOverlay.tsx

@ -30,8 +30,58 @@ interface GroupBackgroundProps {
// Renders just the group background - displayed below nodes (z-index 1) // Renders just the group background - displayed below nodes (z-index 1)
function GroupBackground({ groupId }: GroupBackgroundProps) { function GroupBackground({ groupId }: GroupBackgroundProps) {
const { groups } = useWorkflowStore(); const { groups, updateGroup, moveGroupNodes } = useWorkflowStore();
const group = groups[groupId]; const group = groups[groupId];
const { zoom } = useViewport();
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const handleBackgroundMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, y: e.clientY };
},
[]
);
useEffect(() => {
if (!isDragging || !group) return;
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current) return;
const deltaX = (e.clientX - dragStartRef.current.x) / zoom;
const deltaY = (e.clientY - dragStartRef.current.y) / zoom;
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
updateGroup(groupId, {
position: {
x: group.position.x + deltaX,
y: group.position.y + deltaY,
},
});
moveGroupNodes(groupId, { x: deltaX, y: deltaY });
dragStartRef.current = { x: e.clientX, y: e.clientY };
}
};
const handleMouseUp = () => {
setIsDragging(false);
dragStartRef.current = null;
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging, group, groupId, moveGroupNodes, updateGroup, zoom]);
if (!group) return null; if (!group) return null;
@ -39,7 +89,8 @@ function GroupBackground({ groupId }: GroupBackgroundProps) {
return ( return (
<div <div
className="absolute rounded-xl" className="absolute rounded-xl cursor-grab active:cursor-grabbing"
onMouseDown={handleBackgroundMouseDown}
style={{ style={{
left: group.position.x, left: group.position.x,
top: group.position.y, top: group.position.y,
@ -47,7 +98,7 @@ function GroupBackground({ groupId }: GroupBackgroundProps) {
height: group.size.height, height: group.size.height,
backgroundColor: `${bgColor}60`, backgroundColor: `${bgColor}60`,
border: group.isNbpInput ? `3px dashed rgba(255,255,255,0.25)` : `1px solid ${bgColor}`, border: group.isNbpInput ? `3px dashed rgba(255,255,255,0.25)` : `1px solid ${bgColor}`,
pointerEvents: "none", pointerEvents: "auto",
}} }}
/> />
); );
@ -531,7 +582,7 @@ export function GroupBackgroundsPortal() {
return ( return (
<ViewportPortal> <ViewportPortal>
<div style={{ position: "absolute", top: 0, left: 0, zIndex: -1, pointerEvents: "none" }}> <div style={{ position: "absolute", top: 0, left: 0, zIndex: -1, pointerEvents: "auto" }}>
{groupIds.map((groupId) => ( {groupIds.map((groupId) => (
<GroupBackground key={groupId} groupId={groupId} /> <GroupBackground key={groupId} groupId={groupId} />
))} ))}

25
src/components/WorkflowCanvas.tsx

@ -2586,29 +2586,14 @@ export function WorkflowCanvas() {
fitViewOptions={FIT_VIEW_OPTIONS} fitViewOptions={FIT_VIEW_OPTIONS}
deleteKeyCode={["Backspace", "Delete"]} deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift" multiSelectionKeyCode="Shift"
selectionOnDrag={ selectionOnDrag={tutorialActive ? false : !isModalOpen}
canvasNavigationSettings.selectionMode === "altDrag" || canvasNavigationSettings.selectionMode === "shiftDrag" selectionKeyCode={null}
? false
: canvasNavigationSettings.panMode === "always"
? false
: isMacOSClient && !isModalOpen
}
selectionKeyCode={
isModalOpen ? null
: canvasNavigationSettings.selectionMode === "altDrag" ? "Alt"
: canvasNavigationSettings.selectionMode === "shiftDrag" ? "Shift"
: "Shift"
}
panOnDrag={ panOnDrag={
tutorialActive tutorialActive
? false ? false
: isModalOpen : isModalOpen
? false ? false
: canvasNavigationSettings.panMode === "always" : false
? true
: canvasNavigationSettings.panMode === "middleMouse"
? [2]
: !isMacOSClient
} }
selectNodesOnDrag={false} selectNodesOnDrag={false}
nodeDragThreshold={5} nodeDragThreshold={5}
@ -2623,9 +2608,7 @@ export function WorkflowCanvas() {
? null ? null
: isModalOpen : isModalOpen
? null ? null
: canvasNavigationSettings.panMode === "space" : "Space"
? "Space"
: null
} }
nodesDraggable={!isModalOpen} nodesDraggable={!isModalOpen}
nodesConnectable={!isModalOpen} nodesConnectable={!isModalOpen}

6
src/components/__tests__/GenerationComposer.test.tsx

@ -381,7 +381,7 @@ describe("GenerationComposer", () => {
expect(screen.getByPlaceholderText(/Describe what you want to generate/)).toHaveClass("h-36"); expect(screen.getByPlaceholderText(/Describe what you want to generate/)).toHaveClass("h-36");
}); });
it("places the selected node editor above the node when below would leave the viewport", () => { it("keeps the selected node editor below the node when below would leave the viewport", () => {
const originalInnerHeight = window.innerHeight; const originalInnerHeight = window.innerHeight;
const originalInnerWidth = window.innerWidth; const originalInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 }); Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 });
@ -400,7 +400,7 @@ describe("GenerationComposer", () => {
expect(screen.getByTestId("node-inline-composer")).toHaveStyle({ expect(screen.getByTestId("node-inline-composer")).toHaveStyle({
left: "16px", left: "16px",
top: "104px", top: "536px",
}); });
Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight }); Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth }); Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
@ -1187,7 +1187,6 @@ describe("GenerationComposer", () => {
}); });
render(<GenerationComposer />); render(<GenerationComposer />);
expect(screen.getByText("Video Stitch")).toBeInTheDocument();
expect(screen.getByText("2 video clips")).toBeInTheDocument(); expect(screen.getByText("2 video clips")).toBeInTheDocument();
expect(screen.getByText("Video inputs")).toBeInTheDocument(); expect(screen.getByText("Video inputs")).toBeInTheDocument();
expect(screen.getByText("Clip 1")).toBeInTheDocument(); expect(screen.getByText("Clip 1")).toBeInTheDocument();
@ -1224,7 +1223,6 @@ describe("GenerationComposer", () => {
}); });
render(<GenerationComposer />); render(<GenerationComposer />);
expect(screen.getByText("Ease Curve")).toBeInTheDocument();
expect(screen.getByText("Video connected")).toBeInTheDocument(); expect(screen.getByText("Video connected")).toBeInTheDocument();
expect(screen.getByText("Curve settings")).toBeInTheDocument(); expect(screen.getByText("Curve settings")).toBeInTheDocument();
expect(screen.getByText("easeInOutSine")).toBeInTheDocument(); expect(screen.getByText("easeInOutSine")).toBeInTheDocument();

29
src/components/__tests__/GroupsOverlay.test.tsx

@ -147,7 +147,7 @@ describe("GroupBackgroundsPortal", () => {
expect(style).toContain("border"); expect(style).toContain("border");
}); });
it("should set pointerEvents to none on backgrounds", () => { it("should make backgrounds interactive for group dragging", () => {
mockUseWorkflowStore.mockImplementation((selector) => { mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({ return selector(createDefaultState({
groups: { "group-1": createMockGroup() }, groups: { "group-1": createMockGroup() },
@ -156,7 +156,32 @@ describe("GroupBackgroundsPortal", () => {
const { container } = render(<GroupBackgroundsPortal />); const { container } = render(<GroupBackgroundsPortal />);
const background = container.querySelector(".rounded-xl"); const background = container.querySelector(".rounded-xl");
expect(background).toHaveStyle({ pointerEvents: "none" }); expect(background).toHaveStyle({ pointerEvents: "auto" });
});
it("should drag a group from its background", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
groups: { "group-1": createMockGroup() },
}));
});
const { container } = render(<GroupBackgroundsPortal />);
const background = container.querySelector(".rounded-xl") as HTMLElement;
fireEvent.mouseDown(background, { button: 0, clientX: 100, clientY: 100 });
act(() => {
fireEvent.mouseMove(window, { clientX: 130, clientY: 120 });
});
expect(mockUpdateGroup).toHaveBeenCalledWith("group-1", {
position: { x: 130, y: 120 },
});
expect(mockMoveGroupNodes).toHaveBeenCalledWith("group-1", { x: 30, y: 20 });
act(() => {
fireEvent.mouseUp(window);
});
}); });
}); });
}); });

18
src/components/composer/GenerationComposer.tsx

@ -124,8 +124,6 @@ const MAX_REFERENCE_IMAGE_BYTES = 10 * 1024 * 1024;
const COMPOSER_VIEWPORT_MARGIN = 16; const COMPOSER_VIEWPORT_MARGIN = 16;
const COMPOSER_EXPANDED_WIDTH = 640; const COMPOSER_EXPANDED_WIDTH = 640;
const COMPOSER_COLLAPSED_WIDTH = 560; const COMPOSER_COLLAPSED_WIDTH = 560;
const COMPOSER_EXPANDED_HEIGHT_ESTIMATE = 300;
const COMPOSER_COLLAPSED_HEIGHT_ESTIMATE = 80;
const DEFAULT_IMAGE_COUNT_OPTIONS: ImageGenerationCount[] = [1, 2, 4]; const DEFAULT_IMAGE_COUNT_OPTIONS: ImageGenerationCount[] = [1, 2, 4];
interface SchemaSelectOption { interface SchemaSelectOption {
@ -1408,16 +1406,10 @@ export function GenerationComposer() {
); );
const safeZoom = zoom > 0 ? zoom : 1; const safeZoom = zoom > 0 ? zoom : 1;
const viewportWidth = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerWidth; const viewportWidth = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerWidth;
const viewportHeight = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerHeight;
const visibleLeft = -viewportX / safeZoom; const visibleLeft = -viewportX / safeZoom;
const visibleRight = (viewportWidth - viewportX) / safeZoom; const visibleRight = (viewportWidth - viewportX) / safeZoom;
const visibleTop = -viewportY / safeZoom;
const visibleBottom = (viewportHeight - viewportY) / safeZoom;
const composerWidth = isComposerCollapsed ? COMPOSER_COLLAPSED_WIDTH : COMPOSER_EXPANDED_WIDTH; const composerWidth = isComposerCollapsed ? COMPOSER_COLLAPSED_WIDTH : COMPOSER_EXPANDED_WIDTH;
const composerFlowWidth = composerWidth / safeZoom; const composerFlowWidth = composerWidth / safeZoom;
const composerHeightEstimate = isComposerCollapsed
? COMPOSER_COLLAPSED_HEIGHT_ESTIMATE
: COMPOSER_EXPANDED_HEIGHT_ESTIMATE;
const viewportMargin = COMPOSER_VIEWPORT_MARGIN / safeZoom; const viewportMargin = COMPOSER_VIEWPORT_MARGIN / safeZoom;
const inlineComposerPosition = context.node && selectedNodeDimensions const inlineComposerPosition = context.node && selectedNodeDimensions
? (() => { ? (() => {
@ -1428,13 +1420,10 @@ export function GenerationComposer() {
? Math.min(Math.max(centeredLeft, minLeft), maxLeft) ? Math.min(Math.max(centeredLeft, minLeft), maxLeft)
: minLeft; : minLeft;
const belowTop = context.node.position.y + selectedNodeDimensions.height + viewportMargin; const belowTop = context.node.position.y + selectedNodeDimensions.height + viewportMargin;
const aboveTop = context.node.position.y - composerHeightEstimate / safeZoom - viewportMargin;
const belowFits = belowTop + composerHeightEstimate / safeZoom <= visibleBottom - viewportMargin;
const aboveFits = aboveTop >= visibleTop + viewportMargin;
return { return {
left, left,
top: belowFits || !aboveFits ? belowTop : aboveTop, top: belowTop,
}; };
})() })()
: null; : null;
@ -1996,11 +1985,6 @@ export function GenerationComposer() {
) : ( ) : (
<> <>
<div className="flex items-center gap-2 px-4 pt-4"> <div className="flex items-center gap-2 px-4 pt-4">
<div className="mr-1 flex h-14 min-w-24 flex-col justify-center rounded-lg border border-neutral-700 bg-neutral-900/60 px-3">
<span className="text-[10px] text-neutral-500">{t("composer.console")}</span>
<span className="text-xs font-medium text-neutral-100">{modeLabel}</span>
</div>
{isProcessNode ? ( {isProcessNode ? (
isVideoStitchNode ? ( isVideoStitchNode ? (
<div className="flex h-14 items-center gap-3 rounded-lg border border-neutral-700 bg-neutral-900/50 px-3 text-xs text-neutral-300"> <div className="flex h-14 items-center gap-3 rounded-lg border border-neutral-700 bg-neutral-900/50 px-3 text-xs text-neutral-300">

8
src/components/nodes/AnnotationNode.tsx

@ -32,10 +32,10 @@ export function AnnotationNode({ id, data, selected }: NodeProps<AnnotationNodeT
return; return;
} }
if (file.size > 10 * 1024 * 1024) { // if (file.size > 10 * 1024 * 1024) {
alert(t("upload.imageTooLarge")); // alert(t("upload.imageTooLarge"));
return; // return;
} // }
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {

8
src/components/nodes/ImageInputNode.tsx

@ -135,10 +135,10 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
return; return;
} }
if (file.size > 10 * 1024 * 1024) { // if (file.size > 10 * 1024 * 1024) {
alert(t("upload.imageTooLarge")); // alert(t("upload.imageTooLarge"));
return; // return;
} // }
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {

2
src/i18n/index.tsx

@ -592,7 +592,6 @@ const en = {
"composer.uploadReferenceImage": "Upload reference image", "composer.uploadReferenceImage": "Upload reference image",
"composer.expand": "Expand composer", "composer.expand": "Expand composer",
"composer.collapse": "Collapse composer", "composer.collapse": "Collapse composer",
"composer.console": "Console",
"composer.style": "Style", "composer.style": "Style",
"composer.mark": "Mark", "composer.mark": "Mark",
"composer.focus": "Focus", "composer.focus": "Focus",
@ -1422,7 +1421,6 @@ const zhCN: Dictionary = {
"composer.uploadReferenceImage": "上传参考图片", "composer.uploadReferenceImage": "上传参考图片",
"composer.expand": "展开底部栏", "composer.expand": "展开底部栏",
"composer.collapse": "收起底部栏", "composer.collapse": "收起底部栏",
"composer.console": "控制台",
"composer.style": "风格", "composer.style": "风格",
"composer.mark": "标记", "composer.mark": "标记",
"composer.focus": "聚焦", "composer.focus": "聚焦",

Loading…
Cancel
Save