Files
Genarrative/src/components/image-editor/ImageCanvasGenerationPlacementModel.ts
T
JenkenB 8956ed6fe6 Unify canvas generation placement and UI changes
Add unified placement and UI updates for canvas generation: docs require ImageCanvasGenerationPlacementModel to compute placeholder positions (avoid overlaps, center viewport on placement) and limit frontend video model entries. Refactor UI components and tests: expose div props on PlatformFloatingMenu, make PlatformInlineOptionButton forwardRef, replace inline placeholder actions with ImageCanvasGenerationImageOptionsView in basic generation composer, enhance character animation panel with parameter menu portal and option choices, add reference chip UI, and update many tests to match new interactions and labels. These changes align generation workflows, accessibility labels, and positioning behavior across editor panels.
2026-06-18 11:40:19 +08:00

278 lines
6.7 KiB
TypeScript

import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
type CanvasSize = { width: number; height: number };
type GenerationFrame = NonNullable<GenerateDialogState['placeholder']>;
type Rect = { x: number; y: number; width: number; height: number };
const GENERATION_PLACEMENT_GAP = 32;
const MAX_PLACEMENT_RING = 12;
function getViewportWorldCenter({
canvasSize,
viewport,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
}) {
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
return {
x: (canvasSize.width / 2 - viewport.x) / safeScale,
y: (canvasSize.height / 2 - viewport.y) / safeScale,
};
}
function expandRect(rect: Rect, gap: number): Rect {
return {
x: rect.x - gap,
y: rect.y - gap,
width: rect.width + gap * 2,
height: rect.height + gap * 2,
};
}
function rectsOverlap(a: Rect, b: Rect) {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
);
}
function distanceSquared(
a: { x: number; y: number },
b: { x: number; y: number },
) {
const dx = a.x - b.x;
const dy = a.y - b.y;
return dx * dx + dy * dy;
}
function buildBlockingRects({
layers,
generationDialogs,
}: {
layers: CanvasLayer[];
generationDialogs: CanvasGenerationDialogState[];
}) {
return [
...layers
.filter((layer) => !layer.hidden)
.map((layer) => ({
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
})),
...generationDialogs.flatMap((dialog) =>
dialog.placeholder
? [
{
x: dialog.placeholder.x,
y: dialog.placeholder.y,
width: dialog.placeholder.width,
height: dialog.placeholder.height,
},
]
: [],
),
].map((rect) => expandRect(rect, GENERATION_PLACEMENT_GAP));
}
function isFree(candidate: Rect, blockingRects: Rect[]) {
return !blockingRects.some((rect) => rectsOverlap(candidate, rect));
}
function createCandidate({
center,
frame,
offsetX,
offsetY,
}: {
center: { x: number; y: number };
frame: Pick<GenerationFrame, 'width' | 'height'>;
offsetX: number;
offsetY: number;
}): Rect {
return {
x: center.x - frame.width / 2 + offsetX,
y: center.y - frame.height / 2 + offsetY,
width: frame.width,
height: frame.height,
};
}
function pushUniqueCandidate(candidates: Rect[], candidate: Rect) {
if (
candidates.some(
(current) => current.x === candidate.x && current.y === candidate.y,
)
) {
return;
}
candidates.push(candidate);
}
function buildPlacementCandidates({
center,
frame,
blockingRects,
}: {
center: { x: number; y: number };
frame: Pick<GenerationFrame, 'width' | 'height'>;
blockingRects: Rect[];
}) {
const baseX = center.x - frame.width / 2;
const baseY = center.y - frame.height / 2;
const stepX = frame.width + GENERATION_PLACEMENT_GAP;
const stepY = frame.height + GENERATION_PLACEMENT_GAP;
const candidates: Rect[] = [];
pushUniqueCandidate(candidates, {
x: baseX,
y: baseY,
width: frame.width,
height: frame.height,
});
blockingRects.forEach((rect) => {
[
{ x: rect.x + rect.width, y: baseY },
{ x: baseX, y: rect.y + rect.height },
{ x: rect.x - frame.width, y: baseY },
{ x: baseX, y: rect.y - frame.height },
{ x: rect.x + rect.width, y: rect.y + rect.height },
{ x: rect.x - frame.width, y: rect.y + rect.height },
{ x: rect.x + rect.width, y: rect.y - frame.height },
{ x: rect.x - frame.width, y: rect.y - frame.height },
].forEach((candidate) =>
pushUniqueCandidate(candidates, {
...candidate,
width: frame.width,
height: frame.height,
}),
);
});
// 组合不同阻挡物的水平 / 垂直边界,覆盖中心被多块图层夹住时的最近空位。
const edgeXs = new Set<number>([baseX]);
const edgeYs = new Set<number>([baseY]);
blockingRects.forEach((rect) => {
edgeXs.add(rect.x + rect.width);
edgeXs.add(rect.x - frame.width);
edgeYs.add(rect.y + rect.height);
edgeYs.add(rect.y - frame.height);
});
edgeXs.forEach((x) => {
edgeYs.forEach((y) => {
pushUniqueCandidate(candidates, {
x,
y,
width: frame.width,
height: frame.height,
});
});
});
for (let ring = 1; ring <= MAX_PLACEMENT_RING; ring += 1) {
const offsets = [
{ x: ring, y: 0 },
{ x: 0, y: ring },
{ x: -ring, y: 0 },
{ x: 0, y: -ring },
{ x: ring, y: ring },
{ x: -ring, y: ring },
{ x: ring, y: -ring },
{ x: -ring, y: -ring },
];
offsets.forEach((offset) =>
pushUniqueCandidate(
candidates,
createCandidate({
center,
frame,
offsetX: offset.x * stepX,
offsetY: offset.y * stepY,
}),
),
);
}
return candidates.sort((a, b) => {
const aDistance = distanceSquared(
{ x: a.x + a.width / 2, y: a.y + a.height / 2 },
center,
);
const bDistance = distanceSquared(
{ x: b.x + b.width / 2, y: b.y + b.height / 2 },
center,
);
if (aDistance !== bDistance) {
return aDistance - bDistance;
}
const aDownBias = a.y >= baseY ? 0 : 1;
const bDownBias = b.y >= baseY ? 0 : 1;
if (aDownBias !== bDownBias) {
return aDownBias - bDownBias;
}
if (a.x !== b.x) {
return a.x - b.x;
}
return a.y - b.y;
});
}
export function chooseGenerationPlacement({
canvasSize,
viewport,
frame,
layers,
generationDialogs,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
frame: GenerationFrame;
layers: CanvasLayer[];
generationDialogs: CanvasGenerationDialogState[];
}): GenerationFrame {
const center = getViewportWorldCenter({ canvasSize, viewport });
const blockingRects = buildBlockingRects({ layers, generationDialogs });
const candidates = buildPlacementCandidates({ center, frame, blockingRects });
const fallbackPlacement = createCandidate({
center,
frame,
offsetX: 0,
offsetY: 0,
});
const placement =
candidates.find((candidate) => isFree(candidate, blockingRects)) ??
fallbackPlacement;
return {
...frame,
x: placement.x,
y: placement.y,
};
}
export function centerViewportOnPlacement({
canvasSize,
viewport,
placement,
}: {
canvasSize: CanvasSize;
viewport: CanvasViewport;
placement: GenerationFrame;
}): CanvasViewport {
const scale = viewport.scale > 0 ? viewport.scale : 1;
return {
x: canvasSize.width / 2 - (placement.x + placement.width / 2) * scale,
y: canvasSize.height / 2 - (placement.y + placement.height / 2) * scale,
scale,
};
}