Merge remote-tracking branch 'web/master' into feat/pixel_art2
# Conflicts: # .codex/skills/genarrative-external-editor-api/SKILL.md # .codex/skills/genarrative-external-editor-api/references/api-selection.md # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -428,6 +428,24 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
}),
|
||||
],
|
||||
layers: [
|
||||
...projectLayers,
|
||||
{
|
||||
itemType: 'generation-dialog',
|
||||
layerId: `generation-dialog:${canvasCompletion.dialogId}`,
|
||||
resourceId: `generation-dialog:${canvasCompletion.dialogId}`,
|
||||
dialog: {
|
||||
id: canvasCompletion.dialogId,
|
||||
mode: dialogMode,
|
||||
prompt,
|
||||
status: 'idle',
|
||||
composerOpen: false,
|
||||
generatedLayerId: layerId,
|
||||
placeholder,
|
||||
imageModel: model,
|
||||
aspectRatio: input.aspectRatio,
|
||||
imageSize: input.imageSize,
|
||||
},
|
||||
},
|
||||
{
|
||||
layerId,
|
||||
resourceId,
|
||||
@@ -450,24 +468,6 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
assetKind: input.assetKind ?? undefined,
|
||||
generationInputs: input.generationInputs,
|
||||
},
|
||||
{
|
||||
itemType: 'generation-dialog',
|
||||
layerId: `generation-dialog:${canvasCompletion.dialogId}`,
|
||||
resourceId: `generation-dialog:${canvasCompletion.dialogId}`,
|
||||
dialog: {
|
||||
id: canvasCompletion.dialogId,
|
||||
mode: dialogMode,
|
||||
prompt,
|
||||
status: 'idle',
|
||||
composerOpen: true,
|
||||
generatedLayerId: layerId,
|
||||
placeholder,
|
||||
imageModel: model,
|
||||
aspectRatio: input.aspectRatio,
|
||||
imageSize: input.imageSize,
|
||||
},
|
||||
},
|
||||
...projectLayers,
|
||||
],
|
||||
updatedAt: '2026-06-19T00:00:00.000Z',
|
||||
},
|
||||
@@ -887,10 +887,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
.getByAltText(/画布图片:生成图片/)
|
||||
.closest('button')!;
|
||||
expect(generatedLayer).toBeTruthy();
|
||||
const anchoredGenerateDialog = screen.getByRole('dialog', {
|
||||
name: '生成图片',
|
||||
});
|
||||
expect(anchoredGenerateDialog).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||||
expect(
|
||||
Number.isFinite(
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.top),
|
||||
@@ -978,10 +975,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
const generatedLayer = screen
|
||||
.getByAltText(/画布图片:生成图片/)
|
||||
.closest('button')!;
|
||||
const anchoredGenerateDialog = screen.getByRole('dialog', {
|
||||
name: '生成图片',
|
||||
});
|
||||
expect(anchoredGenerateDialog).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||||
expect(
|
||||
Number.parseFloat((generatedLayer as HTMLElement).style.left) +
|
||||
@@ -1522,7 +1516,28 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
expect(screen.getByAltText(/画布图片:角色规范/)).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText('规范')).toBeTruthy();
|
||||
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
expectedRevision: 1,
|
||||
layers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
itemType: 'generation-dialog',
|
||||
dialog: expect.objectContaining({
|
||||
mode: 'spec',
|
||||
status: 'idle',
|
||||
generatedLayerId: 'layer-editor-spec-role-1',
|
||||
specValues: expect.objectContaining({
|
||||
playSetting: '平台跳跃玩法',
|
||||
artStyle: '低多边形卡通',
|
||||
bodyRatio: '4',
|
||||
characterView: '左向三分之二侧身站姿',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => {
|
||||
@@ -2094,10 +2109,11 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
|
||||
const generatedImage = await screen.findByAltText(/画布图片:生成图片/u);
|
||||
const generatedLayerButton = generatedImage.closest('button')!;
|
||||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||||
fireEvent.click(generatedLayerButton);
|
||||
expect(generatedLayerButton.className).toContain(
|
||||
'image-canvas-editor__layer--selected',
|
||||
);
|
||||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||||
|
||||
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
|
||||
button: 0,
|
||||
@@ -3652,7 +3668,7 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
.closest('button') as HTMLElement;
|
||||
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
|
||||
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
|
||||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||||
|
||||
const metadataCornerButton = screen.getAllByRole('button', {
|
||||
name: /查看生成图片 .*图片信息/,
|
||||
|
||||
@@ -1152,6 +1152,8 @@ export function ImageCanvasEditorView({
|
||||
viewportRef,
|
||||
canvasGenerationDialogsRef,
|
||||
canvasBackgroundColorRef,
|
||||
selectedLayerIdRef,
|
||||
selectedLayerIdsRef,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -1161,6 +1163,8 @@ export function ImageCanvasEditorView({
|
||||
setProjectRenameValue,
|
||||
setViewport,
|
||||
setLayers,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
selectSingleLayer,
|
||||
setLayerCounter: (value: number) => {
|
||||
layerCounterRef.current = value;
|
||||
@@ -1170,6 +1174,8 @@ export function ImageCanvasEditorView({
|
||||
}),
|
||||
[
|
||||
applyCanvasBackgroundColor,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
restoreCanvasGenerationDialogs,
|
||||
selectSingleLayer,
|
||||
setLayers,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import { ApiClientError } from '../../services/apiClient';
|
||||
import { uploadEditorMediaAssetObjectFile } from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import {
|
||||
createEditorProjectResource,
|
||||
type EditorProjectLayerSnapshot,
|
||||
type EditorProjectResourceSnapshot,
|
||||
type EditorProjectSnapshot,
|
||||
loadEditorProject,
|
||||
@@ -43,6 +44,10 @@ import {
|
||||
type ProjectCoverSnapshotViewportSize,
|
||||
} from './ImageCanvasProjectCoverSnapshotModel';
|
||||
import { createProjectCoverSnapshotBlob } from './ImageCanvasProjectCoverSnapshotRenderer';
|
||||
import {
|
||||
firstSelectedLayerId,
|
||||
normalizeCanvasSelectionIds,
|
||||
} from './ImageCanvasSelectionModel';
|
||||
|
||||
type ProjectResourceOptions = {
|
||||
onCreated?: (resourceId: string) => void;
|
||||
@@ -76,6 +81,12 @@ type PendingProjectLayoutSave = {
|
||||
transportRetries?: number;
|
||||
};
|
||||
|
||||
type ActiveProjectLayoutSaveAttempt = {
|
||||
save: PendingProjectLayoutSave;
|
||||
authorityEpoch: number;
|
||||
ownerUserId: string | null | undefined;
|
||||
};
|
||||
|
||||
type CachedEditorProjectSnapshot = {
|
||||
project: EditorProjectSnapshot;
|
||||
cachedAt: number;
|
||||
@@ -88,11 +99,110 @@ type ApplyProjectSnapshotOptions = {
|
||||
allowProjectSwitch?: boolean;
|
||||
};
|
||||
|
||||
function canvasLayoutItemId(item: EditorProjectLayerSnapshot) {
|
||||
return typeof item.layerId === 'string' ? item.layerId : null;
|
||||
}
|
||||
|
||||
function mergePendingCanvasLayerLayout(
|
||||
authoritative: EditorProjectLayerSnapshot,
|
||||
pending: EditorProjectLayerSnapshot,
|
||||
): EditorProjectLayerSnapshot {
|
||||
if (pending.itemType === 'canvas-settings') {
|
||||
return pending;
|
||||
}
|
||||
if (
|
||||
authoritative.itemType === 'generation-dialog' &&
|
||||
pending.itemType === 'generation-dialog'
|
||||
) {
|
||||
const authoritativeDialog = (
|
||||
authoritative as { dialog?: CanvasGenerationDialogState }
|
||||
).dialog;
|
||||
const pendingDialog = (pending as { dialog?: CanvasGenerationDialogState })
|
||||
.dialog;
|
||||
if (authoritativeDialog && pendingDialog) {
|
||||
return {
|
||||
...authoritative,
|
||||
dialog: {
|
||||
...authoritativeDialog,
|
||||
...pendingDialog,
|
||||
status: authoritativeDialog.status,
|
||||
generatedLayerId: authoritativeDialog.generatedLayerId,
|
||||
errorMessage: authoritativeDialog.errorMessage,
|
||||
generationStartedAt: authoritativeDialog.generationStartedAt,
|
||||
generationFinishedAt: authoritativeDialog.generationFinishedAt,
|
||||
characterAnimationResult:
|
||||
authoritativeDialog.characterAnimationResult,
|
||||
composerOpen: authoritativeDialog.composerOpen,
|
||||
},
|
||||
};
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
return {
|
||||
...authoritative,
|
||||
// pendingItems 来自 serializeCanvasLayout,这些布局字段始终存在;直接
|
||||
// 覆盖才能保留 false / undefined 代表的解锁、取消分组和取消翻转。
|
||||
title: pending.title,
|
||||
x: pending.x,
|
||||
y: pending.y,
|
||||
width: pending.width,
|
||||
height: pending.height,
|
||||
originalWidth: pending.originalWidth,
|
||||
originalHeight: pending.originalHeight,
|
||||
zIndex: pending.zIndex,
|
||||
groupId: pending.groupId,
|
||||
hidden: pending.hidden,
|
||||
locked: pending.locked,
|
||||
flipX: pending.flipX,
|
||||
flipY: pending.flipY,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeAuthoritativeCanvasLayoutWithPendingLocalLayout({
|
||||
authoritativeItems,
|
||||
pendingItems,
|
||||
previousAuthoritativeItemIds,
|
||||
}: {
|
||||
authoritativeItems: EditorProjectLayerSnapshot[];
|
||||
pendingItems: EditorProjectLayerSnapshot[];
|
||||
previousAuthoritativeItemIds: ReadonlySet<string>;
|
||||
}) {
|
||||
const authoritativeById = new Map(
|
||||
authoritativeItems.flatMap((item) => {
|
||||
const id = canvasLayoutItemId(item);
|
||||
return id ? [[id, item] as const] : [];
|
||||
}),
|
||||
);
|
||||
const pendingIds = new Set<string>();
|
||||
const merged = pendingItems.flatMap((pendingItem) => {
|
||||
const id = canvasLayoutItemId(pendingItem);
|
||||
if (!id) {
|
||||
return [pendingItem];
|
||||
}
|
||||
pendingIds.add(id);
|
||||
const authoritativeItem = authoritativeById.get(id);
|
||||
if (authoritativeItem) {
|
||||
return [mergePendingCanvasLayerLayout(authoritativeItem, pendingItem)];
|
||||
}
|
||||
return previousAuthoritativeItemIds.has(id) ? [] : [pendingItem];
|
||||
});
|
||||
for (const authoritativeItem of authoritativeItems) {
|
||||
const id = canvasLayoutItemId(authoritativeItem);
|
||||
if (!id || pendingIds.has(id) || previousAuthoritativeItemIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
merged.push(authoritativeItem);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
type ImageCanvasProjectPersistenceRefs = {
|
||||
layersRef: RefObject<CanvasLayer[]>;
|
||||
viewportRef: RefObject<CanvasViewport>;
|
||||
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
|
||||
canvasBackgroundColorRef: RefObject<string>;
|
||||
selectedLayerIdRef: RefObject<string | null>;
|
||||
selectedLayerIdsRef: RefObject<string[]>;
|
||||
};
|
||||
|
||||
type ImageCanvasProjectPersistenceSetters = {
|
||||
@@ -100,6 +210,8 @@ type ImageCanvasProjectPersistenceSetters = {
|
||||
setProjectRenameValue: (title: string) => void;
|
||||
setViewport: (viewport: CanvasViewport) => void;
|
||||
setLayers: (layers: CanvasLayer[]) => void;
|
||||
setSelectedLayerId: (layerId: string | null) => void;
|
||||
setSelectedLayerIds: (layerIds: string[]) => void;
|
||||
selectSingleLayer: (layerId: string | null) => void;
|
||||
setLayerCounter: (value: number) => void;
|
||||
restoreCanvasGenerationDialogs: (
|
||||
@@ -395,6 +507,7 @@ export function useImageCanvasProjectPersistence({
|
||||
const authoritativeProjectIdRef = useRef<string | null>(null);
|
||||
const hasAuthoritativeProjectSnapshotRef = useRef(false);
|
||||
const acceptedAuthoritativeSnapshotSequenceRef = useRef(0);
|
||||
const authoritativeLayoutItemIdsRef = useRef<Set<string>>(new Set());
|
||||
const lastAuthoritativeOwnerUserIdRef = useRef<string | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -419,6 +532,8 @@ export function useImageCanvasProjectPersistence({
|
||||
);
|
||||
const isProjectLayoutSaveRunningRef = useRef(false);
|
||||
const activeProjectLayoutSavePromiseRef = useRef<Promise<void> | null>(null);
|
||||
const activeProjectLayoutSaveAttemptRef =
|
||||
useRef<ActiveProjectLayoutSaveAttempt | null>(null);
|
||||
const skipNextProjectLayoutSaveRef = useRef(false);
|
||||
const saveTimerRef = useRef<number | null>(null);
|
||||
const projectTitleRef = useRef('未命名画布');
|
||||
@@ -440,6 +555,8 @@ export function useImageCanvasProjectPersistence({
|
||||
setProjectRenameValue,
|
||||
setViewport,
|
||||
setLayers,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
selectSingleLayer,
|
||||
setLayerCounter,
|
||||
restoreCanvasGenerationDialogs,
|
||||
@@ -483,6 +600,12 @@ export function useImageCanvasProjectPersistence({
|
||||
...pendingSave,
|
||||
attemptExpectedRevision: expectedRevision,
|
||||
};
|
||||
const activeAttempt: ActiveProjectLayoutSaveAttempt = {
|
||||
save: attemptedSave,
|
||||
authorityEpoch: saveAuthorityEpoch,
|
||||
ownerUserId: saveOwnerUserId,
|
||||
};
|
||||
activeProjectLayoutSaveAttemptRef.current = activeAttempt;
|
||||
const saveStillBelongsToCurrentAuthority = () =>
|
||||
projectAuthorityEpochRef.current === saveAuthorityEpoch &&
|
||||
currentUserIdRef.current === saveOwnerUserId &&
|
||||
@@ -497,11 +620,15 @@ export function useImageCanvasProjectPersistence({
|
||||
if (
|
||||
saveStillBelongsToCurrentAuthority() &&
|
||||
result &&
|
||||
typeof result.revision === 'number'
|
||||
typeof result.revision === 'number' &&
|
||||
result.revision > (projectRevisionRef.current ?? -1)
|
||||
) {
|
||||
projectRevisionRef.current = Math.max(
|
||||
projectRevisionRef.current ?? 0,
|
||||
result.revision,
|
||||
projectRevisionRef.current = result.revision;
|
||||
authoritativeLayoutItemIdsRef.current = new Set(
|
||||
attemptedSave.input.layers.flatMap((item) => {
|
||||
const id = canvasLayoutItemId(item);
|
||||
return id ? [id] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
runNextSave = Boolean(pendingProjectLayoutSaveRef.current);
|
||||
@@ -523,6 +650,7 @@ export function useImageCanvasProjectPersistence({
|
||||
return;
|
||||
}
|
||||
applyProjectSnapshotRef.current?.(latestProject);
|
||||
runNextSave = Boolean(pendingProjectLayoutSaveRef.current);
|
||||
};
|
||||
const scheduleAuthoritativeReload = (retryCount: number) => {
|
||||
if (
|
||||
@@ -593,6 +721,9 @@ export function useImageCanvasProjectPersistence({
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeProjectLayoutSaveAttemptRef.current === activeAttempt) {
|
||||
activeProjectLayoutSaveAttemptRef.current = null;
|
||||
}
|
||||
if (activeProjectLayoutSavePromiseRef.current === savePromise) {
|
||||
activeProjectLayoutSavePromiseRef.current = null;
|
||||
}
|
||||
@@ -1054,6 +1185,22 @@ export function useImageCanvasProjectPersistence({
|
||||
}
|
||||
acceptedAuthoritativeSnapshotSequenceRef.current += 1;
|
||||
}
|
||||
const pendingSave = pendingProjectLayoutSaveRef.current;
|
||||
const activeSaveAttempt = activeProjectLayoutSaveAttemptRef.current;
|
||||
const pendingLocalLayout = authoritative
|
||||
? pendingSave?.projectId === project.projectId
|
||||
? pendingSave
|
||||
: activeSaveAttempt?.save.projectId === project.projectId &&
|
||||
activeSaveAttempt.authorityEpoch ===
|
||||
projectAuthorityEpochRef.current &&
|
||||
activeSaveAttempt.ownerUserId === currentUserIdRef.current
|
||||
? activeSaveAttempt.save
|
||||
: null
|
||||
: null;
|
||||
const previousAuthoritativeItemIds =
|
||||
authoritativeLayoutItemIdsRef.current;
|
||||
const applyingToCurrentProject =
|
||||
projectIdRef.current === project.projectId;
|
||||
clearPendingProjectLayoutSave();
|
||||
skipNextProjectLayoutSaveRef.current = true;
|
||||
if (projectIdRef.current !== project.projectId) {
|
||||
@@ -1080,7 +1227,25 @@ export function useImageCanvasProjectPersistence({
|
||||
projectTitleRef.current = nextProjectTitle;
|
||||
setProjectTitle(nextProjectTitle);
|
||||
setProjectRenameValue(nextProjectTitle);
|
||||
const nextViewport = canvasDisplayViewportToViewport(project.viewport);
|
||||
const appliedLayoutItems = pendingLocalLayout
|
||||
? mergeAuthoritativeCanvasLayoutWithPendingLocalLayout({
|
||||
authoritativeItems: project.layers,
|
||||
pendingItems: pendingLocalLayout.input.layers,
|
||||
previousAuthoritativeItemIds,
|
||||
})
|
||||
: project.layers;
|
||||
if (authoritative) {
|
||||
authoritativeLayoutItemIdsRef.current = new Set(
|
||||
project.layers.flatMap((item) => {
|
||||
const id = canvasLayoutItemId(item);
|
||||
return id ? [id] : [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
const appliedViewport = pendingLocalLayout
|
||||
? pendingLocalLayout.input.viewport
|
||||
: project.viewport;
|
||||
const nextViewport = canvasDisplayViewportToViewport(appliedViewport);
|
||||
setViewport(nextViewport);
|
||||
refs.viewportRef.current = nextViewport;
|
||||
const resourcesById = new Map<string, CanvasLayerResourceMetadata>(
|
||||
@@ -1110,14 +1275,41 @@ export function useImageCanvasProjectPersistence({
|
||||
]),
|
||||
);
|
||||
const { layerItems, generationDialogs, canvasBackgroundColor } =
|
||||
splitCanvasLayoutItems(project.layers, resourcesById, currentUserId);
|
||||
splitCanvasLayoutItems(
|
||||
appliedLayoutItems,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
);
|
||||
const hydratedLayers = layerItems
|
||||
.map((layer) => hydrateLayer(layer, resourcesById))
|
||||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||||
setLayerCounter(hydratedLayers.length);
|
||||
refs.layersRef.current = hydratedLayers;
|
||||
setLayers(hydratedLayers);
|
||||
selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
||||
if (applyingToCurrentProject) {
|
||||
const currentSelectionIds = refs.selectedLayerIdsRef.current.length
|
||||
? refs.selectedLayerIdsRef.current
|
||||
: refs.selectedLayerIdRef.current
|
||||
? [refs.selectedLayerIdRef.current]
|
||||
: [];
|
||||
const nextSelectionIds = normalizeCanvasSelectionIds({
|
||||
selectionIds: currentSelectionIds,
|
||||
layers: hydratedLayers,
|
||||
generationDialogs,
|
||||
});
|
||||
const nextSelectedLayerId = firstSelectedLayerId(nextSelectionIds);
|
||||
refs.selectedLayerIdRef.current = nextSelectedLayerId;
|
||||
refs.selectedLayerIdsRef.current = nextSelectionIds;
|
||||
setSelectedLayerId(nextSelectedLayerId);
|
||||
setSelectedLayerIds(nextSelectionIds);
|
||||
} else {
|
||||
const nextSelectedLayerId = hydratedLayers[0]?.id ?? null;
|
||||
refs.selectedLayerIdRef.current = nextSelectedLayerId;
|
||||
refs.selectedLayerIdsRef.current = nextSelectedLayerId
|
||||
? [nextSelectedLayerId]
|
||||
: [];
|
||||
selectSingleLayer(nextSelectedLayerId);
|
||||
}
|
||||
refs.canvasGenerationDialogsRef.current = generationDialogs;
|
||||
restoreCanvasGenerationDialogs(generationDialogs);
|
||||
applyCanvasBackgroundColor(
|
||||
@@ -1129,10 +1321,22 @@ export function useImageCanvasProjectPersistence({
|
||||
authoritativeProjectIdRef.current === project.projectId;
|
||||
if (projectIsAuthoritative) {
|
||||
writeEditorProjectSessionCache(
|
||||
project,
|
||||
pendingLocalLayout
|
||||
? {
|
||||
...project,
|
||||
viewport: appliedViewport,
|
||||
layers: appliedLayoutItems,
|
||||
}
|
||||
: project,
|
||||
currentUserId,
|
||||
project.canvas?.revision,
|
||||
);
|
||||
if (pendingLocalLayout) {
|
||||
queueProjectLayoutSave(project.projectId, {
|
||||
viewport: appliedViewport,
|
||||
layers: appliedLayoutItems,
|
||||
});
|
||||
}
|
||||
}
|
||||
return projectIsAuthoritative;
|
||||
},
|
||||
@@ -1140,11 +1344,14 @@ export function useImageCanvasProjectPersistence({
|
||||
applyCanvasBackgroundColor,
|
||||
clearPendingProjectLayoutSave,
|
||||
currentUserId,
|
||||
queueProjectLayoutSave,
|
||||
refs,
|
||||
restoreCanvasGenerationDialogs,
|
||||
selectSingleLayer,
|
||||
setLayerCounter,
|
||||
setLayers,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
setProjectRenameValue,
|
||||
setProjectTitle,
|
||||
setViewport,
|
||||
@@ -1176,6 +1383,8 @@ export function useImageCanvasProjectPersistence({
|
||||
hasAuthoritativeProjectSnapshotRef.current = false;
|
||||
authoritativeProjectIdRef.current = null;
|
||||
projectRevisionRef.current = null;
|
||||
authoritativeLayoutItemIdsRef.current = new Set();
|
||||
activeProjectLayoutSaveAttemptRef.current = null;
|
||||
pendingCreatedProjectResourceLayersRef.current = [];
|
||||
clearPendingProjectLayoutSave();
|
||||
setIsProjectReady(false);
|
||||
@@ -1184,6 +1393,8 @@ export function useImageCanvasProjectPersistence({
|
||||
hasAuthoritativeProjectSnapshotRef.current = false;
|
||||
authoritativeProjectIdRef.current = null;
|
||||
projectRevisionRef.current = null;
|
||||
authoritativeLayoutItemIdsRef.current = new Set();
|
||||
activeProjectLayoutSaveAttemptRef.current = null;
|
||||
clearPendingProjectLayoutSave();
|
||||
setIsProjectReady(false);
|
||||
let cancelled = false;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell';
|
||||
import type { SelectionStage } from './platformEntryActiveTypes';
|
||||
|
||||
const authUiMock = vi.hoisted(() => ({
|
||||
value: {
|
||||
@@ -15,10 +17,18 @@ const authUiMock = vi.hoisted(() => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const responsiveMock = vi.hoisted(() => ({
|
||||
isDesktopLayout: true,
|
||||
}));
|
||||
|
||||
vi.mock('../auth/AuthUiContext', () => ({
|
||||
useAuthUi: () => authUiMock.value,
|
||||
}));
|
||||
|
||||
vi.mock('./platformEntryResponsive', () => ({
|
||||
usePlatformDesktopLayout: () => responsiveMock.isDesktopLayout,
|
||||
}));
|
||||
|
||||
vi.mock('../creation-home/CreationLandingView', () => ({
|
||||
CreationLandingView: ({
|
||||
onOpenProject,
|
||||
@@ -102,10 +112,26 @@ vi.mock('./usePlatformProfileCenterController', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
function StatefulPlatformEntryFlowShell({
|
||||
initialStage,
|
||||
}: {
|
||||
initialStage: SelectionStage;
|
||||
}) {
|
||||
const [selectionStage, setSelectionStage] = useState(initialStage);
|
||||
|
||||
return (
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={(nextStage) => setSelectionStage(nextStage)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('PlatformEntryActiveFlowShell', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
authUiMock.value.openLoginModal.mockReset();
|
||||
responsiveMock.isDesktopLayout = true;
|
||||
});
|
||||
|
||||
it('keeps the active desktop rail and the shared account capsule', async () => {
|
||||
@@ -174,11 +200,12 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
).toBe('page');
|
||||
});
|
||||
|
||||
it('keeps creation, project, and profile reachable from the mobile dock', async () => {
|
||||
it('keeps only profile reachable from the mobile dock', async () => {
|
||||
responsiveMock.isDesktopLayout = false;
|
||||
const setSelectionStage = vi.fn();
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="project"
|
||||
selectionStage="platform"
|
||||
setSelectionStage={setSelectionStage}
|
||||
/>,
|
||||
);
|
||||
@@ -190,12 +217,7 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
within(navigation)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['创作', '项目', '我的']);
|
||||
expect(
|
||||
within(navigation)
|
||||
.getByRole('button', { name: '项目' })
|
||||
.getAttribute('aria-current'),
|
||||
).toBe('page');
|
||||
).toEqual(['我的']);
|
||||
|
||||
fireEvent.click(within(navigation).getByRole('button', { name: '我的' }));
|
||||
expect(window.location.pathname).toBe('/profile');
|
||||
@@ -204,6 +226,48 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['creation-home', 'project', 'image-editor'] as const)(
|
||||
'shows the desktop guide instead of mounting the %s page on mobile',
|
||||
async (selectionStage) => {
|
||||
responsiveMock.isDesktopLayout = false;
|
||||
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage={selectionStage}
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('main', { name: '桌面端创作提示' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole('main', { name: '陶泥儿创作主页' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('main', { name: '项目' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole('main', { name: '图片画布编辑器' }),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it('shows the desktop guide instead of the editor after opening a canvas tool from the mobile root', async () => {
|
||||
responsiveMock.isDesktopLayout = false;
|
||||
|
||||
render(<StatefulPlatformEntryFlowShell initialStage="platform" />);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole('button', { name: '打开音乐工具' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('main', { name: '桌面端创作提示' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole('main', { name: '图片画布编辑器' }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('switches between creation and projects and passes search to the active page', async () => {
|
||||
const setSelectionStage = vi.fn();
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { FolderKanban, Palette, Search, UserRound } from 'lucide-react';
|
||||
import {
|
||||
FolderKanban,
|
||||
Home,
|
||||
Monitor,
|
||||
Palette,
|
||||
Search,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ComponentType,
|
||||
type FormEvent,
|
||||
@@ -20,6 +27,8 @@ import {
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import {
|
||||
resolveActivePublicUserCode,
|
||||
resolveActiveUserAvatarLabel,
|
||||
@@ -161,6 +170,45 @@ function LoadingPanel({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCreationDesktopGuide({
|
||||
onReturnHome,
|
||||
}: {
|
||||
onReturnHome: () => void;
|
||||
}) {
|
||||
return (
|
||||
<main
|
||||
className="flex min-h-full flex-1 items-center justify-center px-4 py-8"
|
||||
aria-label="桌面端创作提示"
|
||||
>
|
||||
<PlatformSubpanel
|
||||
as="section"
|
||||
radius="xl"
|
||||
padding="none"
|
||||
className="platform-remap-surface w-full max-w-sm px-5 py-6 text-center"
|
||||
>
|
||||
<span className="mx-auto flex h-14 w-14 items-center justify-center rounded-[1.05rem] bg-[rgba(199,101,50,0.12)] text-[var(--platform-accent-strong)]">
|
||||
<Monitor aria-hidden="true" className="h-7 w-7" />
|
||||
</span>
|
||||
<h1 className="mt-4 text-xl font-black leading-tight text-[var(--platform-text-strong)]">
|
||||
请在桌面端打开创作工具
|
||||
</h1>
|
||||
<p className="mt-3 text-sm font-semibold leading-6 text-[var(--platform-text-base)]">
|
||||
图片画布、项目管理和素材库需要更大的操作空间。
|
||||
</p>
|
||||
<PlatformActionButton
|
||||
onClick={onReturnHome}
|
||||
size="md"
|
||||
shape="pill"
|
||||
className="mt-5 min-h-11 w-full"
|
||||
>
|
||||
<Home aria-hidden="true" className="h-4 w-4" />
|
||||
返回首页
|
||||
</PlatformActionButton>
|
||||
</PlatformSubpanel>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlatformEntryFlowShellImpl({
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
@@ -173,6 +221,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [isApiKeysOpen, setIsApiKeysOpen] = useState(false);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [activeSearchKeyword, setActiveSearchKeyword] = useState('');
|
||||
const [isMobileDesktopGuideOpen, setIsMobileDesktopGuideOpen] =
|
||||
useState(false);
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
|
||||
const refreshDashboard = useCallback(async () => {
|
||||
@@ -206,14 +256,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
});
|
||||
|
||||
const openCreation = useCallback(() => {
|
||||
if (!isDesktopLayout) {
|
||||
setIsMobileDesktopGuideOpen(true);
|
||||
return;
|
||||
}
|
||||
pushAppHistoryPath('/creation');
|
||||
setSelectionStage('creation-home', { path: '/creation' });
|
||||
}, [setSelectionStage]);
|
||||
}, [isDesktopLayout, setSelectionStage]);
|
||||
|
||||
const openProjects = useCallback(() => {
|
||||
if (!isDesktopLayout) {
|
||||
setIsMobileDesktopGuideOpen(true);
|
||||
return;
|
||||
}
|
||||
pushAppHistoryPath('/project');
|
||||
setSelectionStage('project', { path: '/project' });
|
||||
}, [setSelectionStage]);
|
||||
}, [isDesktopLayout, setSelectionStage]);
|
||||
|
||||
const openProfile = useCallback(() => {
|
||||
pushAppHistoryPath('/profile');
|
||||
@@ -222,6 +280,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const openEditorProject = useCallback(
|
||||
(projectId: string, options?: { guide?: boolean; tool?: string }) => {
|
||||
if (!isDesktopLayout) {
|
||||
setIsMobileDesktopGuideOpen(true);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
params.set('projectid', projectId);
|
||||
if (options?.guide) {
|
||||
@@ -234,7 +296,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
pushAppHistoryPath(path);
|
||||
setSelectionStage('image-editor', { path });
|
||||
},
|
||||
[setSelectionStage],
|
||||
[isDesktopLayout, setSelectionStage],
|
||||
);
|
||||
|
||||
const replaceWithProjectGallery = useCallback(() => {
|
||||
@@ -242,6 +304,26 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('project', { path: '/project' });
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const isMobileToolStage =
|
||||
!isDesktopLayout &&
|
||||
(selectionStage === 'creation-home' ||
|
||||
selectionStage === 'project' ||
|
||||
selectionStage === 'image-editor');
|
||||
|
||||
if ((!isDesktopLayout && isMobileDesktopGuideOpen) || isMobileToolStage) {
|
||||
return (
|
||||
<MobileCreationDesktopGuide
|
||||
onReturnHome={() => {
|
||||
setIsMobileDesktopGuideOpen(false);
|
||||
if (selectionStage === 'platform') {
|
||||
return;
|
||||
}
|
||||
setSelectionStage('platform', { path: '/' });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectionStage === 'image-editor') {
|
||||
return (
|
||||
<div className="image-editor-stage-shell flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
|
||||
@@ -469,22 +551,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
</div>
|
||||
<div className="platform-mobile-bottom-dock min-w-0 shrink-0 lg:hidden">
|
||||
<nav
|
||||
className="platform-bottom-nav grid grid-cols-3"
|
||||
className="platform-bottom-nav grid grid-cols-1"
|
||||
aria-label="移动平台导航"
|
||||
>
|
||||
<ActiveBottomNavButton
|
||||
active={isCreationStage}
|
||||
emphasized={isCreationStage}
|
||||
icon={Palette}
|
||||
label="创作"
|
||||
onClick={openCreation}
|
||||
/>
|
||||
<ActiveBottomNavButton
|
||||
active={selectionStage === 'project'}
|
||||
icon={FolderKanban}
|
||||
label="项目"
|
||||
onClick={openProjects}
|
||||
/>
|
||||
<ActiveBottomNavButton
|
||||
active={isProfileStage}
|
||||
icon={UserRound}
|
||||
|
||||
Reference in New Issue
Block a user