修复画布背景色与任务计时显示
恢复生成任务列表的进行中计时显示并兼容微秒时间戳 替换画布背景色原生取色器为可拖拽色盘和色带 持久化画布背景色并在项目恢复时应用 补充背景色交互持久化和任务计时显示测试
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
canvasDisplayViewportToViewport,
|
||||
CANVAS_WORLD_ORIGIN,
|
||||
createLayerFromAsset,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
formatCanvasDisplayScalePercent,
|
||||
hydrateLayer,
|
||||
normalizeAssetLibrary,
|
||||
@@ -49,6 +50,42 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(normalizeCanvasBackgroundHex('#not-a-color')).toBeNull();
|
||||
});
|
||||
|
||||
it('serializes canvas background settings without treating them as layers', () => {
|
||||
const layout = serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [],
|
||||
canvasBackgroundColor: ' #ABC ',
|
||||
});
|
||||
|
||||
expect(layout).toEqual([
|
||||
expect.objectContaining({
|
||||
itemType: 'canvas-settings',
|
||||
layerId: 'canvas-settings:default',
|
||||
resourceId: 'canvas-settings:default',
|
||||
canvasBackgroundColor: '#aabbcc',
|
||||
}),
|
||||
]);
|
||||
|
||||
const { layerItems, generationDialogs, canvasBackgroundColor } =
|
||||
splitCanvasLayoutItems(layout);
|
||||
|
||||
expect(layerItems).toEqual([]);
|
||||
expect(generationDialogs).toEqual([]);
|
||||
expect(canvasBackgroundColor).toBe('#aabbcc');
|
||||
});
|
||||
|
||||
it('drops invalid canvas background settings from serialized layouts', () => {
|
||||
const layout = serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [],
|
||||
canvasBackgroundColor: '#not-a-color',
|
||||
});
|
||||
|
||||
expect(layout).toEqual([]);
|
||||
expect(splitCanvasLayoutItems(layout).canvasBackgroundColor).toBeUndefined();
|
||||
expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc');
|
||||
});
|
||||
|
||||
it('keeps only one default asset folder when normalizing the persisted library', () => {
|
||||
const library = normalizeAssetLibrary({
|
||||
folders: [
|
||||
|
||||
@@ -300,8 +300,15 @@ type CanvasGenerationDialogSnapshot = EditorProjectLayerSnapshot & {
|
||||
dialog: CanvasGenerationDialogState;
|
||||
};
|
||||
|
||||
type CanvasSettingsLayoutSnapshot = EditorProjectLayerSnapshot & {
|
||||
itemType: 'canvas-settings';
|
||||
canvasBackgroundColor?: string;
|
||||
};
|
||||
|
||||
export type CanvasLayoutItems = EditorProjectLayerSnapshot[];
|
||||
|
||||
const CANVAS_SETTINGS_LAYOUT_ITEM_ID = 'canvas-settings:default';
|
||||
|
||||
function isPersistedReferenceResourceId(resourceId: string | null | undefined) {
|
||||
const normalizedResourceId = resourceId?.trim();
|
||||
return Boolean(
|
||||
@@ -419,14 +426,37 @@ export function serializeCanvasGenerationDialog(
|
||||
};
|
||||
}
|
||||
|
||||
function serializeCanvasSettings({
|
||||
canvasBackgroundColor,
|
||||
}: {
|
||||
canvasBackgroundColor?: string | null;
|
||||
}): CanvasSettingsLayoutSnapshot | null {
|
||||
const normalizedBackgroundColor = canvasBackgroundColor
|
||||
? normalizeCanvasBackgroundHex(canvasBackgroundColor)
|
||||
: null;
|
||||
if (!normalizedBackgroundColor) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
itemType: 'canvas-settings',
|
||||
layerId: CANVAS_SETTINGS_LAYOUT_ITEM_ID,
|
||||
resourceId: CANVAS_SETTINGS_LAYOUT_ITEM_ID,
|
||||
canvasBackgroundColor: normalizedBackgroundColor,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeCanvasLayout({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
canvasBackgroundColor,
|
||||
}: {
|
||||
layers: CanvasLayer[];
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
canvasBackgroundColor?: string | null;
|
||||
}): CanvasLayoutItems {
|
||||
const canvasSettings = serializeCanvasSettings({ canvasBackgroundColor });
|
||||
return [
|
||||
...(canvasSettings ? [canvasSettings] : []),
|
||||
...layers.map(serializeLayer),
|
||||
...canvasGenerationDialogs.map(serializeCanvasGenerationDialog),
|
||||
];
|
||||
@@ -445,6 +475,12 @@ export function isCanvasGenerationDialogLayoutItem(
|
||||
);
|
||||
}
|
||||
|
||||
function isCanvasSettingsLayoutItem(
|
||||
item: EditorProjectLayerSnapshot,
|
||||
): item is CanvasSettingsLayoutSnapshot {
|
||||
return item.itemType === 'canvas-settings';
|
||||
}
|
||||
|
||||
export function splitCanvasLayoutItems(
|
||||
items: EditorProjectLayerSnapshot[],
|
||||
resourcesById: Map<string, CanvasLayerResourceMetadata> = new Map(),
|
||||
@@ -452,11 +488,23 @@ export function splitCanvasLayoutItems(
|
||||
): {
|
||||
layerItems: EditorProjectLayerSnapshot[];
|
||||
generationDialogs: CanvasGenerationDialogState[];
|
||||
canvasBackgroundColor?: string;
|
||||
} {
|
||||
const layerItems: EditorProjectLayerSnapshot[] = [];
|
||||
const generationDialogs: CanvasGenerationDialogState[] = [];
|
||||
let canvasBackgroundColor: string | undefined;
|
||||
|
||||
items.forEach((item) => {
|
||||
if (isCanvasSettingsLayoutItem(item)) {
|
||||
const normalizedBackgroundColor =
|
||||
typeof item.canvasBackgroundColor === 'string'
|
||||
? normalizeCanvasBackgroundHex(item.canvasBackgroundColor)
|
||||
: null;
|
||||
if (normalizedBackgroundColor) {
|
||||
canvasBackgroundColor = normalizedBackgroundColor;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isCanvasGenerationDialogLayoutItem(item)) {
|
||||
const dialog = hydrateCanvasGenerationDialog(
|
||||
item.dialog,
|
||||
@@ -471,7 +519,7 @@ export function splitCanvasLayoutItems(
|
||||
layerItems.push(item);
|
||||
});
|
||||
|
||||
return { layerItems, generationDialogs };
|
||||
return { layerItems, generationDialogs, canvasBackgroundColor };
|
||||
}
|
||||
|
||||
export function hydrateCanvasGenerationDialog(
|
||||
|
||||
@@ -314,6 +314,46 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the canvas background color from the persisted project layout', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-background',
|
||||
title: '背景色项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
itemType: 'canvas-settings',
|
||||
layerId: 'canvas-settings:default',
|
||||
resourceId: 'canvas-settings:default',
|
||||
canvasBackgroundColor: '#112233',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const viewport = screen.getByLabelText('画布工作区');
|
||||
await waitFor(() => {
|
||||
expect((viewport as HTMLElement).style.backgroundColor).toBe(
|
||||
'rgb(17, 34, 51)',
|
||||
);
|
||||
});
|
||||
|
||||
const panelToolbar = screen.getByRole('toolbar', { name: '画布面板入口' });
|
||||
fireEvent.click(
|
||||
within(panelToolbar).getByRole('button', { name: '画布背景色' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
within(
|
||||
screen.getByRole('dialog', { name: '画布背景设置' }),
|
||||
).getByLabelText('画布背景十六进制颜色') as HTMLInputElement
|
||||
).value,
|
||||
).toBe('#112233');
|
||||
});
|
||||
|
||||
it('shows the toolbar guide for a newly created blank project until a generator opens', async () => {
|
||||
loadEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-guide',
|
||||
@@ -500,7 +540,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(openAccountModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the same account wallet entry from the canvas topbar mud point button', async () => {
|
||||
it('opens the account recharge entry from the canvas topbar mud point button', async () => {
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
@@ -527,8 +567,10 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
fireEvent.click(walletButton);
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '兑换码' })).toBeTruthy();
|
||||
expect(screen.getByPlaceholderText('输入兑换码')).toBeTruthy();
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '账户充值' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByPlaceholderText('输入兑换码')).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the login modal immediately when entering the editor while logged out', async () => {
|
||||
@@ -1724,9 +1766,11 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
expect(within(settingsPanel).getByText('画布背景')).toBeTruthy();
|
||||
expect(within(settingsPanel).getByLabelText('画布背景色相')).toBeTruthy();
|
||||
expect(within(settingsPanel).getByLabelText('画布背景色盘')).toBeTruthy();
|
||||
expect(
|
||||
within(settingsPanel).getByLabelText('画布背景十六进制颜色'),
|
||||
).toBeTruthy();
|
||||
expect(settingsPanel.querySelector('input[type="color"]')).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
within(settingsPanel).getByRole('button', { name: '暖灰' }),
|
||||
@@ -1736,13 +1780,6 @@ describe('ImageCanvasEditorView', () => {
|
||||
'rgb(243, 240, 234)',
|
||||
);
|
||||
|
||||
fireEvent.change(within(settingsPanel).getByLabelText('自定义画布背景色'), {
|
||||
target: { value: '#ffffff' },
|
||||
});
|
||||
expect((viewport as HTMLElement).style.backgroundColor).toBe(
|
||||
'rgb(255, 255, 255)',
|
||||
);
|
||||
|
||||
const hexInput =
|
||||
within(settingsPanel).getByLabelText('画布背景十六进制颜色');
|
||||
fireEvent.change(hexInput, { target: { value: '#abc' } });
|
||||
|
||||
@@ -34,6 +34,7 @@ import { usePlatformProfileCenterController } from '../platform-entry/usePlatfor
|
||||
import { formatDashboardCount } from '../rpg-entry/rpgEntryProfileDashboardPresentation';
|
||||
import {
|
||||
canvasAssetKindOrNull,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
generationInputsOrNull,
|
||||
isInlineEditorMediaSource,
|
||||
resolveContextMenuPosition,
|
||||
@@ -308,6 +309,7 @@ export function ImageCanvasEditorView({
|
||||
const layersRef = useRef<CanvasLayer[]>([]);
|
||||
const canvasGenerationDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
|
||||
const viewportRef = useRef<CanvasViewport>(DEFAULT_IMAGE_CANVAS_VIEWPORT);
|
||||
const canvasBackgroundColorRef = useRef(DEFAULT_CANVAS_BACKGROUND_COLOR);
|
||||
const captureCanvasHistoryRef = useRef<() => void>(() => {});
|
||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
|
||||
@@ -623,6 +625,7 @@ export function ImageCanvasEditorView({
|
||||
toggleBackgroundSettings,
|
||||
toggleMinimap,
|
||||
} = useImageCanvasEditorChrome({ openEditorLoginModal });
|
||||
canvasBackgroundColorRef.current = canvasBackgroundColor;
|
||||
const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({
|
||||
layers,
|
||||
setLayers,
|
||||
@@ -1031,6 +1034,7 @@ export function ImageCanvasEditorView({
|
||||
layersRef,
|
||||
viewportRef,
|
||||
canvasGenerationDialogsRef,
|
||||
canvasBackgroundColorRef,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -1045,8 +1049,10 @@ export function ImageCanvasEditorView({
|
||||
layerCounterRef.current = value;
|
||||
},
|
||||
restoreCanvasGenerationDialogs,
|
||||
applyCanvasBackgroundColor,
|
||||
}),
|
||||
[
|
||||
applyCanvasBackgroundColor,
|
||||
restoreCanvasGenerationDialogs,
|
||||
selectSingleLayer,
|
||||
setLayers,
|
||||
@@ -1066,6 +1072,7 @@ export function ImageCanvasEditorView({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData: authUi ? authUi.canAccessProtectedData : true,
|
||||
currentUserId: currentEditorUserId,
|
||||
|
||||
@@ -43,6 +43,23 @@ function renderPanelDock(
|
||||
return props;
|
||||
}
|
||||
|
||||
function mockElementRect(
|
||||
element: Element,
|
||||
rect: Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>,
|
||||
) {
|
||||
Object.defineProperty(element, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
...rect,
|
||||
right: rect.left + rect.width,
|
||||
bottom: rect.top + rect.height,
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe('ImageCanvasPanelDockView', () => {
|
||||
it('renders panel dock actions and forwards common controls', () => {
|
||||
const props = renderPanelDock({
|
||||
@@ -98,9 +115,71 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
expect(screen.queryByRole('button', { name: '画布 Agent' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the background picker visible when the selected color is white', () => {
|
||||
const props = renderPanelDock({
|
||||
canvasBackgroundColor: '#ffffff',
|
||||
canvasBackgroundHexValue: '#ffffff',
|
||||
isBackgroundSettingsOpen: true,
|
||||
});
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '画布背景设置' });
|
||||
const spectrumButton = within(panel).getByRole('button', {
|
||||
name: '画布背景色盘',
|
||||
});
|
||||
const hueButton = within(panel).getByRole('button', {
|
||||
name: '画布背景色相',
|
||||
});
|
||||
const spectrumHandle = spectrumButton.querySelector(
|
||||
'.image-canvas-editor__background-spectrum-handle',
|
||||
) as HTMLElement;
|
||||
const hueHandle = hueButton.querySelector(
|
||||
'.image-canvas-editor__background-hue-handle',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(
|
||||
spectrumButton.style.getPropertyValue(
|
||||
'--image-canvas-background-hue-color',
|
||||
),
|
||||
).toBe('#0080ff');
|
||||
expect(
|
||||
spectrumHandle.style.getPropertyValue(
|
||||
'--image-canvas-background-spectrum-handle-left',
|
||||
),
|
||||
).toBe('clamp(0.42rem, 0%, calc(100% - 0.42rem))');
|
||||
expect(
|
||||
spectrumHandle.style.getPropertyValue(
|
||||
'--image-canvas-background-spectrum-handle-top',
|
||||
),
|
||||
).toBe('clamp(0.42rem, 0%, calc(100% - 0.42rem))');
|
||||
expect(
|
||||
hueHandle.style.getPropertyValue(
|
||||
'--image-canvas-background-hue-handle-left',
|
||||
),
|
||||
).toBe(
|
||||
'clamp(0.39rem, 58.333333333333336%, calc(100% - 0.39rem))',
|
||||
);
|
||||
|
||||
mockElementRect(hueButton, {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 20,
|
||||
});
|
||||
fireEvent.pointerDown(hueButton, {
|
||||
pointerId: 1,
|
||||
buttons: 1,
|
||||
clientX: 20,
|
||||
clientY: 10,
|
||||
});
|
||||
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#ffffff');
|
||||
});
|
||||
|
||||
it('renders zoom and background settings with callback wiring', () => {
|
||||
const props = renderPanelDock({
|
||||
viewport: { x: 0, y: 0, scale: 0.5 },
|
||||
canvasBackgroundColor: '#ff0000',
|
||||
canvasBackgroundHexValue: '#ff0000',
|
||||
isZoomMenuOpen: true,
|
||||
isBackgroundSettingsOpen: true,
|
||||
});
|
||||
@@ -122,9 +201,22 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '画布背景设置' });
|
||||
|
||||
expect(panel.querySelector('input[type="color"]')).toBeNull();
|
||||
fireEvent.click(within(panel).getByRole('button', { name: '暖灰' }));
|
||||
fireEvent.change(within(panel).getByLabelText('自定义画布背景色'), {
|
||||
target: { value: '#ffffff' },
|
||||
const spectrumButton = within(panel).getByRole('button', {
|
||||
name: '画布背景色盘',
|
||||
});
|
||||
mockElementRect(spectrumButton, {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
});
|
||||
fireEvent.pointerDown(spectrumButton, {
|
||||
pointerId: 1,
|
||||
buttons: 1,
|
||||
clientX: 50,
|
||||
clientY: 50,
|
||||
});
|
||||
fireEvent.change(within(panel).getByLabelText('画布背景十六进制颜色'), {
|
||||
target: { value: '#abc' },
|
||||
@@ -135,7 +227,7 @@ describe('ImageCanvasPanelDockView', () => {
|
||||
);
|
||||
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f3f0ea');
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#ffffff');
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#804040');
|
||||
expect(props.onCanvasBackgroundHexChange).toHaveBeenCalledWith('#abc');
|
||||
expect(props.onApplyCanvasBackgroundColor).toHaveBeenCalledWith('#f8fafc');
|
||||
expect(props.onToggleBackgroundSettings).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -8,7 +8,12 @@ import {
|
||||
Undo2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import type { PointerEvent as ReactPointerEvent } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type {
|
||||
CSSProperties,
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
@@ -19,8 +24,10 @@ import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton
|
||||
import {
|
||||
CANVAS_BACKGROUND_OPTIONS,
|
||||
canvasDisplayScaleToViewportScale,
|
||||
clamp,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
formatCanvasDisplayScalePercent,
|
||||
normalizeCanvasBackgroundHex,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type { CanvasViewport, SidebarPanel } from './ImageCanvasEditorTypes';
|
||||
@@ -54,6 +61,145 @@ type ImageCanvasPanelDockViewProps = {
|
||||
onMinimapPointerDown: (event: ReactPointerEvent<HTMLButtonElement>) => void;
|
||||
};
|
||||
|
||||
type CanvasBackgroundHsv = {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const DEFAULT_CANVAS_BACKGROUND_PICKER_HUE = 210;
|
||||
const ACHROMATIC_BACKGROUND_SATURATION_EPSILON = 0.001;
|
||||
|
||||
function hexToRgb(value: string) {
|
||||
const normalizedValue = normalizeCanvasBackgroundHex(value);
|
||||
if (!normalizedValue) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
red: Number.parseInt(normalizedValue.slice(1, 3), 16),
|
||||
green: Number.parseInt(normalizedValue.slice(3, 5), 16),
|
||||
blue: Number.parseInt(normalizedValue.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function rgbToHsv(red: number, green: number, blue: number) {
|
||||
const normalizedRed = red / 255;
|
||||
const normalizedGreen = green / 255;
|
||||
const normalizedBlue = blue / 255;
|
||||
const maxValue = Math.max(normalizedRed, normalizedGreen, normalizedBlue);
|
||||
const minValue = Math.min(normalizedRed, normalizedGreen, normalizedBlue);
|
||||
const delta = maxValue - minValue;
|
||||
const saturation = maxValue === 0 ? 0 : delta / maxValue;
|
||||
let hue = 0;
|
||||
if (delta > 0) {
|
||||
if (maxValue === normalizedRed) {
|
||||
hue = 60 * (((normalizedGreen - normalizedBlue) / delta) % 6);
|
||||
} else if (maxValue === normalizedGreen) {
|
||||
hue = 60 * ((normalizedBlue - normalizedRed) / delta + 2);
|
||||
} else {
|
||||
hue = 60 * ((normalizedRed - normalizedGreen) / delta + 4);
|
||||
}
|
||||
}
|
||||
return {
|
||||
hue: (hue + 360) % 360,
|
||||
saturation,
|
||||
value: maxValue,
|
||||
};
|
||||
}
|
||||
|
||||
function hsvToHex({ hue, saturation, value }: CanvasBackgroundHsv) {
|
||||
const chroma = value * saturation;
|
||||
const normalizedHue = ((hue % 360) + 360) % 360;
|
||||
const secondComponent =
|
||||
chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1));
|
||||
const matchValue = value - chroma;
|
||||
const [redPrime, greenPrime, bluePrime] =
|
||||
normalizedHue < 60
|
||||
? [chroma, secondComponent, 0]
|
||||
: normalizedHue < 120
|
||||
? [secondComponent, chroma, 0]
|
||||
: normalizedHue < 180
|
||||
? [0, chroma, secondComponent]
|
||||
: normalizedHue < 240
|
||||
? [0, secondComponent, chroma]
|
||||
: normalizedHue < 300
|
||||
? [secondComponent, 0, chroma]
|
||||
: [chroma, 0, secondComponent];
|
||||
return `#${[redPrime, greenPrime, bluePrime]
|
||||
.map((channel) =>
|
||||
Math.round((channel + matchValue) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0'),
|
||||
)
|
||||
.join('')}`;
|
||||
}
|
||||
|
||||
function resolveCanvasBackgroundHsv(
|
||||
color: string,
|
||||
fallbackHue = DEFAULT_CANVAS_BACKGROUND_PICKER_HUE,
|
||||
): CanvasBackgroundHsv {
|
||||
const rgb = hexToRgb(color) ?? hexToRgb(DEFAULT_CANVAS_BACKGROUND_COLOR);
|
||||
if (!rgb) {
|
||||
return { hue: fallbackHue, saturation: 0.02, value: 0.99 };
|
||||
}
|
||||
const hsv = rgbToHsv(rgb.red, rgb.green, rgb.blue);
|
||||
return {
|
||||
...hsv,
|
||||
hue:
|
||||
hsv.saturation <= ACHROMATIC_BACKGROUND_SATURATION_EPSILON
|
||||
? fallbackHue
|
||||
: hsv.hue,
|
||||
};
|
||||
}
|
||||
|
||||
function getPointerRatio(
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
axis: 'x' | 'y',
|
||||
) {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const size = axis === 'x' ? rect.width : rect.height;
|
||||
if (!Number.isFinite(size) || size <= 0) {
|
||||
return 0.5;
|
||||
}
|
||||
const clientPosition = axis === 'x' ? event.clientX : event.clientY;
|
||||
if (!Number.isFinite(clientPosition)) {
|
||||
return 0.5;
|
||||
}
|
||||
const offset =
|
||||
axis === 'x' ? clientPosition - rect.left : clientPosition - rect.top;
|
||||
return clamp(offset / size, 0, 1);
|
||||
}
|
||||
|
||||
function resolveSpectrumPointerColor(
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
currentColor: string,
|
||||
fallbackHue: number,
|
||||
) {
|
||||
const currentHsv = resolveCanvasBackgroundHsv(currentColor, fallbackHue);
|
||||
return hsvToHex({
|
||||
hue: currentHsv.hue,
|
||||
saturation: getPointerRatio(event, 'x'),
|
||||
value: 1 - getPointerRatio(event, 'y'),
|
||||
});
|
||||
}
|
||||
|
||||
function resolveHuePointerColor(
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
currentColor: string,
|
||||
fallbackHue: number,
|
||||
) {
|
||||
const currentHsv = resolveCanvasBackgroundHsv(currentColor, fallbackHue);
|
||||
return hsvToHex({
|
||||
hue: getPointerRatio(event, 'x') * 360,
|
||||
saturation: currentHsv.saturation,
|
||||
value: currentHsv.value,
|
||||
});
|
||||
}
|
||||
|
||||
function isPrimaryPointerDrag(event: ReactPointerEvent<HTMLElement>) {
|
||||
return event.buttons === 1 || event.pointerType === 'touch';
|
||||
}
|
||||
|
||||
export function ImageCanvasPanelDockView({
|
||||
viewport,
|
||||
canvasBackgroundColor,
|
||||
@@ -81,6 +227,86 @@ export function ImageCanvasPanelDockView({
|
||||
onToggleMinimap,
|
||||
onMinimapPointerDown,
|
||||
}: ImageCanvasPanelDockViewProps) {
|
||||
const lastBackgroundHueRef = useRef(DEFAULT_CANVAS_BACKGROUND_PICKER_HUE);
|
||||
const backgroundHsv = resolveCanvasBackgroundHsv(
|
||||
canvasBackgroundColor,
|
||||
lastBackgroundHueRef.current,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (
|
||||
backgroundHsv.saturation > ACHROMATIC_BACKGROUND_SATURATION_EPSILON
|
||||
) {
|
||||
lastBackgroundHueRef.current = backgroundHsv.hue;
|
||||
}
|
||||
}, [backgroundHsv.hue, backgroundHsv.saturation]);
|
||||
const backgroundHueColor = hsvToHex({
|
||||
hue: backgroundHsv.hue,
|
||||
saturation: 1,
|
||||
value: 1,
|
||||
});
|
||||
const backgroundSpectrumStyle = {
|
||||
'--image-canvas-background-hue-color': backgroundHueColor,
|
||||
} as CSSProperties;
|
||||
const backgroundSpectrumHandleStyle = {
|
||||
'--image-canvas-background-spectrum-handle-left': `clamp(0.42rem, ${backgroundHsv.saturation * 100}%, calc(100% - 0.42rem))`,
|
||||
'--image-canvas-background-spectrum-handle-top': `clamp(0.42rem, ${(1 - backgroundHsv.value) * 100}%, calc(100% - 0.42rem))`,
|
||||
} as CSSProperties;
|
||||
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>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
onApplyCanvasBackgroundColor(
|
||||
resolveSpectrumPointerColor(
|
||||
event,
|
||||
canvasBackgroundColor,
|
||||
backgroundHsv.hue,
|
||||
),
|
||||
);
|
||||
};
|
||||
const applyHuePointerColor = (event: ReactPointerEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
onApplyCanvasBackgroundColor(
|
||||
resolveHuePointerColor(event, canvasBackgroundColor, backgroundHsv.hue),
|
||||
);
|
||||
};
|
||||
const handleSpectrumKeyDown = (
|
||||
event: ReactKeyboardEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
const step = event.shiftKey ? 0.1 : 0.03;
|
||||
const nextHsv = { ...backgroundHsv };
|
||||
if (event.key === 'ArrowLeft') {
|
||||
nextHsv.saturation = clamp(nextHsv.saturation - step, 0, 1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
nextHsv.saturation = clamp(nextHsv.saturation + step, 0, 1);
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
nextHsv.value = clamp(nextHsv.value - step, 0, 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
nextHsv.value = clamp(nextHsv.value + step, 0, 1);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onApplyCanvasBackgroundColor(hsvToHex(nextHsv));
|
||||
};
|
||||
const handleHueKeyDown = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
|
||||
const step = event.shiftKey ? 30 : 6;
|
||||
const nextHsv = { ...backgroundHsv };
|
||||
if (event.key === 'ArrowLeft') {
|
||||
nextHsv.hue = (nextHsv.hue - step + 360) % 360;
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
nextHsv.hue = (nextHsv.hue + step) % 360;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
onApplyCanvasBackgroundColor(hsvToHex(nextHsv));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorIconButton
|
||||
@@ -212,15 +438,20 @@ export function ImageCanvasPanelDockView({
|
||||
/>
|
||||
<span>{canvasBackgroundColor}</span>
|
||||
</div>
|
||||
<label className="image-canvas-editor__background-spectrum">
|
||||
<input
|
||||
type="color"
|
||||
aria-label="画布背景色相"
|
||||
value={canvasBackgroundColor}
|
||||
onChange={(event) =>
|
||||
onApplyCanvasBackgroundColor(event.currentTarget.value)
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__background-spectrum"
|
||||
aria-label="画布背景色盘"
|
||||
aria-valuetext={canvasBackgroundColor}
|
||||
style={backgroundSpectrumStyle}
|
||||
onPointerDown={applySpectrumPointerColor}
|
||||
onPointerMove={(event) => {
|
||||
if (isPrimaryPointerDrag(event)) {
|
||||
applySpectrumPointerColor(event);
|
||||
}
|
||||
/>
|
||||
}}
|
||||
onKeyDown={handleSpectrumKeyDown}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__background-spectrum-surface"
|
||||
aria-hidden="true"
|
||||
@@ -228,18 +459,28 @@ export function ImageCanvasPanelDockView({
|
||||
<span
|
||||
className="image-canvas-editor__background-spectrum-handle"
|
||||
aria-hidden="true"
|
||||
style={backgroundSpectrumHandleStyle}
|
||||
/>
|
||||
</label>
|
||||
<label className="image-canvas-editor__background-hue">
|
||||
<input
|
||||
type="color"
|
||||
aria-label="自定义画布背景色"
|
||||
value={canvasBackgroundColor}
|
||||
onChange={(event) =>
|
||||
onApplyCanvasBackgroundColor(event.currentTarget.value)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="image-canvas-editor__background-hue"
|
||||
aria-label="画布背景色相"
|
||||
aria-valuetext={canvasBackgroundColor}
|
||||
onPointerDown={applyHuePointerColor}
|
||||
onPointerMove={(event) => {
|
||||
if (isPrimaryPointerDrag(event)) {
|
||||
applyHuePointerColor(event);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleHueKeyDown}
|
||||
>
|
||||
<span
|
||||
className="image-canvas-editor__background-hue-handle"
|
||||
aria-hidden="true"
|
||||
style={backgroundHueHandleStyle}
|
||||
/>
|
||||
</label>
|
||||
</button>
|
||||
<div
|
||||
className="image-canvas-editor__background-presets"
|
||||
aria-label="画布背景预设色"
|
||||
|
||||
@@ -69,6 +69,12 @@ function createExternalTask(
|
||||
};
|
||||
}
|
||||
|
||||
function formatBackendTimestampMs(timestampMs: number) {
|
||||
const seconds = Math.floor(timestampMs / 1000);
|
||||
const micros = Math.max(0, Math.floor(timestampMs % 1000) * 1000);
|
||||
return `${seconds}.${String(micros).padStart(6, '0')}Z`;
|
||||
}
|
||||
|
||||
describe('ImageCanvasTaskSidebarView', () => {
|
||||
it('groups tasks by tabs and keeps running tasks before queued tasks', async () => {
|
||||
const focusExternalTask = vi.fn();
|
||||
@@ -158,6 +164,12 @@ describe('ImageCanvasTaskSidebarView', () => {
|
||||
expect(activeTitles[1]).toContain('图标素材');
|
||||
expect(activeTitles[0]).toContain('已用时');
|
||||
expect(activeTitles[1]).not.toContain('已用时');
|
||||
const runningButton = screen.getByText('角色图片').closest('button');
|
||||
expect(
|
||||
runningButton?.querySelector(
|
||||
'.image-canvas-editor__task-sidebar-item-meta',
|
||||
)?.textContent,
|
||||
).toContain('已用时');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen
|
||||
@@ -190,8 +202,9 @@ describe('ImageCanvasTaskSidebarView', () => {
|
||||
});
|
||||
|
||||
it('shows only current project external tasks with backend timing and phase detail', async () => {
|
||||
const startedAt = new Date(Date.now() - 65_000).toISOString();
|
||||
const completedAt = new Date(Date.now() - 20_000).toISOString();
|
||||
const nowMs = Date.now();
|
||||
const startedAt = formatBackendTimestampMs(nowMs - 65_000);
|
||||
const completedAt = formatBackendTimestampMs(nowMs - 20_000);
|
||||
listExternalGenerationTasksMock.mockImplementation(
|
||||
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) =>
|
||||
Promise.resolve({
|
||||
@@ -218,7 +231,7 @@ describe('ImageCanvasTaskSidebarView', () => {
|
||||
jobId: 'done-current',
|
||||
status: 'completed',
|
||||
requestPrompt: '已完成提示词',
|
||||
startedAt: new Date(Date.now() - 90_000).toISOString(),
|
||||
startedAt: formatBackendTimestampMs(nowMs - 90_000),
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
progress: 100,
|
||||
@@ -245,7 +258,7 @@ describe('ImageCanvasTaskSidebarView', () => {
|
||||
|
||||
expect(await screen.findByText('图片画布生成图片')).toBeTruthy();
|
||||
expect(screen.getByText(/发光猫咪主视觉/u)).toBeTruthy();
|
||||
expect(screen.getByText(/正在生成第 2\/4 段。 · 已用时 1分/u)).toBeTruthy();
|
||||
expect(screen.getByText('正在生成第 2/4 段。')).toBeTruthy();
|
||||
expect(screen.queryByText(/35%/u)).toBeNull();
|
||||
expect(screen.getByText(/已用时 1分/u)).toBeTruthy();
|
||||
expect(screen.queryByText(/总进度/u)).toBeNull();
|
||||
|
||||
@@ -54,8 +54,22 @@ function parseTaskTimeMs(value?: string | null) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
const trimmedValue = value.trim();
|
||||
const timestamp = Date.parse(trimmedValue);
|
||||
if (Number.isFinite(timestamp)) {
|
||||
return timestamp;
|
||||
}
|
||||
const unixMicrosMatch = trimmedValue.match(/^(-?\d+)\.(\d{1,6})Z$/u);
|
||||
if (!unixMicrosMatch) {
|
||||
return undefined;
|
||||
}
|
||||
const [, secondsText = '', microsText = ''] = unixMicrosMatch;
|
||||
const seconds = Number(secondsText);
|
||||
const micros = Number(microsText.padEnd(6, '0'));
|
||||
if (!Number.isFinite(seconds) || !Number.isFinite(micros)) {
|
||||
return undefined;
|
||||
}
|
||||
return seconds * 1000 + Math.floor(micros / 1000);
|
||||
}
|
||||
|
||||
function formatElapsedMs(elapsedMs?: number) {
|
||||
@@ -683,17 +697,16 @@ export function ImageCanvasTaskSidebarView({
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' · ');
|
||||
const progressText = [
|
||||
item.progressDetail,
|
||||
isTimedTaskStatus(item.status) ? timeText : null,
|
||||
]
|
||||
const progressText = [item.progressDetail]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' · ');
|
||||
const titleText = [detailText, item.progressDetail, timeText]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join(' · ');
|
||||
const metaTimeText = isActiveTaskStatus(item.status)
|
||||
? null
|
||||
? isTimedTaskStatus(item.status)
|
||||
? timeText
|
||||
: null
|
||||
: timeText;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
CanvasLayer,
|
||||
CanvasViewport,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import {
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
normalizeCanvasBackgroundHex,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||
|
||||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||||
@@ -88,10 +92,12 @@ function createDeferred<T>() {
|
||||
|
||||
function ProjectPersistenceHarness({
|
||||
canAccessProtectedData = true,
|
||||
initialCanvasBackgroundColor = DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
initialGenerationDialogs = [],
|
||||
onProjectAccessLost,
|
||||
}: {
|
||||
canAccessProtectedData?: boolean;
|
||||
initialCanvasBackgroundColor?: string;
|
||||
initialGenerationDialogs?: CanvasGenerationDialogState[];
|
||||
onProjectAccessLost?: () => void;
|
||||
}) {
|
||||
@@ -99,6 +105,9 @@ function ProjectPersistenceHarness({
|
||||
const [generationDialogs, setGenerationDialogs] = useState<
|
||||
CanvasGenerationDialogState[]
|
||||
>(initialGenerationDialogs);
|
||||
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState(
|
||||
initialCanvasBackgroundColor,
|
||||
);
|
||||
const [viewport, setViewport] = useState<CanvasViewport>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -110,6 +119,7 @@ function ProjectPersistenceHarness({
|
||||
const layersRef = useRef(layers);
|
||||
const viewportRef = useRef(viewport);
|
||||
const canvasGenerationDialogsRef = useRef(generationDialogs);
|
||||
const canvasBackgroundColorRef = useRef(canvasBackgroundColor);
|
||||
const selectedLayerRef = useRef<string | null>(null);
|
||||
const layerCounterRef = useRef(0);
|
||||
const openEditorLoginModalRef = useRef(vi.fn());
|
||||
@@ -117,17 +127,27 @@ function ProjectPersistenceHarness({
|
||||
layersRef.current = layers;
|
||||
viewportRef.current = viewport;
|
||||
canvasGenerationDialogsRef.current = generationDialogs;
|
||||
canvasBackgroundColorRef.current = canvasBackgroundColor;
|
||||
const selectSingleLayer = useCallback((layerId: string | null) => {
|
||||
selectedLayerRef.current = layerId;
|
||||
}, []);
|
||||
const setLayerCounter = useCallback((value: number) => {
|
||||
layerCounterRef.current = value;
|
||||
}, []);
|
||||
const applyCanvasBackgroundColor = useCallback((color: string) => {
|
||||
const normalizedColor = normalizeCanvasBackgroundHex(color);
|
||||
if (!normalizedColor) {
|
||||
return false;
|
||||
}
|
||||
setCanvasBackgroundColor(normalizedColor);
|
||||
return true;
|
||||
}, []);
|
||||
const persistenceRefs = useMemo(
|
||||
() => ({
|
||||
layersRef,
|
||||
viewportRef,
|
||||
canvasGenerationDialogsRef,
|
||||
canvasBackgroundColorRef,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -140,8 +160,9 @@ function ProjectPersistenceHarness({
|
||||
selectSingleLayer,
|
||||
setLayerCounter,
|
||||
restoreCanvasGenerationDialogs: setGenerationDialogs,
|
||||
applyCanvasBackgroundColor,
|
||||
}),
|
||||
[selectSingleLayer, setLayerCounter],
|
||||
[applyCanvasBackgroundColor, selectSingleLayer, setLayerCounter],
|
||||
);
|
||||
|
||||
const persistence = useImageCanvasProjectPersistence({
|
||||
@@ -150,6 +171,7 @@ function ProjectPersistenceHarness({
|
||||
layers,
|
||||
canvasGenerationDialogs: generationDialogs,
|
||||
viewport,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData,
|
||||
openEditorLoginModal: openEditorLoginModalRef.current,
|
||||
@@ -187,6 +209,7 @@ function ProjectPersistenceHarness({
|
||||
)
|
||||
.join(',')}
|
||||
</span>
|
||||
<span data-testid="background">{canvasBackgroundColor}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -335,6 +358,22 @@ function ProjectPersistenceHarness({
|
||||
>
|
||||
move generation again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
applyCanvasBackgroundColor(' #ABC ');
|
||||
}}
|
||||
>
|
||||
change background
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
applyCanvasBackgroundColor('#not-a-color');
|
||||
}}
|
||||
>
|
||||
change invalid background
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -523,6 +562,89 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('restores the persisted canvas background color without autosaving on load', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
title: '自定义背景画布',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
itemType: 'canvas-settings',
|
||||
layerId: 'canvas-settings:default',
|
||||
resourceId: 'canvas-settings:default',
|
||||
canvasBackgroundColor: '#112233',
|
||||
},
|
||||
],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
});
|
||||
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('background').textContent).toBe('#112233');
|
||||
});
|
||||
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('saves canvas background color changes in project layout and session cache', async () => {
|
||||
render(<ProjectPersistenceHarness />);
|
||||
|
||||
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
||||
saveEditorProjectLayoutMock.mockClear();
|
||||
vi.useFakeTimers();
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole('button', { name: 'change background' }).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('background').textContent).toBe('#aabbcc');
|
||||
const cachedRaw = globalThis.sessionStorage.getItem(
|
||||
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
|
||||
);
|
||||
expect(cachedRaw).toBeTruthy();
|
||||
expect(cachedRaw).not.toContain(' #ABC ');
|
||||
const cached = JSON.parse(cachedRaw ?? '{}') as {
|
||||
project?: EditorProjectSnapshot;
|
||||
};
|
||||
expect(cached.project?.layers).toEqual([
|
||||
expect.objectContaining({
|
||||
itemType: 'canvas-settings',
|
||||
canvasBackgroundColor: '#aabbcc',
|
||||
}),
|
||||
]);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(451);
|
||||
});
|
||||
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
layers: [
|
||||
expect.objectContaining({
|
||||
itemType: 'canvas-settings',
|
||||
canvasBackgroundColor: '#aabbcc',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
screen.getByRole('button', { name: 'change invalid background' }).click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(451);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('background').textContent).toBe('#aabbcc');
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('persists a static project cover snapshot resource after loading drawable layers', async () => {
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||||
projectId: 'editor-project-default',
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import {
|
||||
canvasDisplayViewportToViewport,
|
||||
type CanvasLayerResourceMetadata,
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
hydrateLayer,
|
||||
isInlineEditorMediaSource,
|
||||
serializeCanvasLayout,
|
||||
@@ -64,6 +65,7 @@ type ImageCanvasProjectPersistenceRefs = {
|
||||
layersRef: RefObject<CanvasLayer[]>;
|
||||
viewportRef: RefObject<CanvasViewport>;
|
||||
canvasGenerationDialogsRef: RefObject<CanvasGenerationDialogState[]>;
|
||||
canvasBackgroundColorRef: RefObject<string>;
|
||||
};
|
||||
|
||||
type ImageCanvasProjectPersistenceSetters = {
|
||||
@@ -76,6 +78,7 @@ type ImageCanvasProjectPersistenceSetters = {
|
||||
restoreCanvasGenerationDialogs: (
|
||||
dialogs: CanvasGenerationDialogState[],
|
||||
) => void;
|
||||
applyCanvasBackgroundColor: (color: string) => boolean;
|
||||
};
|
||||
|
||||
type ImageCanvasProjectPersistenceOptions = {
|
||||
@@ -84,6 +87,7 @@ type ImageCanvasProjectPersistenceOptions = {
|
||||
layers: CanvasLayer[];
|
||||
canvasGenerationDialogs: CanvasGenerationDialogState[];
|
||||
viewport: CanvasViewport;
|
||||
canvasBackgroundColor: string;
|
||||
isViewportInteracting: boolean;
|
||||
canAccessProtectedData: boolean;
|
||||
currentUserId?: string | null;
|
||||
@@ -293,6 +297,7 @@ export function useImageCanvasProjectPersistence({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
viewport,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
canAccessProtectedData,
|
||||
currentUserId,
|
||||
@@ -322,6 +327,7 @@ export function useImageCanvasProjectPersistence({
|
||||
selectSingleLayer,
|
||||
setLayerCounter,
|
||||
restoreCanvasGenerationDialogs,
|
||||
applyCanvasBackgroundColor,
|
||||
} = setters;
|
||||
|
||||
const clearPendingProjectLayoutSave = useCallback(() => {
|
||||
@@ -560,6 +566,7 @@ export function useImageCanvasProjectPersistence({
|
||||
layers: nextLayers,
|
||||
canvasGenerationDialogs:
|
||||
refs.canvasGenerationDialogsRef.current,
|
||||
canvasBackgroundColor: refs.canvasBackgroundColorRef.current,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -616,11 +623,8 @@ export function useImageCanvasProjectPersistence({
|
||||
},
|
||||
]),
|
||||
);
|
||||
const { layerItems, generationDialogs } = splitCanvasLayoutItems(
|
||||
project.layers,
|
||||
resourcesById,
|
||||
currentUserId,
|
||||
);
|
||||
const { layerItems, generationDialogs, canvasBackgroundColor } =
|
||||
splitCanvasLayoutItems(project.layers, resourcesById, currentUserId);
|
||||
const hydratedLayers = layerItems
|
||||
.map((layer) => hydrateLayer(layer, resourcesById))
|
||||
.filter((layer): layer is CanvasLayer => Boolean(layer));
|
||||
@@ -630,8 +634,12 @@ export function useImageCanvasProjectPersistence({
|
||||
selectSingleLayer(hydratedLayers[0]?.id ?? null);
|
||||
refs.canvasGenerationDialogsRef.current = generationDialogs;
|
||||
restoreCanvasGenerationDialogs(generationDialogs);
|
||||
applyCanvasBackgroundColor(
|
||||
canvasBackgroundColor ?? DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
);
|
||||
},
|
||||
[
|
||||
applyCanvasBackgroundColor,
|
||||
clearPendingProjectLayoutSave,
|
||||
currentUserId,
|
||||
refs,
|
||||
@@ -761,6 +769,7 @@ export function useImageCanvasProjectPersistence({
|
||||
layers: serializeCanvasLayout({
|
||||
layers,
|
||||
canvasGenerationDialogs,
|
||||
canvasBackgroundColor,
|
||||
}),
|
||||
},
|
||||
{ delayMs: 450 },
|
||||
@@ -769,6 +778,7 @@ export function useImageCanvasProjectPersistence({
|
||||
}, [
|
||||
isProjectReady,
|
||||
canvasGenerationDialogs,
|
||||
canvasBackgroundColor,
|
||||
isViewportInteracting,
|
||||
layers,
|
||||
persistProjectCoverSnapshot,
|
||||
|
||||
+67
-15
@@ -6818,30 +6818,37 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock
|
||||
.image-canvas-editor__background-panel
|
||||
.image-canvas-editor__background-spectrum {
|
||||
width: 100%;
|
||||
height: 7.6rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock
|
||||
.image-canvas-editor__background-panel
|
||||
.image-canvas-editor__background-hue {
|
||||
width: 100%;
|
||||
height: 0.78rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-close:hover {
|
||||
border-color: #d7dfe9;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-spectrum {
|
||||
.image-canvas-editor__panel-dock .image-canvas-editor__background-spectrum {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 7.6rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.58rem;
|
||||
background: linear-gradient(to top, #000000, transparent),
|
||||
linear-gradient(to right, #ffffff, transparent), #ef4444;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-spectrum input,
|
||||
.image-canvas-editor__background-hue input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
background: var(--image-canvas-background-hue-color, #ef4444);
|
||||
cursor: crosshair;
|
||||
padding: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-spectrum-surface {
|
||||
@@ -6854,17 +6861,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
|
||||
.image-canvas-editor__background-spectrum-handle {
|
||||
position: absolute;
|
||||
top: 0.22rem;
|
||||
left: 0.22rem;
|
||||
left: var(--image-canvas-background-spectrum-handle-left, 0.42rem);
|
||||
top: var(--image-canvas-background-spectrum-handle-top, 0.42rem);
|
||||
width: 0.72rem;
|
||||
height: 0.72rem;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.25);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-hue {
|
||||
.image-canvas-editor__panel-dock .image-canvas-editor__background-hue {
|
||||
position: relative;
|
||||
display: block;
|
||||
height: 0.78rem;
|
||||
@@ -6882,6 +6890,28 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
#ff0000
|
||||
);
|
||||
box-shadow: 0 0 0 1px rgba(203, 213, 225, 0.72);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-hue-handle {
|
||||
position: absolute;
|
||||
left: var(--image-canvas-background-hue-handle-left, 0.39rem);
|
||||
top: 50%;
|
||||
width: 0.78rem;
|
||||
height: 0.78rem;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.28);
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-spectrum:focus-visible,
|
||||
.image-canvas-editor__background-hue:focus-visible {
|
||||
outline: 2px solid rgba(59, 130, 246, 0.74);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.image-canvas-editor__background-presets {
|
||||
@@ -6984,6 +7014,28 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
color: var(--image-canvas-brand-accent-strong);
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock
|
||||
.image-canvas-editor__background-spectrum:hover,
|
||||
.image-canvas-editor__panel-dock
|
||||
.image-canvas-editor__background-spectrum[aria-pressed='true'] {
|
||||
background: var(--image-canvas-background-hue-color, #ef4444);
|
||||
}
|
||||
|
||||
.image-canvas-editor__panel-dock .image-canvas-editor__background-hue:hover,
|
||||
.image-canvas-editor__panel-dock
|
||||
.image-canvas-editor__background-hue[aria-pressed='true'] {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#ff0000,
|
||||
#ffff00,
|
||||
#00ff00,
|
||||
#00ffff,
|
||||
#0000ff,
|
||||
#ff00ff,
|
||||
#ff0000
|
||||
);
|
||||
}
|
||||
|
||||
.image-canvas-editor__bottom-toolbar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
Reference in New Issue
Block a user