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,
output: "standalone",
experimental: {
proxyClientMaxBodySize: "32mb",
serverActions: {
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()
: `canvas-media-${Date.now()}.${extension}`;
const fileName = rawFileName.includes(".") ? rawFileName : `${rawFileName}.${extension}`;
const upstream = await fetch(getPopiBaseUrl(request) + POPI_MEDIA_UPLOAD_BASE64_PATH, {
method: "POST",
headers: {

59
src/components/GroupsOverlay.tsx

@ -30,8 +30,58 @@ interface GroupBackgroundProps {
// Renders just the group background - displayed below nodes (z-index 1)
function GroupBackground({ groupId }: GroupBackgroundProps) {
const { groups } = useWorkflowStore();
const { groups, updateGroup, moveGroupNodes } = useWorkflowStore();
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;
@ -39,7 +89,8 @@ function GroupBackground({ groupId }: GroupBackgroundProps) {
return (
<div
className="absolute rounded-xl"
className="absolute rounded-xl cursor-grab active:cursor-grabbing"
onMouseDown={handleBackgroundMouseDown}
style={{
left: group.position.x,
top: group.position.y,
@ -47,7 +98,7 @@ function GroupBackground({ groupId }: GroupBackgroundProps) {
height: group.size.height,
backgroundColor: `${bgColor}60`,
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 (
<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) => (
<GroupBackground key={groupId} groupId={groupId} />
))}

25
src/components/WorkflowCanvas.tsx

@ -2586,29 +2586,14 @@ export function WorkflowCanvas() {
fitViewOptions={FIT_VIEW_OPTIONS}
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift"
selectionOnDrag={
canvasNavigationSettings.selectionMode === "altDrag" || canvasNavigationSettings.selectionMode === "shiftDrag"
? false
: canvasNavigationSettings.panMode === "always"
? false
: isMacOSClient && !isModalOpen
}
selectionKeyCode={
isModalOpen ? null
: canvasNavigationSettings.selectionMode === "altDrag" ? "Alt"
: canvasNavigationSettings.selectionMode === "shiftDrag" ? "Shift"
: "Shift"
}
selectionOnDrag={tutorialActive ? false : !isModalOpen}
selectionKeyCode={null}
panOnDrag={
tutorialActive
? false
: isModalOpen
? false
: canvasNavigationSettings.panMode === "always"
? true
: canvasNavigationSettings.panMode === "middleMouse"
? [2]
: !isMacOSClient
: false
}
selectNodesOnDrag={false}
nodeDragThreshold={5}
@ -2623,9 +2608,7 @@ export function WorkflowCanvas() {
? null
: isModalOpen
? null
: canvasNavigationSettings.panMode === "space"
? "Space"
: null
: "Space"
}
nodesDraggable={!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");
});
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 originalInnerWidth = window.innerWidth;
Object.defineProperty(window, "innerHeight", { configurable: true, value: 500 });
@ -400,7 +400,7 @@ describe("GenerationComposer", () => {
expect(screen.getByTestId("node-inline-composer")).toHaveStyle({
left: "16px",
top: "104px",
top: "536px",
});
Object.defineProperty(window, "innerHeight", { configurable: true, value: originalInnerHeight });
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalInnerWidth });
@ -1187,7 +1187,6 @@ describe("GenerationComposer", () => {
});
render(<GenerationComposer />);
expect(screen.getByText("Video Stitch")).toBeInTheDocument();
expect(screen.getByText("2 video clips")).toBeInTheDocument();
expect(screen.getByText("Video inputs")).toBeInTheDocument();
expect(screen.getByText("Clip 1")).toBeInTheDocument();
@ -1224,7 +1223,6 @@ describe("GenerationComposer", () => {
});
render(<GenerationComposer />);
expect(screen.getByText("Ease Curve")).toBeInTheDocument();
expect(screen.getByText("Video connected")).toBeInTheDocument();
expect(screen.getByText("Curve settings")).toBeInTheDocument();
expect(screen.getByText("easeInOutSine")).toBeInTheDocument();

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

@ -147,7 +147,7 @@ describe("GroupBackgroundsPortal", () => {
expect(style).toContain("border");
});
it("should set pointerEvents to none on backgrounds", () => {
it("should make backgrounds interactive for group dragging", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({
groups: { "group-1": createMockGroup() },
@ -156,7 +156,32 @@ describe("GroupBackgroundsPortal", () => {
const { container } = render(<GroupBackgroundsPortal />);
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_EXPANDED_WIDTH = 640;
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];
interface SchemaSelectOption {
@ -1408,16 +1406,10 @@ export function GenerationComposer() {
);
const safeZoom = zoom > 0 ? zoom : 1;
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 visibleRight = (viewportWidth - viewportX) / safeZoom;
const visibleTop = -viewportY / safeZoom;
const visibleBottom = (viewportHeight - viewportY) / safeZoom;
const composerWidth = isComposerCollapsed ? COMPOSER_COLLAPSED_WIDTH : COMPOSER_EXPANDED_WIDTH;
const composerFlowWidth = composerWidth / safeZoom;
const composerHeightEstimate = isComposerCollapsed
? COMPOSER_COLLAPSED_HEIGHT_ESTIMATE
: COMPOSER_EXPANDED_HEIGHT_ESTIMATE;
const viewportMargin = COMPOSER_VIEWPORT_MARGIN / safeZoom;
const inlineComposerPosition = context.node && selectedNodeDimensions
? (() => {
@ -1428,13 +1420,10 @@ export function GenerationComposer() {
? Math.min(Math.max(centeredLeft, minLeft), maxLeft)
: minLeft;
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 {
left,
top: belowFits || !aboveFits ? belowTop : aboveTop,
top: belowTop,
};
})()
: null;
@ -1996,11 +1985,6 @@ export function GenerationComposer() {
) : (
<>
<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 ? (
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">

8
src/components/nodes/AnnotationNode.tsx

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

8
src/components/nodes/ImageInputNode.tsx

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

2
src/i18n/index.tsx

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

Loading…
Cancel
Save