Merge branch 'master' into showcase-liked
Project CI / Repository checks (pull_request) Successful in 1m4s
Project CI / Frontend tests (pull_request) Successful in 3m10s
Project CI / Backend tests (pull_request) Successful in 4m18s
Project CI / Native shell tests (pull_request) Successful in 16m0s

This commit is contained in:
2026-08-12 13:53:14 +08:00
205 changed files with 49984 additions and 6559 deletions
+1
View File
@@ -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