Merge branch 'master' into showcase-liked
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import '@genarrative/image-canvas-react/styles.css';
|
||||
import './index.css';
|
||||
|
||||
import { StrictMode, Suspense } from 'react';
|
||||
|
||||
@@ -67,6 +67,17 @@ describe('ImageCanvasBottomToolbarView', () => {
|
||||
const user = userEvent.setup();
|
||||
const { toolbar, switchTool } = renderToolbar();
|
||||
|
||||
expect(toolbar.className).toContain('genarrative-image-canvas__toolbar');
|
||||
expect(toolbar.className).toContain(
|
||||
'genarrative-image-canvas__toolbar--floating',
|
||||
);
|
||||
expect(
|
||||
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-group'),
|
||||
).toHaveLength(12);
|
||||
expect(
|
||||
toolbar.querySelectorAll('.genarrative-image-canvas__toolbar-divider'),
|
||||
).toHaveLength(2);
|
||||
|
||||
const toolExpectations = [
|
||||
['选择工具', 'select'],
|
||||
['抓手工具', 'hand'],
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
CanvasToolbar,
|
||||
CanvasToolbarDivider,
|
||||
CanvasToolbarGroup,
|
||||
} from '@genarrative/image-canvas-react';
|
||||
import {
|
||||
AppWindow,
|
||||
Clapperboard,
|
||||
@@ -79,27 +84,22 @@ export function ImageCanvasBottomToolbarView({
|
||||
onCloseToolOptions,
|
||||
}: ImageCanvasBottomToolbarViewProps) {
|
||||
return (
|
||||
<div
|
||||
<CanvasToolbar
|
||||
className={[
|
||||
'image-canvas-editor__bottom-toolbar',
|
||||
highlight ? 'image-canvas-editor__bottom-toolbar--guided' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="toolbar"
|
||||
aria-label="AI画布工具栏"
|
||||
label="AI画布工具栏"
|
||||
surface="floating"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{canvasTools.map(({ id, label, icon: Icon, separatorBefore }) => {
|
||||
if (isToolbarOptionTool(id)) {
|
||||
return (
|
||||
<span key={id} className="image-canvas-editor__bottom-tool-group">
|
||||
{separatorBefore ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__bottom-toolbar-divider"
|
||||
/>
|
||||
) : null}
|
||||
<CanvasToolbarGroup key={id}>
|
||||
{separatorBefore ? <CanvasToolbarDivider /> : null}
|
||||
<span
|
||||
ref={
|
||||
id === 'spec'
|
||||
@@ -126,22 +126,19 @@ export function ImageCanvasBottomToolbarView({
|
||||
label={label}
|
||||
title={label}
|
||||
icon={Icon}
|
||||
pressed={hasPersistentPressedState(id) && effectiveTool === id}
|
||||
pressed={
|
||||
hasPersistentPressedState(id) && effectiveTool === id
|
||||
}
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</CanvasToolbarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={id} className="image-canvas-editor__bottom-tool-group">
|
||||
{separatorBefore ? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__bottom-toolbar-divider"
|
||||
/>
|
||||
) : null}
|
||||
<CanvasToolbarGroup key={id}>
|
||||
{separatorBefore ? <CanvasToolbarDivider /> : null}
|
||||
<EditorIconButton
|
||||
label={label}
|
||||
title={label}
|
||||
@@ -149,9 +146,9 @@ export function ImageCanvasBottomToolbarView({
|
||||
pressed={hasPersistentPressedState(id) && effectiveTool === id}
|
||||
onClick={() => onSwitchTool(id)}
|
||||
/>
|
||||
</span>
|
||||
</CanvasToolbarGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CanvasToolbar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
canvasDisplayScaleToViewportScale,
|
||||
clamp,
|
||||
resolveSnappedItemPosition,
|
||||
viewportScaleToCanvasDisplayScale,
|
||||
} from '@genarrative/image-canvas-core';
|
||||
|
||||
import {
|
||||
EDITOR_SOUND_EFFECT_MODEL,
|
||||
SOUND_EFFECT_DURATION_MAX_SECONDS,
|
||||
@@ -19,19 +26,41 @@ import type {
|
||||
CanvasGenerationInputs,
|
||||
CanvasLayer,
|
||||
CanvasMediaType,
|
||||
CanvasSnapItem,
|
||||
CanvasViewport,
|
||||
CharacterReferenceImage,
|
||||
EditorAsset,
|
||||
EditorAssetFolder,
|
||||
PerfectPixelOperationSnapshot,
|
||||
SnapCandidate,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
hydrateCanvasGenerationInputs,
|
||||
hydrateCanvasGenerationInputsResult,
|
||||
} from './ImageCanvasGenerationInputsModel';
|
||||
|
||||
export {
|
||||
CANVAS_DISPLAY_SCALE_BASE,
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
CANVAS_WORLD_SIZE,
|
||||
canvasDisplayScaleToViewportScale,
|
||||
clamp,
|
||||
DEFAULT_CANVAS_SIZE,
|
||||
findNearestSnap,
|
||||
FIT_VIEW_PADDING,
|
||||
formatCanvasDisplayScalePercent,
|
||||
formatPercent,
|
||||
getLayerBounds,
|
||||
MAX_HISTORY_STEPS,
|
||||
MAX_SCALE,
|
||||
MIN_SCALE,
|
||||
MINIMAP_DRAG_SENSITIVITY,
|
||||
MINIMAP_PADDING,
|
||||
MINIMAP_SIZE,
|
||||
resolveSnappedItemPosition,
|
||||
SNAP_DISTRIBUTION_OVERLAP_TOLERANCE,
|
||||
SNAP_THRESHOLD_SCREEN_PX,
|
||||
viewportScaleToCanvasDisplayScale,
|
||||
} from '@genarrative/image-canvas-core';
|
||||
|
||||
export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
|
||||
{
|
||||
id: 'project',
|
||||
@@ -42,20 +71,7 @@ export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const CANVAS_WORLD_SIZE = 12000;
|
||||
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
|
||||
export const MIN_SCALE = 0.025;
|
||||
export const MAX_SCALE = 3.2;
|
||||
export const CANVAS_DISPLAY_SCALE_BASE = 0.5;
|
||||
export const TOOLBAR_HALF_WIDTH = 132;
|
||||
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
|
||||
export const SNAP_THRESHOLD_SCREEN_PX = 18;
|
||||
export const SNAP_DISTRIBUTION_OVERLAP_TOLERANCE = 1;
|
||||
const SNAP_DISTRIBUTION_PAIR_LOOKAHEAD = 3;
|
||||
export const FIT_VIEW_PADDING = 10;
|
||||
export const MINIMAP_SIZE = { width: 132, height: 84 };
|
||||
export const MINIMAP_PADDING = 8;
|
||||
export const MINIMAP_DRAG_SENSITIVITY = 0.3;
|
||||
export const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset';
|
||||
const INTERNAL_EDITOR_PROCESSING_MODELS = new Set([
|
||||
'anime-seg',
|
||||
@@ -75,7 +91,6 @@ export function isEditorInternalProcessingModel(
|
||||
);
|
||||
}
|
||||
|
||||
export const MAX_HISTORY_STEPS = 60;
|
||||
export const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
|
||||
export const CONTEXT_MENU_SIZE = {
|
||||
blank: { width: 188, height: 176 },
|
||||
@@ -106,29 +121,6 @@ export function normalizeCanvasBackgroundHex(value: string) {
|
||||
return `#${hexValue}`;
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function formatPercent(value: number) {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
export function formatCanvasDisplayScalePercent(scale: number) {
|
||||
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
||||
return formatPercent(safeScale / CANVAS_DISPLAY_SCALE_BASE);
|
||||
}
|
||||
|
||||
export function canvasDisplayScaleToViewportScale(displayScale: number) {
|
||||
const safeDisplayScale = Number.isFinite(displayScale) ? displayScale : 1;
|
||||
return safeDisplayScale * CANVAS_DISPLAY_SCALE_BASE;
|
||||
}
|
||||
|
||||
export function viewportScaleToCanvasDisplayScale(scale: number) {
|
||||
const safeScale = Number.isFinite(scale) ? scale : CANVAS_DISPLAY_SCALE_BASE;
|
||||
return safeScale / CANVAS_DISPLAY_SCALE_BASE;
|
||||
}
|
||||
|
||||
export function viewportToCanvasDisplayViewport(
|
||||
viewport: CanvasViewport,
|
||||
): CanvasViewport {
|
||||
@@ -2380,27 +2372,6 @@ function hydrateGenerationPlaceholder(
|
||||
};
|
||||
}
|
||||
|
||||
export function getLayerBounds(targetLayers: CanvasLayer[]) {
|
||||
if (targetLayers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return targetLayers.reduce(
|
||||
(current, layer) => ({
|
||||
minX: Math.min(current.minX, layer.x),
|
||||
minY: Math.min(current.minY, layer.y),
|
||||
maxX: Math.max(current.maxX, layer.x + layer.width),
|
||||
maxY: Math.max(current.maxY, layer.y + layer.height),
|
||||
}),
|
||||
{
|
||||
minX: Number.POSITIVE_INFINITY,
|
||||
minY: Number.POSITIVE_INFINITY,
|
||||
maxX: Number.NEGATIVE_INFINITY,
|
||||
maxY: Number.NEGATIVE_INFINITY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSnappedLayerPosition(
|
||||
movingLayer: CanvasLayer,
|
||||
proposedX: number,
|
||||
@@ -2416,268 +2387,3 @@ export function resolveSnappedLayerPosition(
|
||||
scale,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSnappedItemPosition(
|
||||
movingItem: CanvasSnapItem,
|
||||
proposedX: number,
|
||||
proposedY: number,
|
||||
items: CanvasSnapItem[],
|
||||
scale: number,
|
||||
) {
|
||||
const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE);
|
||||
const visibleItems = items.filter(
|
||||
(item) => item.id !== movingItem.id && !item.hidden,
|
||||
);
|
||||
const verticalTargets = [
|
||||
0,
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
...visibleItems.flatMap((item) => [
|
||||
item.x,
|
||||
item.x + item.width / 2,
|
||||
item.x + item.width,
|
||||
]),
|
||||
];
|
||||
const horizontalTargets = [
|
||||
0,
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
...visibleItems.flatMap((item) => [
|
||||
item.y,
|
||||
item.y + item.height / 2,
|
||||
item.y + item.height,
|
||||
]),
|
||||
];
|
||||
|
||||
const xAlignmentSnap = findNearestSnap(
|
||||
proposedX,
|
||||
[0, movingItem.width / 2, movingItem.width],
|
||||
verticalTargets,
|
||||
threshold,
|
||||
);
|
||||
const yAlignmentSnap = findNearestSnap(
|
||||
proposedY,
|
||||
[0, movingItem.height / 2, movingItem.height],
|
||||
horizontalTargets,
|
||||
threshold,
|
||||
);
|
||||
const xDistributionSnap = findNearestEqualSpacingSnap({
|
||||
axis: 'x',
|
||||
proposedStart: proposedX,
|
||||
proposedCrossStart: proposedY,
|
||||
movingSize: movingItem.width,
|
||||
movingCrossSize: movingItem.height,
|
||||
items: visibleItems,
|
||||
threshold,
|
||||
});
|
||||
const yDistributionSnap = findNearestEqualSpacingSnap({
|
||||
axis: 'y',
|
||||
proposedStart: proposedY,
|
||||
proposedCrossStart: proposedX,
|
||||
movingSize: movingItem.height,
|
||||
movingCrossSize: movingItem.width,
|
||||
items: visibleItems,
|
||||
threshold,
|
||||
});
|
||||
const xSnap = chooseNearestSnap(xAlignmentSnap, xDistributionSnap);
|
||||
const ySnap = chooseNearestSnap(yAlignmentSnap, yDistributionSnap);
|
||||
|
||||
return {
|
||||
x: xSnap ? xSnap.position : proposedX,
|
||||
y: ySnap ? ySnap.position : proposedY,
|
||||
guide:
|
||||
xSnap || ySnap
|
||||
? {
|
||||
vertical: xSnap?.guide,
|
||||
horizontal: ySnap?.guide,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function chooseNearestSnap(
|
||||
first: SnapCandidate | null,
|
||||
second: SnapCandidate | null,
|
||||
) {
|
||||
if (!first) {
|
||||
return second;
|
||||
}
|
||||
if (!second) {
|
||||
return first;
|
||||
}
|
||||
return second.distance < first.distance ? second : first;
|
||||
}
|
||||
|
||||
function findNearestEqualSpacingSnap({
|
||||
axis,
|
||||
proposedStart,
|
||||
proposedCrossStart,
|
||||
movingSize,
|
||||
movingCrossSize,
|
||||
items,
|
||||
threshold,
|
||||
}: {
|
||||
axis: 'x' | 'y';
|
||||
proposedStart: number;
|
||||
proposedCrossStart: number;
|
||||
movingSize: number;
|
||||
movingCrossSize: number;
|
||||
items: CanvasSnapItem[];
|
||||
threshold: number;
|
||||
}): SnapCandidate | null {
|
||||
const orderedItems = items
|
||||
.filter((item) =>
|
||||
snapItemsOverlapOnCrossAxis(
|
||||
proposedCrossStart,
|
||||
movingCrossSize,
|
||||
item,
|
||||
axis,
|
||||
),
|
||||
)
|
||||
.sort(
|
||||
(firstItem, secondItem) =>
|
||||
getSnapItemStart(firstItem, axis) - getSnapItemStart(secondItem, axis),
|
||||
);
|
||||
let nearest: SnapCandidate | null = null;
|
||||
|
||||
for (let firstIndex = 0; firstIndex < orderedItems.length; firstIndex += 1) {
|
||||
const firstItem = orderedItems[firstIndex];
|
||||
if (!firstItem) {
|
||||
continue;
|
||||
}
|
||||
for (
|
||||
let secondIndex = firstIndex + 1;
|
||||
secondIndex < orderedItems.length &&
|
||||
secondIndex <= firstIndex + SNAP_DISTRIBUTION_PAIR_LOOKAHEAD;
|
||||
secondIndex += 1
|
||||
) {
|
||||
const secondItem = orderedItems[secondIndex];
|
||||
if (!secondItem) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstStart = getSnapItemStart(firstItem, axis);
|
||||
const firstEnd = getSnapItemEnd(firstItem, axis);
|
||||
const secondStart = getSnapItemStart(secondItem, axis);
|
||||
const secondEnd = getSnapItemEnd(secondItem, axis);
|
||||
const pairGap = secondStart - firstEnd;
|
||||
if (pairGap < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nearest = chooseNearestSnap(
|
||||
nearest,
|
||||
createDistributionSnapCandidate({
|
||||
position: firstStart - pairGap - movingSize,
|
||||
proposedStart,
|
||||
movingSize,
|
||||
threshold,
|
||||
}),
|
||||
);
|
||||
nearest = chooseNearestSnap(
|
||||
nearest,
|
||||
createDistributionSnapCandidate({
|
||||
position: secondEnd + pairGap,
|
||||
proposedStart,
|
||||
movingSize,
|
||||
threshold,
|
||||
}),
|
||||
);
|
||||
|
||||
const betweenGap = (pairGap - movingSize) / 2;
|
||||
if (betweenGap >= 0) {
|
||||
nearest = chooseNearestSnap(
|
||||
nearest,
|
||||
createDistributionSnapCandidate({
|
||||
position: firstEnd + betweenGap,
|
||||
proposedStart,
|
||||
movingSize,
|
||||
threshold,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
function createDistributionSnapCandidate({
|
||||
position,
|
||||
proposedStart,
|
||||
movingSize,
|
||||
threshold,
|
||||
}: {
|
||||
position: number;
|
||||
proposedStart: number;
|
||||
movingSize: number;
|
||||
threshold: number;
|
||||
}): SnapCandidate | null {
|
||||
const distance = Math.abs(position - proposedStart);
|
||||
if (distance > threshold) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
position,
|
||||
guide: position + movingSize / 2,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapItemStart(item: CanvasSnapItem, axis: 'x' | 'y') {
|
||||
return axis === 'x' ? item.x : item.y;
|
||||
}
|
||||
|
||||
function getSnapItemSize(item: CanvasSnapItem, axis: 'x' | 'y') {
|
||||
return axis === 'x' ? item.width : item.height;
|
||||
}
|
||||
|
||||
function getSnapItemEnd(item: CanvasSnapItem, axis: 'x' | 'y') {
|
||||
return getSnapItemStart(item, axis) + getSnapItemSize(item, axis);
|
||||
}
|
||||
|
||||
function getSnapItemCrossStart(item: CanvasSnapItem, axis: 'x' | 'y') {
|
||||
return axis === 'x' ? item.y : item.x;
|
||||
}
|
||||
|
||||
function getSnapItemCrossSize(item: CanvasSnapItem, axis: 'x' | 'y') {
|
||||
return axis === 'x' ? item.height : item.width;
|
||||
}
|
||||
|
||||
function snapItemsOverlapOnCrossAxis(
|
||||
proposedCrossStart: number,
|
||||
movingCrossSize: number,
|
||||
item: CanvasSnapItem,
|
||||
axis: 'x' | 'y',
|
||||
) {
|
||||
const movingCrossEnd = proposedCrossStart + movingCrossSize;
|
||||
const itemCrossStart = getSnapItemCrossStart(item, axis);
|
||||
const itemCrossEnd = itemCrossStart + getSnapItemCrossSize(item, axis);
|
||||
return (
|
||||
proposedCrossStart <= itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
|
||||
movingCrossEnd >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE
|
||||
);
|
||||
}
|
||||
|
||||
export function findNearestSnap(
|
||||
origin: number,
|
||||
offsets: number[],
|
||||
targets: number[],
|
||||
threshold: number,
|
||||
): SnapCandidate | null {
|
||||
let nearest: SnapCandidate | null = null;
|
||||
for (const offset of offsets) {
|
||||
for (const target of targets) {
|
||||
const distance = Math.abs(target - (origin + offset));
|
||||
if (distance > threshold) {
|
||||
continue;
|
||||
}
|
||||
if (!nearest || distance < nearest.distance) {
|
||||
nearest = {
|
||||
position: target - offset,
|
||||
guide: target,
|
||||
distance,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { CanvasPortal } from '@genarrative/image-canvas-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
|
||||
export function ImageCanvasEditorPortal({ children }: { children: ReactNode }) {
|
||||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||||
|
||||
if (typeof document === 'undefined') {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
return (
|
||||
<CanvasPortal
|
||||
className={`platform-theme platform-theme--${platformTheme} image-canvas-editor__portal-theme`}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
</CanvasPortal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SidebarMediaItem,
|
||||
} from './ImageCanvasEditorPrimitives';
|
||||
|
||||
test('editor icon button delegates accessible icon chrome to the platform primitive', () => {
|
||||
test('editor icon button delegates accessible icon chrome to the shared canvas primitive', () => {
|
||||
render(
|
||||
<EditorIconButton
|
||||
label="素材选择模式"
|
||||
@@ -23,7 +23,7 @@ test('editor icon button delegates accessible icon chrome to the platform primit
|
||||
|
||||
const button = screen.getByRole('button', { name: '素材选择模式' });
|
||||
|
||||
expect(button.className).toContain('platform-icon-button');
|
||||
expect(button.className).toContain('genarrative-image-canvas__chrome-button');
|
||||
expect(button.className).toContain('image-canvas-editor__icon-button');
|
||||
expect(button.getAttribute('title')).toBe('选择');
|
||||
expect(button.getAttribute('aria-pressed')).toBe('true');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CanvasChromeButton } from '@genarrative/image-canvas-react';
|
||||
import type {
|
||||
ComponentType,
|
||||
DragEventHandler,
|
||||
@@ -39,6 +40,24 @@ export function EditorIconButton({
|
||||
variant,
|
||||
onClick,
|
||||
}: EditorIconButtonProps) {
|
||||
const iconNode = <Icon className="h-4 w-4" />;
|
||||
|
||||
if (variant === undefined || variant === 'platformIcon') {
|
||||
return (
|
||||
<CanvasChromeButton
|
||||
type={type}
|
||||
className={className}
|
||||
label={label}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
pressed={pressed}
|
||||
expanded={expanded}
|
||||
onClick={onClick}
|
||||
icon={iconNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformIconButton
|
||||
variant={variant}
|
||||
@@ -50,7 +69,7 @@ export function EditorIconButton({
|
||||
aria-pressed={pressed}
|
||||
aria-expanded={expanded}
|
||||
onClick={onClick}
|
||||
icon={<Icon className="h-4 w-4" />}
|
||||
icon={iconNode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,6 +357,7 @@ export type CanvasHistorySnapshot = {
|
||||
|
||||
export type CanvasHistoryActionType =
|
||||
| 'move-image'
|
||||
| 'resize-image'
|
||||
| 'move-generation-result'
|
||||
| 'delete-image'
|
||||
| 'delete-generation-result'
|
||||
|
||||
@@ -2312,7 +2312,9 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '画布小地图' })).toBeNull();
|
||||
expect(backgroundButton.className).toContain('platform-icon-button');
|
||||
expect(backgroundButton.className).toContain(
|
||||
'genarrative-image-canvas__chrome-button',
|
||||
);
|
||||
const minimapToggle = within(panelToolbar).getByRole('button', {
|
||||
name: '切换小地图',
|
||||
});
|
||||
|
||||
@@ -199,6 +199,19 @@ describe('ImageCanvasHistoryModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('restores layer size only for layers targeted by a resize action', () => {
|
||||
const targetLayer = createLayer({ width: 320, height: 240 });
|
||||
const currentLayer = createLayer({ width: 640, height: 480 });
|
||||
|
||||
const merged = mergeCanvasHistorySnapshotForRestore({
|
||||
current: createSnapshot([currentLayer]),
|
||||
target: createSnapshot([targetLayer]),
|
||||
layerSizeLayerIds: new Set([targetLayer.id]),
|
||||
});
|
||||
|
||||
expect(merged.layers[0]).toMatchObject({ width: 320, height: 240 });
|
||||
});
|
||||
|
||||
it('restores the complete target layer after that layer was deleted', () => {
|
||||
const targetLayer = createLayer({
|
||||
resourceId: 'deleted-resource',
|
||||
|
||||
@@ -1,284 +1 @@
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasHistoryAction,
|
||||
CanvasHistorySnapshot,
|
||||
CanvasLayer,
|
||||
GenerateDialogState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
const CANVAS_HISTORY_ACTION_LABELS: Record<
|
||||
CanvasHistoryAction['type'],
|
||||
string
|
||||
> = {
|
||||
'move-image': '移动图片',
|
||||
'move-generation-result': '移动生成结果',
|
||||
'delete-image': '删除图片',
|
||||
'delete-generation-result': '删除生成结果',
|
||||
'cut-image': '剪切图片',
|
||||
'paste-image': '粘贴图片',
|
||||
'duplicate-image': '复制图片',
|
||||
'add-image': '添加图片',
|
||||
'upload-image': '上传图片',
|
||||
'generate-image': '生成图片',
|
||||
'expand-image': '扩展图片',
|
||||
'remove-background': '移除背景',
|
||||
'perfect-pixel': '完美像素',
|
||||
'split-atlas': '拆分图集',
|
||||
'replace-image': '替换图片',
|
||||
'show-image': '显示图片',
|
||||
'hide-image': '隐藏图片',
|
||||
'change-layer-order': '调整图片层级',
|
||||
'group-images': '组合图片',
|
||||
'ungroup-images': '取消组合',
|
||||
'lock-image': '锁定图片',
|
||||
'unlock-image': '解锁图片',
|
||||
'flip-image': '翻转图片',
|
||||
'change-asset-kind': '修改素材类型',
|
||||
'change-viewport': '调整画布视图',
|
||||
};
|
||||
|
||||
const PROTECTED_CANVAS_HISTORY_ACTION_TYPES = new Set<
|
||||
CanvasHistoryAction['type']
|
||||
>([
|
||||
'paste-image',
|
||||
'duplicate-image',
|
||||
'add-image',
|
||||
'upload-image',
|
||||
'generate-image',
|
||||
'expand-image',
|
||||
'remove-background',
|
||||
'perfect-pixel',
|
||||
'split-atlas',
|
||||
'replace-image',
|
||||
]);
|
||||
|
||||
export function formatCanvasHistoryAction(action: CanvasHistoryAction): string {
|
||||
return CANVAS_HISTORY_ACTION_LABELS[action.type];
|
||||
}
|
||||
|
||||
export function isProtectedCanvasHistoryAction(
|
||||
action: CanvasHistoryAction,
|
||||
): boolean {
|
||||
return PROTECTED_CANVAS_HISTORY_ACTION_TYPES.has(action.type);
|
||||
}
|
||||
|
||||
function hasSameStableMediaReference(
|
||||
current: {
|
||||
src: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
},
|
||||
target: {
|
||||
src: string;
|
||||
objectKey?: string | null;
|
||||
assetObjectId?: string | null;
|
||||
},
|
||||
): boolean {
|
||||
if (current.objectKey && target.objectKey) {
|
||||
return current.objectKey === target.objectKey;
|
||||
}
|
||||
if (current.assetObjectId && target.assetObjectId) {
|
||||
return current.assetObjectId === target.assetObjectId;
|
||||
}
|
||||
return current.src === target.src;
|
||||
}
|
||||
|
||||
function hasSameImageSequenceFrames(
|
||||
current: CanvasLayer,
|
||||
target: CanvasLayer,
|
||||
): boolean {
|
||||
const currentFrames = current.imageSequenceFrames ?? [];
|
||||
const targetFrames = target.imageSequenceFrames ?? [];
|
||||
if (currentFrames.length !== targetFrames.length) {
|
||||
return false;
|
||||
}
|
||||
return currentFrames.every((currentFrame, index) => {
|
||||
const targetFrame = targetFrames[index];
|
||||
return (
|
||||
targetFrame !== undefined &&
|
||||
hasSameStableMediaReference(
|
||||
{
|
||||
src: currentFrame.imageSrc,
|
||||
objectKey: currentFrame.objectKey,
|
||||
assetObjectId: currentFrame.assetObjectId,
|
||||
},
|
||||
{
|
||||
src: targetFrame.imageSrc,
|
||||
objectKey: targetFrame.objectKey,
|
||||
assetObjectId: targetFrame.assetObjectId,
|
||||
},
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function hasSameLayerContent(
|
||||
current: CanvasLayer,
|
||||
target: CanvasLayer,
|
||||
): boolean {
|
||||
return (
|
||||
(current.mediaType ?? 'image') === (target.mediaType ?? 'image') &&
|
||||
hasSameStableMediaReference(current, target) &&
|
||||
hasSameImageSequenceFrames(current, target) &&
|
||||
current.imageSequenceDurationMs === target.imageSequenceDurationMs
|
||||
);
|
||||
}
|
||||
|
||||
function cloneDialogPlaceholder(
|
||||
dialog: GenerateDialogState,
|
||||
): GenerateDialogState['placeholder'] {
|
||||
return dialog.placeholder ? { ...dialog.placeholder } : undefined;
|
||||
}
|
||||
|
||||
function mergeLayerForHistoryRestore(
|
||||
target: CanvasLayer,
|
||||
current: CanvasLayer | undefined,
|
||||
restoreAssetKind: boolean,
|
||||
): CanvasLayer {
|
||||
if (!current) {
|
||||
return { ...target };
|
||||
}
|
||||
const resourceAssetKind =
|
||||
current.resourceAssetKind ?? target.resourceAssetKind;
|
||||
const restoresLayerOverride =
|
||||
target.assetKindOverride !== undefined ||
|
||||
current.assetKindOverride !== undefined;
|
||||
const assetKindOverride = restoreAssetKind
|
||||
? (target.assetKindOverride ?? null)
|
||||
: current.assetKindOverride;
|
||||
return {
|
||||
...current,
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
zIndex: target.zIndex,
|
||||
groupId: target.groupId,
|
||||
...(resourceAssetKind !== undefined ? { resourceAssetKind } : {}),
|
||||
...(restoresLayerOverride ? { assetKindOverride } : {}),
|
||||
assetKind: restoreAssetKind
|
||||
? restoresLayerOverride
|
||||
? (assetKindOverride ?? resourceAssetKind ?? null)
|
||||
: target.assetKind
|
||||
: current.assetKind,
|
||||
hidden: target.hidden,
|
||||
locked: target.locked,
|
||||
flipX: target.flipX,
|
||||
flipY: target.flipY,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDialogPlaceholderForHistoryRestore(
|
||||
target: GenerateDialogState,
|
||||
current: GenerateDialogState,
|
||||
): GenerateDialogState['placeholder'] {
|
||||
if (!current.placeholder) {
|
||||
return cloneDialogPlaceholder(target);
|
||||
}
|
||||
if (!target.placeholder) {
|
||||
return cloneDialogPlaceholder(current);
|
||||
}
|
||||
return {
|
||||
...current.placeholder,
|
||||
x: target.placeholder.x,
|
||||
y: target.placeholder.y,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeDialogForHistoryRestore<
|
||||
TTarget extends GenerateDialogState,
|
||||
TCurrent extends GenerateDialogState,
|
||||
>(target: TTarget, current: TCurrent | undefined): TTarget | TCurrent {
|
||||
if (!current) {
|
||||
return {
|
||||
...target,
|
||||
placeholder: cloneDialogPlaceholder(target),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
composerOpen: target.composerOpen,
|
||||
placeholder: mergeDialogPlaceholderForHistoryRestore(target, current),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCanvasHistorySnapshotForRestore({
|
||||
current,
|
||||
target,
|
||||
assetKindLayerIds,
|
||||
}: {
|
||||
current: CanvasHistorySnapshot;
|
||||
target: CanvasHistorySnapshot;
|
||||
assetKindLayerIds?: ReadonlySet<string>;
|
||||
}): CanvasHistorySnapshot {
|
||||
const currentLayerById = new Map(
|
||||
current.layers.map((layer) => [layer.id, layer] as const),
|
||||
);
|
||||
const currentDialogById = new Map<string, GenerateDialogState>();
|
||||
for (const dialog of current.inactiveGenerateDialogs) {
|
||||
currentDialogById.set(dialog.id, dialog);
|
||||
}
|
||||
if (current.generateDialog?.id) {
|
||||
currentDialogById.set(current.generateDialog.id, current.generateDialog);
|
||||
}
|
||||
|
||||
const mergeTargetDialog = <T extends GenerateDialogState>(dialog: T): T =>
|
||||
mergeDialogForHistoryRestore(
|
||||
dialog,
|
||||
dialog.id ? currentDialogById.get(dialog.id) : undefined,
|
||||
) as T;
|
||||
|
||||
return {
|
||||
...target,
|
||||
layers: target.layers.map((layer) =>
|
||||
mergeLayerForHistoryRestore(
|
||||
layer,
|
||||
currentLayerById.get(layer.id),
|
||||
assetKindLayerIds?.has(layer.id) ?? true,
|
||||
),
|
||||
),
|
||||
generateDialog: target.generateDialog
|
||||
? mergeTargetDialog(target.generateDialog)
|
||||
: null,
|
||||
inactiveGenerateDialogs: target.inactiveGenerateDialogs.map(
|
||||
(dialog): CanvasGenerationDialogState => mergeTargetDialog(dialog),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function canRestoreCanvasHistorySnapshotWithoutContentLoss({
|
||||
current,
|
||||
target,
|
||||
}: {
|
||||
current: CanvasHistorySnapshot;
|
||||
target: CanvasHistorySnapshot;
|
||||
}): boolean {
|
||||
const targetLayerById = new Map(
|
||||
target.layers.map((layer) => [layer.id, layer] as const),
|
||||
);
|
||||
|
||||
const preservesLayers = current.layers.every((currentLayer) => {
|
||||
const targetLayer = targetLayerById.get(currentLayer.id);
|
||||
if (!targetLayer) {
|
||||
return false;
|
||||
}
|
||||
if (!currentLayer.hidden && targetLayer.hidden) {
|
||||
return false;
|
||||
}
|
||||
return hasSameLayerContent(currentLayer, targetLayer);
|
||||
});
|
||||
if (!preservesLayers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const getDialogIds = (snapshot: CanvasHistorySnapshot): Set<string> =>
|
||||
new Set(
|
||||
[
|
||||
snapshot.generateDialog?.id,
|
||||
...snapshot.inactiveGenerateDialogs.map((dialog) => dialog.id),
|
||||
].filter((dialogId): dialogId is string => Boolean(dialogId)),
|
||||
);
|
||||
const targetDialogIds = getDialogIds(target);
|
||||
|
||||
return [...getDialogIds(current)].every((dialogId) =>
|
||||
targetDialogIds.has(dialogId),
|
||||
);
|
||||
}
|
||||
export * from '../../../packages/image-canvas-core/src/history';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,259 +1 @@
|
||||
import type {
|
||||
CanvasClipboard,
|
||||
CanvasContextMenuState,
|
||||
CanvasLayer,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel';
|
||||
|
||||
export type CanvasLayerMoveMode = 'up' | 'down' | 'top' | 'bottom';
|
||||
|
||||
export type CanvasLayerFlipAxis = 'x' | 'y';
|
||||
|
||||
export type CanvasLayerCopyBlockReason =
|
||||
| 'pending-resource'
|
||||
| 'self-contained-local-unsupported'
|
||||
| 'unresolved-resource';
|
||||
|
||||
type CanvasPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
function cloneLayer(layer: CanvasLayer): CanvasLayer {
|
||||
return {
|
||||
...layer,
|
||||
generationInputs: layer.generationInputs
|
||||
? {
|
||||
...layer.generationInputs,
|
||||
fields: layer.generationInputs.fields.map((field) => ({ ...field })),
|
||||
references: layer.generationInputs.references.map((reference) => ({
|
||||
...reference,
|
||||
})),
|
||||
...(layer.generationInputs.soundEffect
|
||||
? { soundEffect: { ...layer.generationInputs.soundEffect } }
|
||||
: {}),
|
||||
}
|
||||
: layer.generationInputs,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveContextTargetLayerIds(
|
||||
menu: CanvasContextMenuState | null,
|
||||
selectedLayerIds: string[],
|
||||
) {
|
||||
if (!menu || menu.kind === 'blank') {
|
||||
return [];
|
||||
}
|
||||
const targetId =
|
||||
menu.kind === 'generation-dialog'
|
||||
? getCanvasGenerationSelectionId(menu.dialogId)
|
||||
: menu.layerId;
|
||||
return selectedLayerIds.includes(targetId)
|
||||
? [...selectedLayerIds]
|
||||
: [targetId];
|
||||
}
|
||||
|
||||
export function getCanvasLayersByIds(
|
||||
layers: CanvasLayer[],
|
||||
targetIds: string[],
|
||||
) {
|
||||
return layers.filter((layer) => targetIds.includes(layer.id));
|
||||
}
|
||||
|
||||
export function getCanvasLayerCopyBlockReason(
|
||||
layers: CanvasLayer[],
|
||||
): CanvasLayerCopyBlockReason | null {
|
||||
for (const layer of layers) {
|
||||
if (layer.resourcePersistenceState === 'registered') {
|
||||
continue;
|
||||
}
|
||||
if (layer.resourcePersistenceState === 'pending') {
|
||||
return 'pending-resource';
|
||||
}
|
||||
if (layer.resourcePersistenceState === 'self-contained-local') {
|
||||
return 'self-contained-local-unsupported';
|
||||
}
|
||||
if (
|
||||
layer.resourcePersistenceState === 'unresolved-local' ||
|
||||
layer.resourceId.startsWith('local-')
|
||||
) {
|
||||
return 'unresolved-resource';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canCopyCanvasLayers(layers: CanvasLayer[]) {
|
||||
return layers.length > 0 && getCanvasLayerCopyBlockReason(layers) === null;
|
||||
}
|
||||
|
||||
export function duplicateCanvasLayers({
|
||||
sourceLayers,
|
||||
allLayers,
|
||||
canvasPoint,
|
||||
renameCopies = true,
|
||||
stamp = Date.now(),
|
||||
}: {
|
||||
sourceLayers: CanvasLayer[];
|
||||
allLayers: CanvasLayer[];
|
||||
canvasPoint?: CanvasPoint;
|
||||
renameCopies?: boolean;
|
||||
stamp?: number | string;
|
||||
}) {
|
||||
if (!canCopyCanvasLayers(sourceLayers)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const minX = Math.min(...sourceLayers.map((layer) => layer.x));
|
||||
const minY = Math.min(...sourceLayers.map((layer) => layer.y));
|
||||
const maxZIndex = allLayers.reduce(
|
||||
(maxZ, layer) => Math.max(maxZ, layer.zIndex),
|
||||
0,
|
||||
);
|
||||
|
||||
return sourceLayers.map((layer, index) => ({
|
||||
...cloneLayer(layer),
|
||||
id: `layer-copy-${stamp}-${index}`,
|
||||
title: renameCopies ? `${layer.title} 副本` : layer.title,
|
||||
x: canvasPoint ? canvasPoint.x + (layer.x - minX) : layer.x + 32,
|
||||
y: canvasPoint ? canvasPoint.y + (layer.y - minY) : layer.y + 32,
|
||||
zIndex: maxZIndex + index + 1,
|
||||
groupId: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function createCanvasLayerClipboard(
|
||||
layers: CanvasLayer[],
|
||||
targetIds: string[],
|
||||
mode: CanvasClipboard['mode'],
|
||||
): CanvasClipboard | null {
|
||||
const targetLayers = getCanvasLayersByIds(layers, targetIds);
|
||||
if (!canCopyCanvasLayers(targetLayers)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
layers: targetLayers.map(cloneLayer),
|
||||
mode,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeCanvasLayers(layers: CanvasLayer[], targetIds: string[]) {
|
||||
if (!targetIds.length) {
|
||||
return layers;
|
||||
}
|
||||
return layers.filter((layer) => !targetIds.includes(layer.id));
|
||||
}
|
||||
|
||||
export function updateCanvasLayersByIds(
|
||||
layers: CanvasLayer[],
|
||||
targetIds: string[],
|
||||
updater: (layer: CanvasLayer, targetIds: string[]) => CanvasLayer,
|
||||
) {
|
||||
if (!targetIds.length) {
|
||||
return layers;
|
||||
}
|
||||
return layers.map((layer) =>
|
||||
targetIds.includes(layer.id) ? updater(layer, targetIds) : layer,
|
||||
);
|
||||
}
|
||||
|
||||
export function moveCanvasLayers(
|
||||
layers: CanvasLayer[],
|
||||
targetIds: string[],
|
||||
mode: CanvasLayerMoveMode,
|
||||
) {
|
||||
if (!targetIds.length) {
|
||||
return layers;
|
||||
}
|
||||
const maxZIndex = layers.reduce(
|
||||
(maxZ, layer) => Math.max(maxZ, layer.zIndex),
|
||||
0,
|
||||
);
|
||||
const minZIndex = layers.reduce(
|
||||
(minZ, layer) => Math.min(minZ, layer.zIndex),
|
||||
0,
|
||||
);
|
||||
let offsetIndex = 0;
|
||||
|
||||
return updateCanvasLayersByIds(layers, targetIds, (layer) => {
|
||||
if (mode === 'up') {
|
||||
return { ...layer, zIndex: layer.zIndex + 1 };
|
||||
}
|
||||
if (mode === 'down') {
|
||||
return { ...layer, zIndex: layer.zIndex - 1 };
|
||||
}
|
||||
offsetIndex += 1;
|
||||
if (mode === 'top') {
|
||||
return { ...layer, zIndex: maxZIndex + offsetIndex };
|
||||
}
|
||||
return {
|
||||
...layer,
|
||||
zIndex: minZIndex - (targetIds.length - offsetIndex + 1),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function groupCanvasLayers(
|
||||
layers: CanvasLayer[],
|
||||
targetIds: string[],
|
||||
groupId: string,
|
||||
) {
|
||||
return updateCanvasLayersByIds(layers, targetIds, (layer) => ({
|
||||
...layer,
|
||||
groupId,
|
||||
}));
|
||||
}
|
||||
|
||||
export function ungroupCanvasLayers(
|
||||
layers: CanvasLayer[],
|
||||
targetIds: string[],
|
||||
) {
|
||||
return updateCanvasLayersByIds(layers, targetIds, (layer) => ({
|
||||
...layer,
|
||||
groupId: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toggleCanvasLayersVisibility(
|
||||
layers: CanvasLayer[],
|
||||
targetIds: string[],
|
||||
) {
|
||||
const shouldHide = getCanvasLayersByIds(layers, targetIds).some(
|
||||
(layer) => !layer.hidden,
|
||||
);
|
||||
return updateCanvasLayersByIds(layers, targetIds, (layer) => ({
|
||||
...layer,
|
||||
hidden: shouldHide,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toggleCanvasLayersLock(
|
||||
layers: CanvasLayer[],
|
||||
targetIds: string[],
|
||||
) {
|
||||
const shouldLock = getCanvasLayersByIds(layers, targetIds).some(
|
||||
(layer) => !layer.locked,
|
||||
);
|
||||
return updateCanvasLayersByIds(layers, targetIds, (layer) => ({
|
||||
...layer,
|
||||
locked: shouldLock,
|
||||
}));
|
||||
}
|
||||
|
||||
export function flipCanvasLayers(
|
||||
layers: CanvasLayer[],
|
||||
targetIds: string[],
|
||||
axis: CanvasLayerFlipAxis,
|
||||
) {
|
||||
return updateCanvasLayersByIds(layers, targetIds, (layer) =>
|
||||
axis === 'x'
|
||||
? {
|
||||
...layer,
|
||||
flipX: !layer.flipX,
|
||||
}
|
||||
: {
|
||||
...layer,
|
||||
flipY: !layer.flipY,
|
||||
},
|
||||
);
|
||||
}
|
||||
export * from '../../../packages/image-canvas-core/src/layerCommands';
|
||||
|
||||
@@ -1,219 +1 @@
|
||||
import type {
|
||||
CanvasGenerationDialogMode,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
CharacterAnimationPanelState,
|
||||
CropExpandPanelState,
|
||||
GenerateDialogState,
|
||||
QuickEditPanelState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
type CanvasSize = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type OverlayAnchor = Pick<CanvasLayer, 'x' | 'y' | 'width' | 'height'>;
|
||||
|
||||
const QUICK_EDIT_FOCUS_HORIZONTAL_MARGIN = 56;
|
||||
const QUICK_EDIT_FOCUS_SOURCE_TOP = 56;
|
||||
const QUICK_EDIT_PANEL_GAP = 12;
|
||||
const QUICK_EDIT_PANEL_ESTIMATED_HEIGHT = 260;
|
||||
const QUICK_EDIT_PANEL_BOTTOM_MARGIN = 4;
|
||||
|
||||
export type CanvasOverlayStyle = {
|
||||
left: number;
|
||||
top: number;
|
||||
};
|
||||
|
||||
export type CanvasComposerOverlayStyle = CanvasOverlayStyle & {
|
||||
width?: string;
|
||||
};
|
||||
|
||||
export function resolveGenerationAnchor({
|
||||
dialog,
|
||||
generatedLayer,
|
||||
}: {
|
||||
dialog: CanvasGenerationDialogState | null;
|
||||
generatedLayer: CanvasLayer | null;
|
||||
}) {
|
||||
if (!dialog) {
|
||||
return null;
|
||||
}
|
||||
return generatedLayer ?? dialog.placeholder ?? null;
|
||||
}
|
||||
|
||||
export function resolveGenerationComposerStyle({
|
||||
dialog,
|
||||
anchor,
|
||||
viewport,
|
||||
}: {
|
||||
dialog: CanvasGenerationDialogState | null;
|
||||
anchor: OverlayAnchor | null;
|
||||
viewport: CanvasViewport;
|
||||
}): CanvasOverlayStyle | null {
|
||||
if (
|
||||
!dialog ||
|
||||
dialog.status === 'generating' ||
|
||||
dialog.composerOpen === false ||
|
||||
!anchor
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left: viewport.x + (anchor.x + anchor.width / 2) * viewport.scale,
|
||||
top: viewport.y + (anchor.y + anchor.height) * viewport.scale + 10,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveIconComposerStyle({
|
||||
dialog,
|
||||
composerStyle,
|
||||
}: {
|
||||
dialog: CanvasGenerationDialogState | null;
|
||||
composerStyle: CanvasOverlayStyle | null;
|
||||
}): CanvasComposerOverlayStyle | null {
|
||||
if (dialog?.mode !== 'icon' || !composerStyle) {
|
||||
return null;
|
||||
}
|
||||
return composerStyle;
|
||||
}
|
||||
|
||||
export function resolveSelectedToolbarStyle({
|
||||
selectedLayer,
|
||||
viewport,
|
||||
}: {
|
||||
selectedLayer: CanvasLayer | null;
|
||||
viewport: CanvasViewport;
|
||||
canvasSize: CanvasSize;
|
||||
}): CanvasOverlayStyle | null {
|
||||
if (!selectedLayer) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left:
|
||||
viewport.x +
|
||||
selectedLayer.x * viewport.scale +
|
||||
(selectedLayer.width * viewport.scale) / 2,
|
||||
top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveQuickEditFocusViewport({
|
||||
sourceLayer,
|
||||
canvasSize,
|
||||
}: {
|
||||
sourceLayer: CanvasLayer;
|
||||
canvasSize: CanvasSize;
|
||||
}): CanvasViewport {
|
||||
const safeSourceWidth = Math.max(1, sourceLayer.width);
|
||||
const safeSourceHeight = Math.max(1, sourceLayer.height);
|
||||
const availableWidth = Math.max(
|
||||
1,
|
||||
canvasSize.width - QUICK_EDIT_FOCUS_HORIZONTAL_MARGIN * 2,
|
||||
);
|
||||
const availableHeight = Math.max(
|
||||
1,
|
||||
canvasSize.height -
|
||||
QUICK_EDIT_FOCUS_SOURCE_TOP -
|
||||
QUICK_EDIT_PANEL_GAP -
|
||||
QUICK_EDIT_PANEL_ESTIMATED_HEIGHT -
|
||||
QUICK_EDIT_PANEL_BOTTOM_MARGIN,
|
||||
);
|
||||
const scale = Math.min(
|
||||
1,
|
||||
availableWidth / safeSourceWidth,
|
||||
availableHeight / safeSourceHeight,
|
||||
);
|
||||
|
||||
return {
|
||||
scale,
|
||||
x:
|
||||
canvasSize.width / 2 -
|
||||
(sourceLayer.x + sourceLayer.width / 2) * scale,
|
||||
y: QUICK_EDIT_FOCUS_SOURCE_TOP - sourceLayer.y * scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveQuickEditPanelStyle({
|
||||
panel,
|
||||
sourceLayer,
|
||||
viewport,
|
||||
}: {
|
||||
panel: QuickEditPanelState | null;
|
||||
sourceLayer: CanvasLayer | null;
|
||||
viewport: CanvasViewport;
|
||||
canvasSize: CanvasSize;
|
||||
}): CanvasOverlayStyle | null {
|
||||
if (!panel || !sourceLayer) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left:
|
||||
viewport.x + (sourceLayer.x + sourceLayer.width / 2) * viewport.scale,
|
||||
top:
|
||||
viewport.y +
|
||||
(sourceLayer.y + sourceLayer.height) * viewport.scale +
|
||||
QUICK_EDIT_PANEL_GAP,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCropExpandPanelStyle({
|
||||
panel,
|
||||
sourceLayer,
|
||||
viewport,
|
||||
}: {
|
||||
panel: CropExpandPanelState | null;
|
||||
sourceLayer: CanvasLayer | null;
|
||||
viewport: CanvasViewport;
|
||||
canvasSize: CanvasSize;
|
||||
}): CanvasOverlayStyle | null {
|
||||
if (!panel || !sourceLayer) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left:
|
||||
viewport.x + (sourceLayer.x + sourceLayer.width) * viewport.scale + 12,
|
||||
top: viewport.y + sourceLayer.y * viewport.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCharacterAnimationPanelStyle({
|
||||
panel,
|
||||
sourceLayer,
|
||||
viewport,
|
||||
}: {
|
||||
panel: CharacterAnimationPanelState | null;
|
||||
sourceLayer: CanvasLayer | null;
|
||||
viewport: CanvasViewport;
|
||||
canvasSize: CanvasSize;
|
||||
}): CanvasOverlayStyle | null {
|
||||
if (!panel || !sourceLayer) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
left:
|
||||
viewport.x + (sourceLayer.x + sourceLayer.width) * viewport.scale + 12,
|
||||
top: viewport.y + sourceLayer.y * viewport.scale,
|
||||
};
|
||||
}
|
||||
|
||||
export function isCanvasGenerationComposerVisible(
|
||||
dialog: GenerateDialogState | null,
|
||||
): dialog is GenerateDialogState & { mode: CanvasGenerationDialogMode } {
|
||||
return (
|
||||
dialog?.mode === 'generate' ||
|
||||
dialog?.mode === 'scene' ||
|
||||
dialog?.mode === 'spec' ||
|
||||
dialog?.mode === 'character' ||
|
||||
dialog?.mode === 'icon' ||
|
||||
dialog?.mode === 'ui-design' ||
|
||||
dialog?.mode === 'quick-edit' ||
|
||||
dialog?.mode === 'publication' ||
|
||||
dialog?.mode === 'character-animation' ||
|
||||
dialog?.mode === 'video' ||
|
||||
dialog?.mode === 'audio-sound-effect' ||
|
||||
dialog?.mode === 'audio-background-music'
|
||||
);
|
||||
}
|
||||
export * from '../../../packages/image-canvas-core/src/overlays';
|
||||
|
||||
@@ -119,6 +119,11 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '画布背景设置' });
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '画布背景色' })
|
||||
.getAttribute('aria-expanded'),
|
||||
).toBe('true');
|
||||
const spectrumButton = within(panel).getByRole('button', {
|
||||
name: '画布背景色盘',
|
||||
});
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
CanvasChromeButton,
|
||||
Minimap,
|
||||
ZoomControls,
|
||||
} from '@genarrative/image-canvas-react';
|
||||
import {
|
||||
ImagePlus,
|
||||
Layers,
|
||||
@@ -19,14 +24,11 @@ import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import {
|
||||
CANVAS_BACKGROUND_OPTIONS,
|
||||
canvasDisplayScaleToViewportScale,
|
||||
clamp,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
formatCanvasDisplayScalePercent,
|
||||
normalizeCanvasBackgroundHex,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
@@ -233,9 +235,7 @@ export function ImageCanvasPanelDockView({
|
||||
lastBackgroundHueRef.current,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
backgroundHsv.saturation > ACHROMATIC_BACKGROUND_SATURATION_EPSILON
|
||||
) {
|
||||
if (backgroundHsv.saturation > ACHROMATIC_BACKGROUND_SATURATION_EPSILON) {
|
||||
lastBackgroundHueRef.current = backgroundHsv.hue;
|
||||
}
|
||||
}, [backgroundHsv.hue, backgroundHsv.saturation]);
|
||||
@@ -254,9 +254,7 @@ export function ImageCanvasPanelDockView({
|
||||
const backgroundHueHandleStyle = {
|
||||
'--image-canvas-background-hue-handle-left': `clamp(0.39rem, ${(backgroundHsv.hue / 360) * 100}%, calc(100% - 0.39rem))`,
|
||||
} as CSSProperties;
|
||||
const applySpectrumPointerColor = (
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
) => {
|
||||
const applySpectrumPointerColor = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
onApplyCanvasBackgroundColor(
|
||||
@@ -344,67 +342,65 @@ export function ImageCanvasPanelDockView({
|
||||
disabled={!canRedo}
|
||||
onClick={onRedoCanvasChange}
|
||||
/>
|
||||
<div className="image-canvas-editor__zoom-menu-wrap">
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__zoom-trigger"
|
||||
aria-label={`当前缩放比例 ${formatCanvasDisplayScalePercent(viewport.scale)}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isZoomMenuOpen}
|
||||
onClick={onToggleZoomMenu}
|
||||
>
|
||||
{formatCanvasDisplayScalePercent(viewport.scale)}
|
||||
</PlatformInlineOptionButton>
|
||||
{isZoomMenuOpen ? (
|
||||
<PlatformFloatingMenu label="缩放菜单" placement="top-start">
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__zoom-menu-item"
|
||||
onClick={() => {
|
||||
onUpdateScaleFromCenter(viewport.scale * 1.16);
|
||||
onCloseZoomMenu();
|
||||
}}
|
||||
<ZoomControls
|
||||
viewport={viewport}
|
||||
onFit={onFitLayers}
|
||||
onScaleFromCenter={onUpdateScaleFromCenter}
|
||||
>
|
||||
{(zoomActions) => (
|
||||
<div className="image-canvas-editor__zoom-menu-wrap">
|
||||
<PlatformInlineOptionButton
|
||||
className="image-canvas-editor__zoom-trigger"
|
||||
aria-label={`当前缩放比例 ${zoomActions.displayPercent}`}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isZoomMenuOpen}
|
||||
onClick={onToggleZoomMenu}
|
||||
>
|
||||
放大
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__zoom-menu-item"
|
||||
onClick={() => {
|
||||
onUpdateScaleFromCenter(viewport.scale * 0.86);
|
||||
onCloseZoomMenu();
|
||||
}}
|
||||
>
|
||||
缩小
|
||||
</PlatformFloatingMenuItem>
|
||||
<PlatformFloatingMenuItem
|
||||
className="image-canvas-editor__zoom-menu-item"
|
||||
onClick={() => {
|
||||
onFitLayers();
|
||||
onCloseZoomMenu();
|
||||
}}
|
||||
>
|
||||
显示画布所有元素
|
||||
</PlatformFloatingMenuItem>
|
||||
{[0.5, 1, 2].map((displayScale) => (
|
||||
<PlatformFloatingMenuItem
|
||||
key={displayScale}
|
||||
className="image-canvas-editor__zoom-menu-item"
|
||||
onClick={() => {
|
||||
onUpdateScaleFromCenter(
|
||||
canvasDisplayScaleToViewportScale(displayScale),
|
||||
);
|
||||
onCloseZoomMenu();
|
||||
}}
|
||||
>
|
||||
缩放至{Math.round(displayScale * 100)}%
|
||||
</PlatformFloatingMenuItem>
|
||||
))}
|
||||
</PlatformFloatingMenu>
|
||||
) : null}
|
||||
</div>
|
||||
{zoomActions.displayPercent}
|
||||
</PlatformInlineOptionButton>
|
||||
{isZoomMenuOpen ? (
|
||||
<PlatformFloatingMenu label="缩放菜单" placement="top-start">
|
||||
{[
|
||||
{ label: '放大', action: zoomActions.zoomIn },
|
||||
{ label: '缩小', action: zoomActions.zoomOut },
|
||||
{
|
||||
label: '显示画布所有元素',
|
||||
action: zoomActions.fit,
|
||||
},
|
||||
].map(({ label, action }) => (
|
||||
<PlatformFloatingMenuItem
|
||||
key={label}
|
||||
className="image-canvas-editor__zoom-menu-item"
|
||||
onClick={() => {
|
||||
action();
|
||||
onCloseZoomMenu();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</PlatformFloatingMenuItem>
|
||||
))}
|
||||
{[0.5, 1, 2].map((displayScale) => (
|
||||
<PlatformFloatingMenuItem
|
||||
key={displayScale}
|
||||
className="image-canvas-editor__zoom-menu-item"
|
||||
onClick={() => {
|
||||
zoomActions.zoomToDisplayScale(displayScale);
|
||||
onCloseZoomMenu();
|
||||
}}
|
||||
>
|
||||
缩放至{Math.round(displayScale * 100)}%
|
||||
</PlatformFloatingMenuItem>
|
||||
))}
|
||||
</PlatformFloatingMenu>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</ZoomControls>
|
||||
<div className="image-canvas-editor__background-control">
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
label="画布背景色"
|
||||
title="画布背景色"
|
||||
aria-expanded={isBackgroundSettingsOpen}
|
||||
expanded={isBackgroundSettingsOpen}
|
||||
onClick={onToggleBackgroundSettings}
|
||||
icon={
|
||||
<span
|
||||
@@ -562,28 +558,14 @@ export function ImageCanvasPanelDockView({
|
||||
</div>
|
||||
|
||||
{isMinimapOpen && minimapModel ? (
|
||||
<button
|
||||
type="button"
|
||||
<Minimap
|
||||
model={minimapModel}
|
||||
className="image-canvas-editor__minimap"
|
||||
aria-label="画布小地图"
|
||||
title="拖拽移动视图"
|
||||
stageClassName="image-canvas-editor__minimap-stage"
|
||||
layerClassName="image-canvas-editor__minimap-layer"
|
||||
viewportClassName="image-canvas-editor__minimap-viewport"
|
||||
onPointerDown={onMinimapPointerDown}
|
||||
>
|
||||
<span className="image-canvas-editor__minimap-stage">
|
||||
{minimapModel.layers.map((layer) => (
|
||||
<span
|
||||
key={layer.id}
|
||||
className="image-canvas-editor__minimap-layer"
|
||||
title={layer.title}
|
||||
style={layer.rect}
|
||||
/>
|
||||
))}
|
||||
<span
|
||||
className="image-canvas-editor__minimap-viewport"
|
||||
style={minimapModel.viewport}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CanvasChromeButton } from '@genarrative/image-canvas-react';
|
||||
import {
|
||||
Crop,
|
||||
Download,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
@@ -69,7 +69,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{canRedraw ? (
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
@@ -77,7 +77,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
label="下载按钮"
|
||||
@@ -104,7 +104,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
>
|
||||
{!isQuickEditUnsupportedAssetKind(selectedLayer) ? (
|
||||
<>
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="快速编辑"
|
||||
title="快速编辑"
|
||||
@@ -112,7 +112,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onOpenQuickEditPanel(selectedLayer)}
|
||||
>
|
||||
<span>快速编辑</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
@@ -133,7 +133,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
icon={ImageOff}
|
||||
onClick={() => onRemoveBackground(selectedLayer)}
|
||||
/>
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
|
||||
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
|
||||
@@ -183,11 +183,11 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
? '待确认'
|
||||
: '完美像素'}
|
||||
</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
</>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'icon-spritesheet' ? (
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label={
|
||||
isPersistingAssetKind
|
||||
@@ -221,10 +221,10 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
? '拆图中'
|
||||
: '拆分图集'}
|
||||
</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'ui-design' ? (
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="提取素材"
|
||||
title="提取素材"
|
||||
@@ -232,10 +232,10 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onExtractUiDesignAssets(selectedLayer)}
|
||||
>
|
||||
<span>提取素材</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{selectedLayer.assetKind === 'character' ? (
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="生成动画"
|
||||
title="生成动画"
|
||||
@@ -243,7 +243,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onOpenCharacterAnimationPanel(selectedLayer)}
|
||||
>
|
||||
<span>生成动画</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
) : null}
|
||||
{canRedraw ? (
|
||||
<>
|
||||
@@ -251,7 +251,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
aria-hidden="true"
|
||||
className="image-canvas-editor__floating-toolbar-divider"
|
||||
/>
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
className="image-canvas-editor__floating-toolbar-text-button"
|
||||
label="改造"
|
||||
title="改造"
|
||||
@@ -259,7 +259,7 @@ export function ImageCanvasSelectedLayerToolbarView({
|
||||
onClick={() => onOpenRedrawPanel(selectedLayer)}
|
||||
>
|
||||
<span>改造</span>
|
||||
</PlatformIconButton>
|
||||
</CanvasChromeButton>
|
||||
</>
|
||||
) : null}
|
||||
<EditorIconButton
|
||||
|
||||
@@ -1,87 +1 @@
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
export const CANVAS_GENERATION_SELECTION_PREFIX = 'generation-dialog:';
|
||||
|
||||
export function getCanvasGenerationSelectionId(dialogId: string) {
|
||||
return `${CANVAS_GENERATION_SELECTION_PREFIX}${dialogId}`;
|
||||
}
|
||||
|
||||
export function getCanvasGenerationDialogIdFromSelectionId(targetId: string) {
|
||||
return targetId.startsWith(CANVAS_GENERATION_SELECTION_PREFIX)
|
||||
? targetId.slice(CANVAS_GENERATION_SELECTION_PREFIX.length)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isCanvasGenerationSelectionId(targetId: string) {
|
||||
return getCanvasGenerationDialogIdFromSelectionId(targetId) !== null;
|
||||
}
|
||||
|
||||
export function getSelectedLayerIds(selectionIds: string[]) {
|
||||
return selectionIds.filter(
|
||||
(selectionId) => !isCanvasGenerationSelectionId(selectionId),
|
||||
);
|
||||
}
|
||||
|
||||
export function getSelectedGenerationDialogIds(selectionIds: string[]) {
|
||||
return selectionIds.flatMap((selectionId) => {
|
||||
const dialogId = getCanvasGenerationDialogIdFromSelectionId(selectionId);
|
||||
return dialogId ? [dialogId] : [];
|
||||
});
|
||||
}
|
||||
|
||||
export function dedupeSelectionIds(selectionIds: string[]) {
|
||||
return Array.from(new Set(selectionIds));
|
||||
}
|
||||
|
||||
export function firstSelectedLayerId(selectionIds: string[]) {
|
||||
return getSelectedLayerIds(selectionIds)[0] ?? null;
|
||||
}
|
||||
|
||||
export function normalizeCanvasSelectionIds({
|
||||
selectionIds,
|
||||
layers,
|
||||
generationDialogs,
|
||||
}: {
|
||||
selectionIds: string[];
|
||||
layers: CanvasLayer[];
|
||||
generationDialogs: CanvasGenerationDialogState[];
|
||||
}) {
|
||||
const layerIds = new Set(layers.map((layer) => layer.id));
|
||||
const generationDialogIds = new Set(
|
||||
generationDialogs.map((dialog) => dialog.id),
|
||||
);
|
||||
return dedupeSelectionIds(selectionIds).filter((selectionId) => {
|
||||
if (isCanvasGenerationSelectionId(selectionId)) {
|
||||
const dialogId = getCanvasGenerationDialogIdFromSelectionId(selectionId);
|
||||
return Boolean(dialogId && generationDialogIds.has(dialogId));
|
||||
}
|
||||
return layerIds.has(selectionId);
|
||||
});
|
||||
}
|
||||
|
||||
export function resolvePointerSelection({
|
||||
targetId,
|
||||
selectedIds,
|
||||
isMultiSelectGesture,
|
||||
}: {
|
||||
targetId: string;
|
||||
selectedIds: string[];
|
||||
isMultiSelectGesture: boolean;
|
||||
}) {
|
||||
if (!isMultiSelectGesture) {
|
||||
if (selectedIds.includes(targetId)) {
|
||||
return selectedIds;
|
||||
}
|
||||
return [targetId];
|
||||
}
|
||||
if (!selectedIds.includes(targetId)) {
|
||||
return [...selectedIds, targetId];
|
||||
}
|
||||
if (selectedIds.length <= 1) {
|
||||
return [targetId];
|
||||
}
|
||||
return selectedIds.filter((selectedId) => selectedId !== targetId);
|
||||
}
|
||||
export * from '../../../packages/image-canvas-core/src/selection';
|
||||
|
||||
@@ -95,6 +95,7 @@ describe('ImageCanvasStageControllerModel', () => {
|
||||
clientX: 1000,
|
||||
clientY: 1000,
|
||||
canvasPoint: { x: 12, y: 16 },
|
||||
viewportBounds: { width: window.innerWidth, height: window.innerHeight },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: 'blank',
|
||||
@@ -109,6 +110,7 @@ describe('ImageCanvasStageControllerModel', () => {
|
||||
clientY: 1000,
|
||||
layerId: 'layer-a',
|
||||
canvasPoint: { x: 20, y: 30 },
|
||||
viewportBounds: { width: window.innerWidth, height: window.innerHeight },
|
||||
}),
|
||||
).toEqual({
|
||||
contextMenu: {
|
||||
|
||||
@@ -1,156 +1 @@
|
||||
import {
|
||||
resolveContextMenuPosition,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import type {
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
ImageContextMenuState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
getCanvasLayersByIds,
|
||||
resolveContextTargetLayerIds,
|
||||
} from './ImageCanvasLayerCommandModel';
|
||||
import {
|
||||
resolveSelectedToolbarStyle,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
import {
|
||||
getSelectedGenerationDialogIds,
|
||||
getSelectedLayerIds,
|
||||
} from './ImageCanvasSelectionModel';
|
||||
|
||||
export type ImageCanvasStageControllerModel = {
|
||||
selectedLayer: CanvasLayer | null;
|
||||
selectedLayerCount: number;
|
||||
hasMultipleSelectedLayers: boolean;
|
||||
selectedToolbarStyle: ReturnType<typeof resolveSelectedToolbarStyle>;
|
||||
imageContextMenuLayer: CanvasLayer | null;
|
||||
contextTargetIds: string[];
|
||||
contextTargetLayers: CanvasLayer[];
|
||||
contextShouldShowLayer: boolean;
|
||||
contextShouldUnlockLayer: boolean;
|
||||
};
|
||||
|
||||
type ResolveImageCanvasStageControllerModelOptions = {
|
||||
layers: CanvasLayer[];
|
||||
canvasGenerationDialogs?: CanvasGenerationDialogState[];
|
||||
selectedLayerId: string | null;
|
||||
selectedLayerIds: string[];
|
||||
imageContextMenu: ImageContextMenuState | null;
|
||||
contextMenu: CanvasContextMenuState | null;
|
||||
viewport: CanvasViewport;
|
||||
canvasSize: { width: number; height: number };
|
||||
};
|
||||
|
||||
export function resolveImageCanvasStageControllerModel({
|
||||
layers,
|
||||
canvasGenerationDialogs = [],
|
||||
selectedLayerId,
|
||||
selectedLayerIds,
|
||||
imageContextMenu,
|
||||
contextMenu,
|
||||
viewport,
|
||||
canvasSize,
|
||||
}: ResolveImageCanvasStageControllerModelOptions): ImageCanvasStageControllerModel {
|
||||
const selectedLayer =
|
||||
layers.find((layer) => layer.id === selectedLayerId) ?? null;
|
||||
const selectedCanvasLayerIds = getSelectedLayerIds(selectedLayerIds);
|
||||
const selectedGenerationDialogIds =
|
||||
getSelectedGenerationDialogIds(selectedLayerIds);
|
||||
const selectedLayerCount =
|
||||
selectedCanvasLayerIds.filter((layerId) =>
|
||||
layers.some((layer) => layer.id === layerId),
|
||||
).length +
|
||||
selectedGenerationDialogIds.filter((dialogId) =>
|
||||
canvasGenerationDialogs.some((dialog) => dialog.id === dialogId),
|
||||
).length;
|
||||
const imageContextMenuLayer = imageContextMenu
|
||||
? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null)
|
||||
: null;
|
||||
const contextTargetIds = resolveContextTargetLayerIds(
|
||||
contextMenu,
|
||||
selectedLayerIds,
|
||||
);
|
||||
const contextTargetLayers = getCanvasLayersByIds(layers, contextTargetIds);
|
||||
|
||||
return {
|
||||
selectedLayer,
|
||||
selectedLayerCount,
|
||||
hasMultipleSelectedLayers: selectedLayerCount > 1,
|
||||
selectedToolbarStyle: resolveSelectedToolbarStyle({
|
||||
selectedLayer,
|
||||
viewport,
|
||||
canvasSize,
|
||||
}),
|
||||
imageContextMenuLayer,
|
||||
contextTargetIds,
|
||||
contextTargetLayers,
|
||||
contextShouldShowLayer: contextTargetLayers.some((layer) => layer.hidden),
|
||||
contextShouldUnlockLayer: contextTargetLayers.some((layer) => layer.locked),
|
||||
};
|
||||
}
|
||||
|
||||
export function createBlankCanvasContextMenu({
|
||||
clientX,
|
||||
clientY,
|
||||
canvasPoint,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
canvasPoint: { x: number; y: number };
|
||||
}): CanvasContextMenuState {
|
||||
return {
|
||||
kind: 'blank',
|
||||
...resolveContextMenuPosition(clientX, clientY, 'blank'),
|
||||
canvasPoint,
|
||||
};
|
||||
}
|
||||
|
||||
export function createLayerCanvasContextMenus({
|
||||
clientX,
|
||||
clientY,
|
||||
layerId,
|
||||
canvasPoint,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
layerId: string;
|
||||
canvasPoint: { x: number; y: number };
|
||||
}): {
|
||||
contextMenu: CanvasContextMenuState;
|
||||
imageContextMenu: ImageContextMenuState;
|
||||
} {
|
||||
const position = resolveContextMenuPosition(clientX, clientY, 'layer');
|
||||
return {
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
layerId,
|
||||
...position,
|
||||
canvasPoint,
|
||||
},
|
||||
imageContextMenu: {
|
||||
layerId,
|
||||
...position,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createGenerationDialogCanvasContextMenu({
|
||||
clientX,
|
||||
clientY,
|
||||
dialogId,
|
||||
canvasPoint,
|
||||
}: {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
dialogId: string;
|
||||
canvasPoint: { x: number; y: number };
|
||||
}): CanvasContextMenuState {
|
||||
return {
|
||||
kind: 'generation-dialog',
|
||||
dialogId,
|
||||
...resolveContextMenuPosition(clientX, clientY, 'generation-dialog'),
|
||||
canvasPoint,
|
||||
};
|
||||
}
|
||||
export * from '../../../packages/image-canvas-core/src/stageController';
|
||||
|
||||
@@ -1,440 +1 @@
|
||||
import type {
|
||||
CanvasGenerationDialogState,
|
||||
CanvasLayer,
|
||||
CanvasMarqueeState,
|
||||
CanvasViewport,
|
||||
DragState,
|
||||
GenerateDialogState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import type { CanvasPoint } from './ImageCanvasInteractionModel';
|
||||
import {
|
||||
firstSelectedLayerId,
|
||||
getCanvasGenerationSelectionId,
|
||||
getSelectedGenerationDialogIds,
|
||||
getSelectedLayerIds,
|
||||
resolvePointerSelection,
|
||||
} from './ImageCanvasSelectionModel';
|
||||
|
||||
type PointerSource = {
|
||||
button?: number;
|
||||
buttons?: number;
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
pointerId?: number;
|
||||
nativeEvent?: {
|
||||
button?: number;
|
||||
buttons?: number;
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
pointerId?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type CanvasRectLike =
|
||||
| {
|
||||
left?: number;
|
||||
top?: number;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
const CANVAS_GENERATION_DIALOG_MODES = new Set([
|
||||
'generate',
|
||||
'scene',
|
||||
'spec',
|
||||
'character',
|
||||
'icon',
|
||||
'ui-design',
|
||||
'character-animation',
|
||||
'video',
|
||||
'audio-sound-effect',
|
||||
'audio-background-music',
|
||||
]);
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function hasCanvasGenerationDialogMode(
|
||||
dialog: GenerateDialogState | null,
|
||||
): dialog is GenerateDialogState & {
|
||||
mode: CanvasGenerationDialogState['mode'];
|
||||
} {
|
||||
if (!dialog) {
|
||||
return false;
|
||||
}
|
||||
return CANVAS_GENERATION_DIALOG_MODES.has(dialog.mode);
|
||||
}
|
||||
|
||||
export function getPointerButton(event: PointerSource) {
|
||||
const nativeButtons = Number(event.nativeEvent?.buttons);
|
||||
if (Number.isFinite(nativeButtons)) {
|
||||
if ((nativeButtons & 2) === 2) {
|
||||
return 2;
|
||||
}
|
||||
if ((nativeButtons & 4) === 4) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
const syntheticButtons = Number(event.buttons);
|
||||
if (Number.isFinite(syntheticButtons)) {
|
||||
if ((syntheticButtons & 2) === 2) {
|
||||
return 2;
|
||||
}
|
||||
if ((syntheticButtons & 4) === 4) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
const syntheticButton = Number(event.button);
|
||||
if (Number.isFinite(syntheticButton)) {
|
||||
return syntheticButton;
|
||||
}
|
||||
const nativeButton = Number(event.nativeEvent?.button);
|
||||
if (Number.isFinite(nativeButton)) {
|
||||
return nativeButton;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getPointerClient(event: PointerSource): CanvasPoint {
|
||||
return {
|
||||
x: isFiniteNumber(event.clientX)
|
||||
? event.clientX
|
||||
: isFiniteNumber(event.nativeEvent?.clientX)
|
||||
? event.nativeEvent.clientX
|
||||
: 0,
|
||||
y: isFiniteNumber(event.clientY)
|
||||
? event.clientY
|
||||
: isFiniteNumber(event.nativeEvent?.clientY)
|
||||
? event.nativeEvent.clientY
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function getPointerId(event: PointerSource) {
|
||||
return isFiniteNumber(event.pointerId)
|
||||
? event.pointerId
|
||||
: isFiniteNumber(event.nativeEvent?.pointerId)
|
||||
? event.nativeEvent.pointerId
|
||||
: -1;
|
||||
}
|
||||
|
||||
export function getCanvasPointFromPointer({
|
||||
pointer,
|
||||
rect,
|
||||
}: {
|
||||
pointer: CanvasPoint;
|
||||
rect: CanvasRectLike;
|
||||
}): CanvasPoint {
|
||||
return {
|
||||
x: pointer.x - (rect?.left ?? 0),
|
||||
y: pointer.y - (rect?.top ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function createCanvasMarqueeState({
|
||||
pointerId,
|
||||
pointer,
|
||||
rect,
|
||||
}: {
|
||||
pointerId: number;
|
||||
pointer: CanvasPoint;
|
||||
rect: CanvasRectLike;
|
||||
}): CanvasMarqueeState {
|
||||
const startPoint = getCanvasPointFromPointer({ pointer, rect });
|
||||
return {
|
||||
pointerId,
|
||||
startX: startPoint.x,
|
||||
startY: startPoint.y,
|
||||
currentX: startPoint.x,
|
||||
currentY: startPoint.y,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPanDragState({
|
||||
pointerId,
|
||||
pointer,
|
||||
viewport,
|
||||
}: {
|
||||
pointerId: number;
|
||||
pointer: CanvasPoint;
|
||||
viewport: CanvasViewport;
|
||||
}): Extract<DragState, { kind: 'pan' }> {
|
||||
return {
|
||||
kind: 'pan',
|
||||
pointerId,
|
||||
startClientX: pointer.x,
|
||||
startClientY: pointer.y,
|
||||
startViewport: viewport,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveLayerPointerSelection({
|
||||
layerId,
|
||||
selectedLayerIds,
|
||||
isMultiSelectGesture,
|
||||
}: {
|
||||
layerId: string;
|
||||
selectedLayerIds: string[];
|
||||
isMultiSelectGesture: boolean;
|
||||
}) {
|
||||
return resolvePointerSelection({
|
||||
targetId: layerId,
|
||||
selectedIds: selectedLayerIds,
|
||||
isMultiSelectGesture,
|
||||
});
|
||||
}
|
||||
|
||||
export function createLayerDragStart({
|
||||
layer,
|
||||
layers,
|
||||
generationDialogs = [],
|
||||
selectedLayerIds,
|
||||
isMultiSelectGesture,
|
||||
pointerId,
|
||||
pointer,
|
||||
viewportScale,
|
||||
}: {
|
||||
layer: CanvasLayer;
|
||||
layers: CanvasLayer[];
|
||||
generationDialogs?: CanvasGenerationDialogState[];
|
||||
selectedLayerIds: string[];
|
||||
isMultiSelectGesture: boolean;
|
||||
pointerId: number;
|
||||
pointer: CanvasPoint;
|
||||
viewportScale: number;
|
||||
}): {
|
||||
selectedLayerIds: string[];
|
||||
selectedLayerId: string | null;
|
||||
dragState: Extract<DragState, { kind: 'layer' }>;
|
||||
} {
|
||||
const nextSelectedLayerIds = resolveLayerPointerSelection({
|
||||
layerId: layer.id,
|
||||
selectedLayerIds,
|
||||
isMultiSelectGesture,
|
||||
});
|
||||
const selectedCanvasLayerIds = getSelectedLayerIds(nextSelectedLayerIds);
|
||||
const selectedGenerationDialogIds =
|
||||
getSelectedGenerationDialogIds(nextSelectedLayerIds);
|
||||
const dragLayerIds = selectedCanvasLayerIds.includes(layer.id)
|
||||
? selectedCanvasLayerIds
|
||||
: [layer.id];
|
||||
const startLayers = layers
|
||||
.filter((currentLayer) => dragLayerIds.includes(currentLayer.id))
|
||||
.map((currentLayer) => ({
|
||||
id: currentLayer.id,
|
||||
x: currentLayer.x,
|
||||
y: currentLayer.y,
|
||||
}));
|
||||
const startGenerationFrames = generationDialogs
|
||||
.filter(
|
||||
(dialog) =>
|
||||
selectedGenerationDialogIds.includes(dialog.id) && dialog.placeholder,
|
||||
)
|
||||
.map((dialog) => ({
|
||||
id: dialog.id,
|
||||
x: dialog.placeholder?.x ?? 0,
|
||||
y: dialog.placeholder?.y ?? 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
selectedLayerIds: nextSelectedLayerIds,
|
||||
selectedLayerId: nextSelectedLayerIds.includes(layer.id)
|
||||
? layer.id
|
||||
: firstSelectedLayerId(nextSelectedLayerIds),
|
||||
dragState: {
|
||||
kind: 'layer',
|
||||
pointerId,
|
||||
layerId: layer.id,
|
||||
layerIds: dragLayerIds,
|
||||
generationDialogIds: selectedGenerationDialogIds,
|
||||
startClientX: pointer.x,
|
||||
startClientY: pointer.y,
|
||||
startLayerX: layer.x,
|
||||
startLayerY: layer.y,
|
||||
startLayers,
|
||||
startGenerationFrames,
|
||||
startScale: viewportScale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function updateGenerateDialogForLayerPointerDown(
|
||||
dialog: GenerateDialogState | null,
|
||||
_layerId: string,
|
||||
): GenerateDialogState | null {
|
||||
if (!hasCanvasGenerationDialogMode(dialog)) {
|
||||
return dialog;
|
||||
}
|
||||
return {
|
||||
...dialog,
|
||||
composerOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateGenerateDialogForLayerClick(
|
||||
dialog: GenerateDialogState | null,
|
||||
): GenerateDialogState | null {
|
||||
if (!hasCanvasGenerationDialogMode(dialog)) {
|
||||
return dialog;
|
||||
}
|
||||
return {
|
||||
...dialog,
|
||||
composerOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createGenerationFrameSelectionStart({
|
||||
dialog,
|
||||
layers,
|
||||
generationDialogs,
|
||||
selectedLayerIds,
|
||||
isMultiSelectGesture,
|
||||
pointerId,
|
||||
pointer,
|
||||
viewportScale,
|
||||
}: {
|
||||
dialog: CanvasGenerationDialogState;
|
||||
layers: CanvasLayer[];
|
||||
generationDialogs: CanvasGenerationDialogState[];
|
||||
selectedLayerIds: string[];
|
||||
isMultiSelectGesture: boolean;
|
||||
pointerId: number;
|
||||
pointer: CanvasPoint;
|
||||
viewportScale: number;
|
||||
}): {
|
||||
selectedLayerIds: string[];
|
||||
selectedLayerId: string | null;
|
||||
dragState: Extract<DragState, { kind: 'generation-frame' }> | null;
|
||||
} {
|
||||
const targetSelectionId = getCanvasGenerationSelectionId(dialog.id);
|
||||
const nextSelectedLayerIds = resolvePointerSelection({
|
||||
targetId: targetSelectionId,
|
||||
selectedIds: selectedLayerIds,
|
||||
isMultiSelectGesture,
|
||||
});
|
||||
const selectedGenerationDialogIds =
|
||||
getSelectedGenerationDialogIds(nextSelectedLayerIds);
|
||||
const selectedCanvasLayerIds = getSelectedLayerIds(nextSelectedLayerIds);
|
||||
const dragDialogIds = selectedGenerationDialogIds.includes(dialog.id)
|
||||
? selectedGenerationDialogIds
|
||||
: [dialog.id];
|
||||
const startFrames = generationDialogs
|
||||
.filter(
|
||||
(currentDialog) =>
|
||||
dragDialogIds.includes(currentDialog.id) && currentDialog.placeholder,
|
||||
)
|
||||
.map((currentDialog) => ({
|
||||
id: currentDialog.id,
|
||||
x: currentDialog.placeholder?.x ?? 0,
|
||||
y: currentDialog.placeholder?.y ?? 0,
|
||||
}));
|
||||
const startLayers = layers
|
||||
.filter((currentLayer) => selectedCanvasLayerIds.includes(currentLayer.id))
|
||||
.map((currentLayer) => ({
|
||||
id: currentLayer.id,
|
||||
x: currentLayer.x,
|
||||
y: currentLayer.y,
|
||||
}));
|
||||
|
||||
return {
|
||||
selectedLayerIds: nextSelectedLayerIds,
|
||||
selectedLayerId: firstSelectedLayerId(nextSelectedLayerIds),
|
||||
dragState: createGenerationFrameDragState({
|
||||
dialog,
|
||||
dialogIds: dragDialogIds,
|
||||
layerIds: selectedCanvasLayerIds,
|
||||
startFrames,
|
||||
startLayers,
|
||||
pointerId,
|
||||
pointer,
|
||||
viewportScale,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createGenerationFrameDragState({
|
||||
dialog,
|
||||
dialogIds = [dialog.id],
|
||||
layerIds = [],
|
||||
startFrames,
|
||||
startLayers = [],
|
||||
pointerId,
|
||||
pointer,
|
||||
viewportScale,
|
||||
}: {
|
||||
dialog: CanvasGenerationDialogState;
|
||||
dialogIds?: string[];
|
||||
layerIds?: string[];
|
||||
startFrames?: Array<{ id: string; x: number; y: number }>;
|
||||
startLayers?: Array<{ id: string; x: number; y: number }>;
|
||||
pointerId: number;
|
||||
pointer: CanvasPoint;
|
||||
viewportScale: number;
|
||||
}): Extract<DragState, { kind: 'generation-frame' }> | null {
|
||||
if (!dialog.placeholder) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: 'generation-frame',
|
||||
dialogId: dialog.id,
|
||||
dialogIds,
|
||||
layerIds,
|
||||
pointerId,
|
||||
startClientX: pointer.x,
|
||||
startClientY: pointer.y,
|
||||
startFrameX: dialog.placeholder.x,
|
||||
startFrameY: dialog.placeholder.y,
|
||||
startFrames:
|
||||
startFrames && startFrames.length
|
||||
? startFrames
|
||||
: [
|
||||
{
|
||||
id: dialog.id,
|
||||
x: dialog.placeholder.x,
|
||||
y: dialog.placeholder.y,
|
||||
},
|
||||
],
|
||||
startLayers,
|
||||
startScale: viewportScale,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMinimapDragState({
|
||||
pointerId,
|
||||
pointer,
|
||||
viewport,
|
||||
minimapScale,
|
||||
}: {
|
||||
pointerId: number;
|
||||
pointer: CanvasPoint;
|
||||
viewport: CanvasViewport;
|
||||
minimapScale: number;
|
||||
}): Extract<DragState, { kind: 'minimap' }> {
|
||||
return {
|
||||
kind: 'minimap',
|
||||
pointerId,
|
||||
startClientX: pointer.x,
|
||||
startClientY: pointer.y,
|
||||
startViewport: { ...viewport },
|
||||
minimapScale,
|
||||
moved: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateMinimapDragMovement(
|
||||
dragState: Extract<DragState, { kind: 'minimap' }>,
|
||||
pointer: CanvasPoint,
|
||||
): Extract<DragState, { kind: 'minimap' }> {
|
||||
if (dragState.moved) {
|
||||
return dragState;
|
||||
}
|
||||
const deltaX = pointer.x - dragState.startClientX;
|
||||
const deltaY = pointer.y - dragState.startClientY;
|
||||
return Math.hypot(deltaX, deltaY) >= 2
|
||||
? {
|
||||
...dragState,
|
||||
moved: true,
|
||||
}
|
||||
: dragState;
|
||||
}
|
||||
export * from '../../../packages/image-canvas-core/src/stageInteractions';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CanvasViewport as SharedCanvasViewport } from '@genarrative/image-canvas-react';
|
||||
import type {
|
||||
CSSProperties,
|
||||
DragEvent as ReactDragEvent,
|
||||
@@ -342,15 +343,14 @@ export function ImageCanvasStageView({
|
||||
onCloseToolOptions,
|
||||
}: ImageCanvasStageViewProps) {
|
||||
return (
|
||||
<div
|
||||
<SharedCanvasViewport
|
||||
ref={canvasViewportRef}
|
||||
className={`image-canvas-editor__viewport ${isPanning ? 'image-canvas-editor__viewport--panning' : ''} image-canvas-editor__viewport--tool-${effectiveTool}`}
|
||||
style={{
|
||||
backgroundColor: canvasBackgroundColor,
|
||||
pointerEvents: isInteractionPaused ? 'none' : undefined,
|
||||
}}
|
||||
backgroundColor={canvasBackgroundColor}
|
||||
isInteractionPaused={isInteractionPaused}
|
||||
isPanning={isPanning}
|
||||
tool={effectiveTool}
|
||||
aria-label="画布工作区"
|
||||
aria-disabled={isInteractionPaused || undefined}
|
||||
onPointerDown={isInteractionPaused ? undefined : onCanvasPointerDown}
|
||||
onPointerMove={isInteractionPaused ? undefined : onCanvasPointerMove}
|
||||
onPointerUp={isInteractionPaused ? undefined : onCanvasPointerUp}
|
||||
@@ -570,6 +570,6 @@ export function ImageCanvasStageView({
|
||||
/>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</SharedCanvasViewport>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CanvasChromeButton } from '@genarrative/image-canvas-react';
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -14,7 +15,6 @@ import { useState } from 'react';
|
||||
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
||||
import { PlatformIconButton } from '../common/PlatformIconButton';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import type { CanvasLayer, CanvasViewport } from './ImageCanvasEditorTypes';
|
||||
@@ -321,9 +321,9 @@ export function ImageCanvasUiAssetExtractionOverlayView({
|
||||
const Icon = option.icon;
|
||||
const isActive = state.tool === option.value;
|
||||
return (
|
||||
<PlatformIconButton
|
||||
<CanvasChromeButton
|
||||
key={option.value}
|
||||
aria-pressed={isActive}
|
||||
pressed={isActive}
|
||||
className={
|
||||
isActive
|
||||
? 'image-canvas-editor__ui-extraction-tool--active'
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
CanvasWorld as SharedCanvasWorld,
|
||||
LayerRenderer,
|
||||
SelectionOverlay,
|
||||
} from '@genarrative/image-canvas-react';
|
||||
import {
|
||||
AppWindow,
|
||||
Clapperboard,
|
||||
@@ -606,7 +611,7 @@ function ImageCanvasVideoLayer({
|
||||
const { resolvedUrl: posterUrl } = useResolvedAssetReadUrl(
|
||||
layer.thumbnailSrc,
|
||||
{
|
||||
refreshKey: `${layer.taskId ?? layer.resourceId}:poster`,
|
||||
refreshKey: `${layer.taskId ?? layer.resourceId}:poster`,
|
||||
},
|
||||
);
|
||||
const [showGeneratedPreview, setShowGeneratedPreview] = useState(
|
||||
@@ -978,14 +983,11 @@ export function ImageCanvasWorldView({
|
||||
}, [openLayerKindMenuId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
<SharedCanvasWorld
|
||||
className="image-canvas-editor__world"
|
||||
data-testid="image-canvas-world"
|
||||
style={{
|
||||
width: CANVAS_WORLD_SIZE,
|
||||
height: CANVAS_WORLD_SIZE,
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
|
||||
}}
|
||||
viewport={viewport}
|
||||
worldSize={CANVAS_WORLD_SIZE}
|
||||
>
|
||||
{snapGuide?.vertical !== undefined ? (
|
||||
<div
|
||||
@@ -1021,23 +1023,17 @@ export function ImageCanvasWorldView({
|
||||
? '修改中'
|
||||
: null;
|
||||
const isMediaContainer = isCanvasMediaContainerLayer(layer);
|
||||
const LayerElement = isMediaContainer ? 'div' : 'button';
|
||||
const metadataMediaLabel = getLayerMetadataMediaLabel(layer);
|
||||
return (
|
||||
<div key={layer.id}>
|
||||
<LayerElement
|
||||
{...(isMediaContainer
|
||||
? { role: 'button', tabIndex: 0 }
|
||||
: { type: 'button' })}
|
||||
<LayerRenderer
|
||||
as={isMediaContainer ? 'div' : 'button'}
|
||||
tabIndex={isMediaContainer ? 0 : undefined}
|
||||
layer={layer}
|
||||
selected={isSelected}
|
||||
hovered={isHovered}
|
||||
generating={Boolean(layerGeneratingLabel)}
|
||||
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''} ${layerGeneratingLabel ? 'image-canvas-editor__layer--generating' : ''} ${layer.locked ? 'image-canvas-editor__layer--locked' : ''}`}
|
||||
style={{
|
||||
left: layer.x,
|
||||
top: layer.y,
|
||||
width: layer.width,
|
||||
height: layer.height,
|
||||
zIndex: layer.zIndex,
|
||||
display: layer.hidden ? 'none' : undefined,
|
||||
}}
|
||||
onPointerDown={(event) => onLayerPointerDown(event, layer)}
|
||||
onClick={(event) => {
|
||||
setOpenLayerKindMenuId(null);
|
||||
@@ -1131,7 +1127,7 @@ export function ImageCanvasWorldView({
|
||||
{layerGeneratingLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</LayerElement>
|
||||
</LayerRenderer>
|
||||
{openLayerKindMenuId === layer.id ? (
|
||||
<div
|
||||
ref={openLayerKindMenuRef}
|
||||
@@ -1185,28 +1181,11 @@ export function ImageCanvasWorldView({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canvasMarquee ? (
|
||||
<div
|
||||
className="image-canvas-editor__canvas-marquee"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
left:
|
||||
(Math.min(canvasMarquee.startX, canvasMarquee.currentX) -
|
||||
viewport.x) /
|
||||
viewport.scale,
|
||||
top:
|
||||
(Math.min(canvasMarquee.startY, canvasMarquee.currentY) -
|
||||
viewport.y) /
|
||||
viewport.scale,
|
||||
width:
|
||||
Math.abs(canvasMarquee.currentX - canvasMarquee.startX) /
|
||||
viewport.scale,
|
||||
height:
|
||||
Math.abs(canvasMarquee.currentY - canvasMarquee.startY) /
|
||||
viewport.scale,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<SelectionOverlay
|
||||
className="image-canvas-editor__canvas-marquee"
|
||||
marquee={canvasMarquee}
|
||||
viewport={viewport}
|
||||
/>
|
||||
{cropExpandPanel && cropExpandSourceLayer ? (
|
||||
<div
|
||||
className="image-canvas-editor__crop-expand-frame"
|
||||
@@ -1378,6 +1357,6 @@ export function ImageCanvasWorldView({
|
||||
生成中
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
</div>
|
||||
</SharedCanvasWorld>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
ImageCanvasAssetPort,
|
||||
ImageCanvasCompletionPort,
|
||||
ImageCanvasGenerationPort,
|
||||
ImageCanvasHostCapabilities,
|
||||
ImageCanvasHostPort,
|
||||
ImageCanvasProjectPort,
|
||||
} from '@genarrative/image-canvas-core';
|
||||
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
EditorProjectSnapshot,
|
||||
} from '../../../services/image-editor/editorProjectClient';
|
||||
|
||||
export type WebImageCanvasHostServices = {
|
||||
requestLogin: () => void;
|
||||
openAccount: () => void;
|
||||
openWallet: () => void;
|
||||
navigateToProject: (projectId: string) => void;
|
||||
readCurrentProject: () => EditorProjectSnapshot | null;
|
||||
resolveCloudAsset: (resourceId: string) => EditorAssetSnapshot | null;
|
||||
};
|
||||
|
||||
export type WebImageCanvasHostAdapter = ImageCanvasHostPort & {
|
||||
readonly kind: 'web';
|
||||
readonly web: WebImageCanvasHostServices;
|
||||
};
|
||||
|
||||
const WEB_IMAGE_CANVAS_CAPABILITIES: ImageCanvasHostCapabilities = {
|
||||
account: true,
|
||||
wallet: true,
|
||||
cloudAssetLibrary: true,
|
||||
localProject: false,
|
||||
externalEditorGeneration: false,
|
||||
advancedBackgroundRemoval: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Composes the existing website workflows behind shared host ports. The
|
||||
* website remains the owner of auth, wallet, routing and editorProjectClient;
|
||||
* shared canvas modules only receive the four semantic port groups.
|
||||
*/
|
||||
export function createWebImageCanvasHostAdapter(input: {
|
||||
project: ImageCanvasProjectPort;
|
||||
asset: ImageCanvasAssetPort;
|
||||
generation: ImageCanvasGenerationPort;
|
||||
completion: ImageCanvasCompletionPort;
|
||||
web: WebImageCanvasHostServices;
|
||||
}): WebImageCanvasHostAdapter {
|
||||
return {
|
||||
kind: 'web',
|
||||
capabilities: WEB_IMAGE_CANVAS_CAPABILITIES,
|
||||
project: input.project,
|
||||
asset: input.asset,
|
||||
generation: input.generation,
|
||||
completion: input.completion,
|
||||
web: input.web,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user