feat(canvas): enhance mixer node functionality with overlay dimensions and cropping support
This commit is contained in:
@@ -543,6 +543,74 @@ describe("useCanvasConnections", () => {
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects self-drops on a note instead of auto-splitting its incoming edge", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const runSplitEdgeAtExistingNodeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
<HookHarness
|
||||
helperResult={{
|
||||
sourceNodeId: "node-note",
|
||||
targetNodeId: "node-note",
|
||||
sourceHandle: undefined,
|
||||
targetHandle: undefined,
|
||||
}}
|
||||
runCreateEdgeMutation={runCreateEdgeMutation}
|
||||
runSplitEdgeAtExistingNodeMutation={runSplitEdgeAtExistingNodeMutation}
|
||||
showConnectionRejectedToast={showConnectionRejectedToast}
|
||||
nodes={[
|
||||
{ id: "node-image", type: "image", position: { x: 0, y: 0 }, data: {} },
|
||||
{ id: "node-note", type: "note", position: { x: 240, y: 120 }, data: {} },
|
||||
]}
|
||||
edges={[
|
||||
{
|
||||
id: "edge-image-note",
|
||||
source: "node-image",
|
||||
target: "node-note",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
latestHandlersRef.current?.onConnectStart?.(
|
||||
{} as MouseEvent,
|
||||
{
|
||||
nodeId: "node-note",
|
||||
handleId: null,
|
||||
handleType: "source",
|
||||
} as never,
|
||||
);
|
||||
latestHandlersRef.current?.onConnectEnd(
|
||||
{ clientX: 260, clientY: 160 } as MouseEvent,
|
||||
{
|
||||
isValid: false,
|
||||
from: { x: 0, y: 0 },
|
||||
fromNode: { id: "node-note", type: "note" },
|
||||
fromHandle: { id: null, type: "source" },
|
||||
fromPosition: null,
|
||||
to: { x: 260, y: 160 },
|
||||
toHandle: null,
|
||||
toNode: null,
|
||||
toPosition: null,
|
||||
pointer: null,
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
expect(runSplitEdgeAtExistingNodeMutation).not.toHaveBeenCalled();
|
||||
expect(runCreateEdgeMutation).not.toHaveBeenCalled();
|
||||
expect(showConnectionRejectedToast).toHaveBeenCalledWith("self-loop");
|
||||
expect(latestHandlersRef.current?.connectionDropMenu).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects text to ai-video body drops", async () => {
|
||||
const runCreateEdgeMutation = vi.fn(async () => undefined);
|
||||
const showConnectionRejectedToast = vi.fn();
|
||||
|
||||
@@ -414,7 +414,7 @@ export function useCanvasConnections({
|
||||
!isOptimisticEdgeId(edge.id),
|
||||
);
|
||||
const incomingEdge = incomingEdges.length === 1 ? incomingEdges[0] : undefined;
|
||||
const splitValidationError =
|
||||
const shouldAttemptAutoSplit =
|
||||
validationError === "adjustment-incoming-limit" &&
|
||||
droppedConnection.sourceNodeId === fromNode.id &&
|
||||
fromHandle.type === "source" &&
|
||||
@@ -424,7 +424,8 @@ export function useCanvasConnections({
|
||||
hasHandleKey(splitHandles, "target") &&
|
||||
incomingEdge !== undefined &&
|
||||
incomingEdge.source !== fullFromNode.id &&
|
||||
incomingEdge.target !== fullFromNode.id
|
||||
incomingEdge.target !== fullFromNode.id;
|
||||
const splitValidationError = shouldAttemptAutoSplit
|
||||
? validateCanvasEdgeSplit({
|
||||
nodes: nodesRef.current,
|
||||
edges: edgesRef.current,
|
||||
@@ -433,7 +434,21 @@ export function useCanvasConnections({
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!splitValidationError && incomingEdge && fullFromNode && splitHandles) {
|
||||
logCanvasConnectionDebug("connect:end-auto-split-eval", {
|
||||
point: pt,
|
||||
flow,
|
||||
droppedConnection,
|
||||
validationError,
|
||||
shouldAttemptAutoSplit,
|
||||
splitValidationError,
|
||||
fromNodeId: fromNode.id,
|
||||
fromNodeType: fullFromNode?.type ?? null,
|
||||
incomingEdgeId: incomingEdge?.id ?? null,
|
||||
incomingEdgeSourceNodeId: incomingEdge?.source ?? null,
|
||||
incomingEdgeTargetNodeId: incomingEdge?.target ?? null,
|
||||
});
|
||||
|
||||
if (shouldAttemptAutoSplit && !splitValidationError && incomingEdge && fullFromNode && splitHandles) {
|
||||
logCanvasConnectionDebug("connect:end-auto-split", {
|
||||
point: pt,
|
||||
flow,
|
||||
|
||||
92
docs/plans/2026-04-14-mixer-resize-crop-design.md
Normal file
92
docs/plans/2026-04-14-mixer-resize-crop-design.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Mixer Resize/Crop Design
|
||||
|
||||
**Goal:** Make mixer overlay resize behave like proportional image scaling, and make crop behave like classic edge-based trimming without changing displayed image size.
|
||||
|
||||
## Approved Interaction Model
|
||||
|
||||
- `Resize` changes the displayed overlay size only.
|
||||
- `Resize` keeps aspect ratio locked.
|
||||
- `Crop` changes only the visible source region.
|
||||
- `Crop` does not change the displayed overlay frame size.
|
||||
- `Crop` uses 8 handles: 4 corners and 4 side-midpoints.
|
||||
- Dragging inside the crop box repositions the crop region.
|
||||
|
||||
## Conceptual Split
|
||||
|
||||
### 1. Display Frame
|
||||
|
||||
Controls where and how large the overlay appears in the mixer.
|
||||
|
||||
- `overlayX`
|
||||
- `overlayY`
|
||||
- `overlayWidth`
|
||||
- `overlayHeight`
|
||||
|
||||
These fields represent the displayed overlay frame in mixer preview/output space.
|
||||
|
||||
### 2. Source Crop Region
|
||||
|
||||
Controls which part of the source image is shown inside that frame.
|
||||
|
||||
Recommended crop contract:
|
||||
|
||||
- `cropLeft`
|
||||
- `cropTop`
|
||||
- `cropRight`
|
||||
- `cropBottom`
|
||||
|
||||
All values are normalized source-image trims from the corresponding edge.
|
||||
|
||||
Why this model:
|
||||
|
||||
- Left handle changes only `cropLeft`
|
||||
- Top handle changes only `cropTop`
|
||||
- Corner handles combine two crop edges
|
||||
- The mental model exactly matches "take content away from edges"
|
||||
|
||||
## Rendering Semantics
|
||||
|
||||
Preview, compare, and bake must all use the same mapping:
|
||||
|
||||
1. Resolve source image.
|
||||
2. Apply crop trims to derive the sampled source rect.
|
||||
3. Draw that sampled rect into the displayed overlay frame.
|
||||
|
||||
This removes the ambiguous zoom-like behavior from crop mode.
|
||||
|
||||
## UX Rules
|
||||
|
||||
### Resize Mode
|
||||
|
||||
- Handles are anchored to the display frame.
|
||||
- Corner drag scales proportionally.
|
||||
- Side handles are either hidden or mapped to proportional scaling from the nearest axis while preserving aspect ratio.
|
||||
- Resize never mutates crop fields.
|
||||
|
||||
### Crop Mode
|
||||
|
||||
- Handles are anchored to the crop box.
|
||||
- Edge handles trim one side.
|
||||
- Corner handles trim two sides.
|
||||
- Drag inside crop box repositions the crop window.
|
||||
- Crop never mutates display frame size.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Minimum display size must keep handles usable.
|
||||
- Minimum crop region must prevent inverted or zero-area crop boxes.
|
||||
- Crop box stays within source bounds.
|
||||
- Display frame stays within mixer preview bounds.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Existing mixer nodes with `contentX/Y/Width/Height` need a migration/default path.
|
||||
- If a direct field migration is too risky, normalization can temporarily map legacy content fields into equivalent crop trims.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- rotation
|
||||
- masks
|
||||
- free distortion
|
||||
- multi-layer cropping
|
||||
- standalone crop modal
|
||||
198
docs/plans/2026-04-14-mixer-resize-crop.md
Normal file
198
docs/plans/2026-04-14-mixer-resize-crop.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# Mixer Resize/Crop Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Correct mixer interactions so resize scales the overlay proportionally while crop trims visible content from any side without changing displayed image size.
|
||||
|
||||
**Architecture:** Split displayed overlay geometry from source crop geometry. Keep `overlayX/Y/Width/Height` for display frame placement and size. Introduce explicit crop-edge semantics so preview, compare, and bake all trim the same source region and map it into the unchanged frame.
|
||||
|
||||
**Tech Stack:** Next.js 16, React 19, `@xyflow/react`, Vitest, local node preview state via `useNodeLocalData`, image pipeline source-loader.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add failing tests for the approved resize/crop semantics
|
||||
|
||||
**Files:**
|
||||
- Modify: `components/canvas/__tests__/mixer-node.test.tsx`
|
||||
- Modify: `tests/image-pipeline/source-loader.test.ts`
|
||||
- Modify: `tests/lib/canvas-mixer-preview.test.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Add tests that prove:
|
||||
|
||||
- frame resize keeps aspect ratio locked
|
||||
- crop handle drag trims edges without changing displayed overlay frame size
|
||||
- crop drag inside crop box repositions crop region only
|
||||
- resize does not mutate crop fields
|
||||
- crop does not mutate `overlayWidth` / `overlayHeight`
|
||||
|
||||
**Step 2: Run tests to verify RED**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run components/canvas/__tests__/mixer-node.test.tsx tests/image-pipeline/source-loader.test.ts tests/lib/canvas-mixer-preview.test.ts
|
||||
```
|
||||
|
||||
Expected: failures showing current crop behavior still behaves like zoom/scale instead of edge trimming.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add components/canvas/__tests__/mixer-node.test.tsx tests/image-pipeline/source-loader.test.ts tests/lib/canvas-mixer-preview.test.ts
|
||||
git commit -m "test(canvas): cover mixer resize and crop semantics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Replace zoom-like content fields with crop-edge normalization
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/canvas-mixer-preview.ts`
|
||||
- Modify: `lib/canvas-utils.ts`
|
||||
- Modify: `lib/canvas-node-templates.ts`
|
||||
- Modify: `components/canvas/nodes/mixer-node.tsx`
|
||||
|
||||
**Step 1: Implement minimal normalized crop model**
|
||||
|
||||
Prefer explicit crop trims:
|
||||
|
||||
```ts
|
||||
type MixerCropData = {
|
||||
cropLeft: number;
|
||||
cropTop: number;
|
||||
cropRight: number;
|
||||
cropBottom: number;
|
||||
};
|
||||
```
|
||||
|
||||
Normalization rules:
|
||||
|
||||
- clamp each crop edge to `0..1`
|
||||
- enforce minimum remaining source width/height
|
||||
- preserve display frame fields separately
|
||||
- map legacy `contentX/Y/Width/Height` into equivalent crop trims during normalization if needed
|
||||
|
||||
**Step 2: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/lib/canvas-mixer-preview.test.ts
|
||||
```
|
||||
|
||||
Expected: GREEN for normalization and backward-compatibility cases.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add lib/canvas-mixer-preview.ts lib/canvas-utils.ts lib/canvas-node-templates.ts components/canvas/nodes/mixer-node.tsx tests/lib/canvas-mixer-preview.test.ts
|
||||
git commit -m "feat(canvas): add explicit mixer crop edge model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Fix mixer node interactions
|
||||
|
||||
**Files:**
|
||||
- Modify: `components/canvas/nodes/mixer-node.tsx`
|
||||
- Modify: `components/canvas/__tests__/mixer-node.test.tsx`
|
||||
|
||||
**Step 1: Implement proportional resize**
|
||||
|
||||
- use display frame aspect ratio as the locked ratio
|
||||
- corner drag scales frame proportionally
|
||||
- side handles either hide in resize mode or preserve ratio while scaling
|
||||
- resize mutates only `overlay*`
|
||||
|
||||
**Step 2: Implement classic crop handles**
|
||||
|
||||
- render 8 crop handles in crop mode
|
||||
- edge handles trim one side
|
||||
- corner handles trim two sides
|
||||
- dragging inside crop box repositions crop region
|
||||
- crop mutates only crop fields
|
||||
|
||||
**Step 3: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run components/canvas/__tests__/mixer-node.test.tsx
|
||||
```
|
||||
|
||||
Expected: GREEN for resize and crop semantics.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add components/canvas/nodes/mixer-node.tsx components/canvas/__tests__/mixer-node.test.tsx
|
||||
git commit -m "feat(canvas): separate mixer resize and crop interactions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Align compare and bake semantics
|
||||
|
||||
**Files:**
|
||||
- Modify: `components/canvas/nodes/compare-surface.tsx`
|
||||
- Modify: `lib/image-pipeline/source-loader.ts`
|
||||
- Modify: `tests/image-pipeline/source-loader.test.ts`
|
||||
- Modify: `tests/lib/canvas-render-preview.test.ts`
|
||||
- Optional modify: `components/canvas/__tests__/compare-node.test.tsx`
|
||||
|
||||
**Step 1: Implement crop-edge sampling everywhere**
|
||||
|
||||
- compare preview uses crop edges, not zoom-like content scaling
|
||||
- bake path samples cropped source region into overlay frame
|
||||
- non-mixer behavior stays unchanged
|
||||
|
||||
**Step 2: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run tests/image-pipeline/source-loader.test.ts tests/lib/canvas-render-preview.test.ts components/canvas/__tests__/compare-node.test.tsx
|
||||
```
|
||||
|
||||
Expected: GREEN with preview/bake parity.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add components/canvas/nodes/compare-surface.tsx lib/image-pipeline/source-loader.ts tests/image-pipeline/source-loader.test.ts tests/lib/canvas-render-preview.test.ts components/canvas/__tests__/compare-node.test.tsx
|
||||
git commit -m "fix(canvas): align mixer crop semantics across preview and bake"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Final verification
|
||||
|
||||
**Files:**
|
||||
- Modify only if docs or small follow-up fixes are needed
|
||||
|
||||
**Step 1: Run the verification suite**
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run components/canvas/__tests__/mixer-node.test.tsx tests/lib/canvas-mixer-preview.test.ts tests/lib/canvas-render-preview.test.ts tests/image-pipeline/source-loader.test.ts components/canvas/__tests__/compare-node.test.tsx
|
||||
pnpm lint
|
||||
pnpm build
|
||||
```
|
||||
|
||||
Expected: all green.
|
||||
|
||||
**Step 2: Commit docs/follow-ups if needed**
|
||||
|
||||
```bash
|
||||
git add components/canvas/CLAUDE.md convex/CLAUDE.md
|
||||
git commit -m "docs(canvas): document mixer resize and crop semantics"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Keep `nodrag` and `nopan` on every interactive surface and handle.
|
||||
- Prefer the smallest migration path from legacy `content*` fields into crop trims.
|
||||
- Do not broaden into rotation, masks, or non-uniform scaling.
|
||||
Reference in New Issue
Block a user