修复画布背景色与任务计时显示

恢复生成任务列表的进行中计时显示并兼容微秒时间戳

替换画布背景色原生取色器为可拖拽色盘和色带

持久化画布背景色并在项目恢复时应用

补充背景色交互持久化和任务计时显示测试
This commit is contained in:
2026-07-09 16:05:25 +08:00
parent 5f6f4d0ba6
commit 67fee3718d
11 changed files with 736 additions and 64 deletions
@@ -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
View File
@@ -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%;