合并最新主分支并同步审核修复
合入画布副本持久化与素材类型覆盖变更 合入画布编辑器多行输入框样式调整 保留资源管理审核修复与项目记忆更新 # Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AutoGrowTextArea } from './AutoGrowTextArea';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('AutoGrowTextArea', () => {
|
||||
it('uses native field sizing when the browser supports it', () => {
|
||||
const supports = vi.fn().mockReturnValue(true);
|
||||
const resizeObserver = vi.fn();
|
||||
const onChange = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports });
|
||||
vi.stubGlobal('ResizeObserver', resizeObserver);
|
||||
|
||||
render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText(
|
||||
'自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
|
||||
expect(resizeObserver).not.toHaveBeenCalled();
|
||||
expect(input.getAttribute('rows')).toBe('1');
|
||||
expect(input.className).toContain('auto-grow-text-area');
|
||||
expect(input.className).toContain('[field-sizing:content]');
|
||||
expect(input.className).toContain('min-h-10');
|
||||
expect(input.className).toContain('max-h-32');
|
||||
expect(input.className).toContain('overflow-y-auto');
|
||||
expect(input.className).toContain('disabled:cursor-not-allowed');
|
||||
expect(input.className).toContain('disabled:opacity-60');
|
||||
expect(input.style.height).toBe('');
|
||||
|
||||
fireEvent.change(input, { target: { value: '新内容' } });
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('falls back to measured sizing, preserves css limits and remeasures width changes', () => {
|
||||
let resizeObserverCallback!: ResizeObserverCallback;
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
|
||||
resizeObserverCallback = callback;
|
||||
return {
|
||||
observe,
|
||||
unobserve: vi.fn(),
|
||||
disconnect,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value=""
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(observe).toHaveBeenCalledWith(input);
|
||||
let contentHeight = 92;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 82 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value="第一行\n第二行\n第三行\n第四行\n第五行"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('94px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 180;
|
||||
act(() => {
|
||||
resizeObserverCallback(
|
||||
[
|
||||
{
|
||||
target: input,
|
||||
contentRect: { width: 180 },
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
contentHeight = 30;
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="自动增长输入框"
|
||||
value="短内容"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '80px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('80px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('remeasures uncontrolled input changes and forwards the consumer input handler', () => {
|
||||
const onInput = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="非受控自动增长输入框"
|
||||
defaultValue="初始内容"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
}}
|
||||
onInput={onInput}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'非受控自动增长输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
let contentHeight = 90;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 42 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
fireEvent.input(input, {
|
||||
target: { value: '第一行\n第二行\n第三行\n第四行' },
|
||||
});
|
||||
expect(input.style.height).toBe('92px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 180;
|
||||
fireEvent.input(input, {
|
||||
target: { value: '很多行内容'.repeat(30) },
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
contentHeight = 20;
|
||||
fireEvent.input(input, { target: { value: '短内容' } });
|
||||
expect(input.style.height).toBe('40px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
expect(onInput).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('measures content-box height without double-counting padding or borders', () => {
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
})),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value=""
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'内容盒输入框',
|
||||
) as HTMLTextAreaElement;
|
||||
let contentHeight = 120;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => 122,
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => 120,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value="多行内容"
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('104px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
|
||||
contentHeight = 160;
|
||||
rerender(
|
||||
<AutoGrowTextArea
|
||||
aria-label="内容盒输入框"
|
||||
value="更多多行内容"
|
||||
style={{
|
||||
boxSizing: 'content-box',
|
||||
minHeight: '40px',
|
||||
maxHeight: '128px',
|
||||
paddingTop: '10px',
|
||||
paddingBottom: '6px',
|
||||
}}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
type InputEventHandler,
|
||||
type TextareaHTMLAttributes,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
const DEFAULT_MIN_HEIGHT_PX = 40;
|
||||
const DEFAULT_MAX_HEIGHT_PX = 128;
|
||||
|
||||
function supportsNativeFieldSizing() {
|
||||
return (
|
||||
typeof CSS !== 'undefined' &&
|
||||
typeof CSS.supports === 'function' &&
|
||||
CSS.supports('field-sizing', 'content')
|
||||
);
|
||||
}
|
||||
|
||||
function parseComputedPixelValue(value: string, fallback: number) {
|
||||
const parsedValue = Number.parseFloat(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : fallback;
|
||||
}
|
||||
|
||||
function resizeFallbackTextArea(textarea: HTMLTextAreaElement) {
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
const minHeight = parseComputedPixelValue(
|
||||
computedStyle.minHeight,
|
||||
DEFAULT_MIN_HEIGHT_PX,
|
||||
);
|
||||
const maxHeight = Math.max(
|
||||
minHeight,
|
||||
parseComputedPixelValue(computedStyle.maxHeight, DEFAULT_MAX_HEIGHT_PX),
|
||||
);
|
||||
const contentHeight = textarea.scrollHeight;
|
||||
const borderHeight = Math.max(
|
||||
0,
|
||||
textarea.offsetHeight - textarea.clientHeight,
|
||||
);
|
||||
const isBorderBox = computedStyle.boxSizing === 'border-box';
|
||||
const paddingHeight =
|
||||
parseComputedPixelValue(computedStyle.paddingTop, 0) +
|
||||
parseComputedPixelValue(computedStyle.paddingBottom, 0);
|
||||
const measuredHeight = isBorderBox
|
||||
? contentHeight + borderHeight
|
||||
: Math.max(0, contentHeight - paddingHeight);
|
||||
const nextHeight = Math.min(
|
||||
Math.max(measuredHeight, minHeight),
|
||||
maxHeight,
|
||||
);
|
||||
|
||||
textarea.style.height = `${nextHeight}px`;
|
||||
textarea.style.overflowY = measuredHeight > maxHeight ? 'auto' : 'hidden';
|
||||
}
|
||||
|
||||
export function AutoGrowTextArea({
|
||||
className,
|
||||
onInput,
|
||||
rows = 1,
|
||||
value,
|
||||
...textareaProps
|
||||
}: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || supportsNativeFieldSizing()) {
|
||||
return;
|
||||
}
|
||||
resizeFallbackTextArea(textarea);
|
||||
}, [value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || supportsNativeFieldSizing()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let previousWidth = textarea.getBoundingClientRect().width;
|
||||
const resizeWhenWidthChanges = (nextWidth: number) => {
|
||||
if (Math.abs(nextWidth - previousWidth) < 0.5) {
|
||||
return;
|
||||
}
|
||||
previousWidth = nextWidth;
|
||||
resizeFallbackTextArea(textarea);
|
||||
};
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
resizeWhenWidthChanges(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(textarea);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
const handleWindowResize = () => {
|
||||
resizeWhenWidthChanges(textarea.getBoundingClientRect().width);
|
||||
};
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
return () => window.removeEventListener('resize', handleWindowResize);
|
||||
}, []);
|
||||
|
||||
const resolvedClassName = [
|
||||
'auto-grow-text-area max-h-32 min-h-10 resize-none overflow-x-hidden overflow-y-auto overscroll-contain [field-sizing:content] disabled:cursor-not-allowed disabled:opacity-60',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const handleInput: InputEventHandler<HTMLTextAreaElement> = (event) => {
|
||||
if (!supportsNativeFieldSizing()) {
|
||||
resizeFallbackTextArea(event.currentTarget);
|
||||
}
|
||||
onInput?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
{...textareaProps}
|
||||
ref={textareaRef}
|
||||
className={resolvedClassName}
|
||||
onInput={handleInput}
|
||||
rows={rows}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+17
-89
@@ -1,108 +1,36 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { EditorAgentDraftTextarea } from './EditorAgentDraftTextarea.tsx';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('EditorAgentDraftTextarea', () => {
|
||||
it('uses native field sizing when the browser supports it', () => {
|
||||
const supports = vi.fn().mockReturnValue(true);
|
||||
const resizeObserver = vi.fn();
|
||||
it('forwards draft changes and paste events through the Agent wrapper', () => {
|
||||
const onChange = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports });
|
||||
vi.stubGlobal('ResizeObserver', resizeObserver);
|
||||
const onPaste = vi.fn();
|
||||
|
||||
render(<EditorAgentDraftTextarea value="" onChange={onChange} />);
|
||||
render(
|
||||
<EditorAgentDraftTextarea
|
||||
value=""
|
||||
onChange={onChange}
|
||||
onPaste={onPaste}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(supports).toHaveBeenCalledWith('field-sizing', 'content');
|
||||
expect(resizeObserver).not.toHaveBeenCalled();
|
||||
expect(input.className).toContain('[field-sizing:content]');
|
||||
expect(input.className).toContain('min-h-10');
|
||||
expect(input.className).toContain('max-h-32');
|
||||
expect(input.className).toContain('overflow-y-auto');
|
||||
expect(input.style.height).toBe('');
|
||||
expect(input.className).toContain('auto-grow-text-area');
|
||||
expect(input.className).toContain(
|
||||
'editor-agent-conversation__draft-input',
|
||||
);
|
||||
|
||||
fireEvent.change(input, { target: { value: '新草稿' } });
|
||||
expect(onChange).toHaveBeenCalledWith('新草稿');
|
||||
});
|
||||
|
||||
it('falls back to measured sizing and remeasures when width changes', () => {
|
||||
let resizeObserverCallback!: ResizeObserverCallback;
|
||||
const observe = vi.fn();
|
||||
const disconnect = vi.fn();
|
||||
vi.stubGlobal('CSS', { supports: vi.fn().mockReturnValue(false) });
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
vi.fn().mockImplementation((callback: ResizeObserverCallback) => {
|
||||
resizeObserverCallback = callback;
|
||||
return {
|
||||
observe,
|
||||
unobserve: vi.fn(),
|
||||
disconnect,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender, unmount } = render(
|
||||
<EditorAgentDraftTextarea value="" onChange={vi.fn()} />,
|
||||
);
|
||||
const input = screen.getByLabelText(
|
||||
'发送给画布 Agent',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(observe).toHaveBeenCalledWith(input);
|
||||
let contentHeight = 92;
|
||||
Object.defineProperty(input, 'offsetHeight', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
const styleHeight = Number.parseFloat(input.style.height);
|
||||
return Number.isNaN(styleHeight) ? 42 : styleHeight;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(input, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => input.offsetHeight - 2,
|
||||
});
|
||||
Object.defineProperty(input, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => contentHeight,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<EditorAgentDraftTextarea
|
||||
value="第一行\n第二行\n第三行\n第四行\n第五行"
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(input.style.height).toBe('94px');
|
||||
expect(input.style.overflowY).toBe('hidden');
|
||||
expect(input.scrollHeight).toBeLessThanOrEqual(input.clientHeight);
|
||||
|
||||
contentHeight = 180;
|
||||
act(() => {
|
||||
resizeObserverCallback(
|
||||
[
|
||||
{
|
||||
target: input,
|
||||
contentRect: { width: 180 },
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
});
|
||||
expect(input.style.height).toBe('128px');
|
||||
expect(input.style.overflowY).toBe('auto');
|
||||
|
||||
unmount();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
fireEvent.paste(input);
|
||||
expect(onPaste).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits on Enter and keeps line breaks or IME confirmation from submitting', () => {
|
||||
|
||||
@@ -1,38 +1,9 @@
|
||||
import {
|
||||
type ClipboardEventHandler,
|
||||
type KeyboardEvent,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
const DRAFT_MIN_HEIGHT_PX = 40;
|
||||
const DRAFT_MAX_HEIGHT_PX = 128;
|
||||
|
||||
// for fallback on some browser
|
||||
function supportsNativeFieldSizing() {
|
||||
return (
|
||||
typeof CSS !== 'undefined' &&
|
||||
typeof CSS.supports === 'function' &&
|
||||
CSS.supports('field-sizing', 'content')
|
||||
);
|
||||
}
|
||||
|
||||
function resizeFallbackTextarea(textarea: HTMLTextAreaElement) {
|
||||
textarea.style.height = 'auto';
|
||||
const contentHeight = textarea.scrollHeight;
|
||||
const borderHeight = Math.max(
|
||||
0,
|
||||
textarea.offsetHeight - textarea.clientHeight,
|
||||
);
|
||||
const borderBoxContentHeight = contentHeight + borderHeight;
|
||||
const nextHeight = Math.min(
|
||||
Math.max(borderBoxContentHeight, DRAFT_MIN_HEIGHT_PX),
|
||||
DRAFT_MAX_HEIGHT_PX,
|
||||
);
|
||||
textarea.style.height = `${nextHeight}px`;
|
||||
textarea.style.overflowY =
|
||||
borderBoxContentHeight > DRAFT_MAX_HEIGHT_PX ? 'auto' : 'hidden';
|
||||
}
|
||||
import { AutoGrowTextArea } from '@/src/components/common/AutoGrowTextArea';
|
||||
|
||||
export function EditorAgentDraftTextarea({
|
||||
value,
|
||||
@@ -43,49 +14,6 @@ export function EditorAgentDraftTextarea({
|
||||
onChange: (value: string) => void;
|
||||
onPaste?: ClipboardEventHandler<HTMLTextAreaElement>;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || supportsNativeFieldSizing()) {
|
||||
return;
|
||||
}
|
||||
resizeFallbackTextarea(textarea);
|
||||
}, [value]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea || supportsNativeFieldSizing()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let previousWidth = textarea.getBoundingClientRect().width;
|
||||
const resizeWhenWidthChanges = (nextWidth: number) => {
|
||||
if (Math.abs(nextWidth - previousWidth) < 0.5) {
|
||||
return;
|
||||
}
|
||||
previousWidth = nextWidth;
|
||||
resizeFallbackTextarea(textarea);
|
||||
};
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
resizeWhenWidthChanges(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(textarea);
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
|
||||
const handleWindowResize = () => {
|
||||
resizeWhenWidthChanges(textarea.getBoundingClientRect().width);
|
||||
};
|
||||
window.addEventListener('resize', handleWindowResize);
|
||||
return () => window.removeEventListener('resize', handleWindowResize);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
@@ -99,12 +27,10 @@ export function EditorAgentDraftTextarea({
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="editor-agent-conversation__draft-input max-h-32 min-h-10 min-w-0 flex-1 resize-none overflow-x-hidden overflow-y-auto overscroll-contain rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none [field-sizing:content] focus:border-slate-400"
|
||||
<AutoGrowTextArea
|
||||
className="editor-agent-conversation__draft-input min-w-0 flex-1 rounded-3xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-800 outline-none focus:border-slate-400"
|
||||
aria-label="发送给画布 Agent"
|
||||
value={value}
|
||||
rows={1}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
|
||||
@@ -190,6 +190,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
});
|
||||
|
||||
it('keeps quick edit to one prompt box with image parameters', () => {
|
||||
const submitQuickEdit = vi.fn();
|
||||
render(
|
||||
<BasicGenerationHarness
|
||||
initialDialog={createDialog({
|
||||
@@ -206,14 +207,19 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
})}
|
||||
onSubmit={submitQuickEdit}
|
||||
/>,
|
||||
);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
|
||||
const prompt = within(panel).getByRole('textbox', {
|
||||
name: '快速编辑提示词',
|
||||
});
|
||||
|
||||
expect(
|
||||
within(panel).getByRole('textbox', { name: '快速编辑提示词' }),
|
||||
).toBeTruthy();
|
||||
expect(prompt.className).toContain('auto-grow-text-area');
|
||||
expect(prompt.className).not.toContain('platform-text-field');
|
||||
fireEvent.keyDown(prompt, { key: 'Enter' });
|
||||
expect(submitQuickEdit).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(panel).queryByRole('button', { name: '添加参考图' }),
|
||||
).toBeNull();
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
@@ -247,14 +247,11 @@ export function ImageCanvasBasicGenerationComposerView({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
<AutoGrowTextArea
|
||||
aria-label={resolvedPromptLabel}
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder={resolvedPromptPlaceholder}
|
||||
size="sm"
|
||||
density="compact"
|
||||
className={promptClassName}
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
|
||||
@@ -113,7 +113,11 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('动画描述'), {
|
||||
const description = screen.getByLabelText('动画描述');
|
||||
expect(description.className).toContain('auto-grow-text-area');
|
||||
expect(description.className).not.toContain('platform-text-field');
|
||||
|
||||
fireEvent.change(description, {
|
||||
target: { value: `${'a'.repeat(4001)}` },
|
||||
});
|
||||
fireEvent.click(
|
||||
@@ -207,9 +211,12 @@ describe('ImageCanvasCharacterAnimationPanelView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成中' }));
|
||||
|
||||
expect((screen.getByLabelText('动画描述') as HTMLTextAreaElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
const description = screen.getByLabelText(
|
||||
'动画描述',
|
||||
) as HTMLTextAreaElement;
|
||||
expect(description.disabled).toBe(true);
|
||||
expect(description.className).toContain('disabled:cursor-not-allowed');
|
||||
expect(description.className).toContain('disabled:opacity-60');
|
||||
expect((screen.getByRole('button', { name: '待机' }) as HTMLButtonElement).disabled).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -13,11 +13,11 @@ import type {
|
||||
EditorCharacterAnimationRatio,
|
||||
EditorCharacterAnimationResolution,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { ImageCanvasEditorPortal } from './ImageCanvasEditorPortal';
|
||||
import { EditorIconButton } from './ImageCanvasEditorPrimitives';
|
||||
import type {
|
||||
@@ -194,16 +194,13 @@ export function ImageCanvasCharacterAnimationPanelView({
|
||||
disabled={isGenerating}
|
||||
onClick={closePanel}
|
||||
/>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
<AutoGrowTextArea
|
||||
aria-label="动画描述"
|
||||
value={panel.promptText}
|
||||
maxLength={4000}
|
||||
disabled={isGenerating}
|
||||
placeholder="你希望角色做什么动作?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt image-canvas-editor__character-animation-textarea"
|
||||
className="max-h-64 image-canvas-editor__generation-prompt image-canvas-editor__character-animation-textarea"
|
||||
onChange={(event) =>
|
||||
updatePanel({ promptText: event.target.value.slice(0, 4000) })
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
|
||||
.join(''),
|
||||
).not.toContain('角色设定');
|
||||
expect(prompt.getAttribute('placeholder')).toBe('你希望角色如何设计?');
|
||||
expect(prompt.className).toContain('auto-grow-text-area');
|
||||
expect(prompt.className).not.toContain('platform-text-field');
|
||||
expect(prompt.className).toContain('image-canvas-editor__generation-prompt');
|
||||
expect(prompt.className).not.toContain(
|
||||
'image-canvas-editor__generation-prompt--borderless',
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type {
|
||||
CharacterReferenceImage,
|
||||
GenerateDialogState,
|
||||
@@ -254,14 +254,11 @@ export function ImageCanvasCharacterGenerationComposerView({
|
||||
: null}
|
||||
</div>
|
||||
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
<AutoGrowTextArea
|
||||
aria-label="角色设定"
|
||||
value={dialog.prompt}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="你希望角色如何设计?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) =>
|
||||
setGenerateDialog((currentDialog) =>
|
||||
|
||||
@@ -36,6 +36,8 @@ function renderContextMenus(
|
||||
contextMenuLayer: null,
|
||||
contextShouldShowLayer: false,
|
||||
contextShouldUnlockLayer: false,
|
||||
canCopyContextLayers: true,
|
||||
isCanvasClipboardCopyBlocked: false,
|
||||
onPasteCanvasClipboard: vi.fn(),
|
||||
onCopyContextLayers: vi.fn(),
|
||||
onDuplicateContextLayers: vi.fn(),
|
||||
@@ -183,6 +185,87 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
expect(props.onDeleteContextLayers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('disables copy actions while the target resource is still being saved', () => {
|
||||
const layer = createLayer({ resourceId: 'local-resource-pending' });
|
||||
const props = renderContextMenus({
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
x: 16,
|
||||
y: 18,
|
||||
layerId: layer.id,
|
||||
canvasPoint: { x: 40, y: 42 },
|
||||
},
|
||||
contextMenuLayer: layer,
|
||||
canCopyContextLayers: false,
|
||||
contextLayerCopyBlockReason: 'pending-resource',
|
||||
});
|
||||
|
||||
expect(
|
||||
(screen.getByRole('menuitem', { name: '复制' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole('menuitem', { name: '剪切' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole('menuitem', { name: '创建副本' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(props.onCopyContextLayers).not.toHaveBeenCalled();
|
||||
expect(props.onDuplicateContextLayers).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
|
||||
).toBe('素材仍在保存,请稍后重试');
|
||||
});
|
||||
|
||||
it('explains that persisted historical sequences cannot be copied', () => {
|
||||
const layer = createLayer({
|
||||
resourceId: 'local-resource-historical-sequence',
|
||||
resourcePersistenceState: 'self-contained-local',
|
||||
mediaType: 'image-sequence',
|
||||
});
|
||||
renderContextMenus({
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
x: 16,
|
||||
y: 18,
|
||||
layerId: layer.id,
|
||||
canvasPoint: { x: 40, y: 42 },
|
||||
},
|
||||
contextMenuLayer: layer,
|
||||
canCopyContextLayers: false,
|
||||
contextLayerCopyBlockReason: 'self-contained-local-unsupported',
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
|
||||
).toBe('历史序列帧素材暂不支持复制');
|
||||
});
|
||||
|
||||
it('explains that unresolved local resources are not registered', () => {
|
||||
const layer = createLayer({
|
||||
resourceId: 'local-resource-unresolved',
|
||||
resourcePersistenceState: 'unresolved-local',
|
||||
});
|
||||
renderContextMenus({
|
||||
contextMenu: {
|
||||
kind: 'layer',
|
||||
x: 16,
|
||||
y: 18,
|
||||
layerId: layer.id,
|
||||
canvasPoint: { x: 40, y: 42 },
|
||||
},
|
||||
contextMenuLayer: layer,
|
||||
canCopyContextLayers: false,
|
||||
contextLayerCopyBlockReason: 'unresolved-resource',
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '复制' }).getAttribute('title'),
|
||||
).toBe('素材资源尚未登记,暂不能复制');
|
||||
});
|
||||
|
||||
it('keeps measured layer menus and submenus inside the viewport', async () => {
|
||||
const originalInnerWidth = window.innerWidth;
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
@@ -274,9 +357,7 @@ describe('ImageCanvasContextMenusView', () => {
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: '快速编辑' })).toBeNull();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: '查看图片信息' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByRole('menuitem', { name: '查看图片信息' })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps quick edit in the standalone menu for icon spritesheets', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
ImageContextMenuState,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { isQuickEditUnsupportedAssetKind } from './ImageCanvasGenerationModel';
|
||||
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
|
||||
|
||||
type ImageCanvasContextMenusViewProps = {
|
||||
viewport: CanvasViewport;
|
||||
@@ -29,6 +30,9 @@ type ImageCanvasContextMenusViewProps = {
|
||||
contextMenuLayer: CanvasLayer | null;
|
||||
contextShouldShowLayer: boolean;
|
||||
contextShouldUnlockLayer: boolean;
|
||||
canCopyContextLayers: boolean;
|
||||
contextLayerCopyBlockReason?: CanvasLayerCopyBlockReason | null;
|
||||
isCanvasClipboardCopyBlocked: boolean;
|
||||
onPasteCanvasClipboard: (canvasPoint?: { x: number; y: number }) => void;
|
||||
onCopyContextLayers: (options?: { cut?: boolean }) => void;
|
||||
onDuplicateContextLayers: () => void;
|
||||
@@ -69,6 +73,9 @@ export function ImageCanvasContextMenusView({
|
||||
contextMenuLayer,
|
||||
contextShouldShowLayer,
|
||||
contextShouldUnlockLayer,
|
||||
canCopyContextLayers,
|
||||
contextLayerCopyBlockReason = null,
|
||||
isCanvasClipboardCopyBlocked,
|
||||
onPasteCanvasClipboard,
|
||||
onCopyContextLayers,
|
||||
onDuplicateContextLayers,
|
||||
@@ -90,6 +97,14 @@ export function ImageCanvasContextMenusView({
|
||||
onOpenCharacterAnimationPanel,
|
||||
}: ImageCanvasContextMenusViewProps) {
|
||||
const isImageSequenceLayer = contextMenuLayer?.mediaType === 'image-sequence';
|
||||
const layerCopyBlockedTitle =
|
||||
contextLayerCopyBlockReason === 'self-contained-local-unsupported'
|
||||
? '历史序列帧素材暂不支持复制'
|
||||
: contextLayerCopyBlockReason === 'pending-resource'
|
||||
? '素材仍在保存,请稍后重试'
|
||||
: contextLayerCopyBlockReason === 'unresolved-resource'
|
||||
? '素材资源尚未登记,暂不能复制'
|
||||
: undefined;
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [measuredMenuLayout, setMeasuredMenuLayout] =
|
||||
useState<MeasuredMenuLayout | null>(null);
|
||||
@@ -282,6 +297,7 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={isCanvasClipboardCopyBlocked}
|
||||
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
|
||||
>
|
||||
粘贴
|
||||
@@ -331,6 +347,8 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canCopyContextLayers}
|
||||
title={layerCopyBlockedTitle}
|
||||
onClick={() => onCopyContextLayers()}
|
||||
>
|
||||
复制
|
||||
@@ -338,6 +356,8 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canCopyContextLayers}
|
||||
title={layerCopyBlockedTitle}
|
||||
onClick={() => onCopyContextLayers({ cut: true })}
|
||||
>
|
||||
剪切
|
||||
@@ -345,6 +365,7 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={isCanvasClipboardCopyBlocked}
|
||||
onClick={() => onPasteCanvasClipboard(contextMenu.canvasPoint)}
|
||||
>
|
||||
粘贴
|
||||
@@ -352,6 +373,8 @@ export function ImageCanvasContextMenusView({
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={!canCopyContextLayers}
|
||||
title={layerCopyBlockedTitle}
|
||||
onClick={onDuplicateContextLayers}
|
||||
>
|
||||
创建副本
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
createAuthValue,
|
||||
createDataTransferStub,
|
||||
createDeferred,
|
||||
defaultEditorProjectLayers,
|
||||
defaultEditorProjectResources,
|
||||
dispatchPointerEvent,
|
||||
ImageCanvasEditorView,
|
||||
setupImageCanvasEditorViewTestLifecycle,
|
||||
@@ -871,37 +873,39 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
|
||||
it('saves the canvas layout after deleting an asset linked by source resource id', async () => {
|
||||
const user = userEvent.setup();
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-default',
|
||||
title: '生成资源项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-generated-a',
|
||||
resourceId: 'resource-generated-a',
|
||||
title: '生成资源A',
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-generated-a',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/editor-test/project/generated-a.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}));
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
|
||||
withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-default',
|
||||
title: '生成资源项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
layerId: 'layer-generated-a',
|
||||
resourceId: 'resource-generated-a',
|
||||
title: '生成资源A',
|
||||
x: 100,
|
||||
y: 120,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
zIndex: 1,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-generated-a',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/editor-test/project/generated-a.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'generated',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
@@ -947,14 +951,15 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull();
|
||||
});
|
||||
await waitFor(() => {
|
||||
const savedLayoutWithoutDeletedLayer = saveEditorProjectLayoutMock.mock.calls
|
||||
.map((call) => call[1])
|
||||
.find((layout) =>
|
||||
layout.layers.every(
|
||||
(item: { layerId?: unknown }) =>
|
||||
item.layerId !== 'layer-generated-a',
|
||||
),
|
||||
);
|
||||
const savedLayoutWithoutDeletedLayer =
|
||||
saveEditorProjectLayoutMock.mock.calls
|
||||
.map((call) => call[1])
|
||||
.find((layout) =>
|
||||
layout.layers.every(
|
||||
(item: { layerId?: unknown }) =>
|
||||
item.layerId !== 'layer-generated-a',
|
||||
),
|
||||
);
|
||||
expect(savedLayoutWithoutDeletedLayer).toBeTruthy();
|
||||
});
|
||||
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-generated-a');
|
||||
@@ -965,21 +970,25 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
createEditorProjectResourceMock.mockResolvedValueOnce({
|
||||
resourceId: 'resource-added-asset-a',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/generated-character-drafts/editor/asset-library/image/asset-a.png',
|
||||
objectKey: 'generated-character-drafts/editor/asset-library/image/asset-a.png',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/asset-library/image/asset-a.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/image/asset-a.png',
|
||||
assetObjectId: 'assetobj-asset-a',
|
||||
width: 320,
|
||||
height: 240,
|
||||
sourceType: 'uploaded',
|
||||
});
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-default',
|
||||
title: '空画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}));
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
|
||||
withEditorProjectCanvasRevision({
|
||||
projectId: 'editor-project-default',
|
||||
title: '空画布项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
loadEditorAssetLibraryMock.mockResolvedValueOnce({
|
||||
folders: [
|
||||
{
|
||||
@@ -1109,7 +1118,6 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
expect(within(batchToolbar).getByText(/已选 2/u)).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
it('drops an image file on the canvas as a new canvas layer', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
await waitFor(() => {
|
||||
@@ -1141,9 +1149,9 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
);
|
||||
expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '打开素材' }).getAttribute(
|
||||
'aria-pressed',
|
||||
),
|
||||
screen
|
||||
.getByRole('button', { name: '打开素材' })
|
||||
.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
expect(
|
||||
screen
|
||||
@@ -1287,6 +1295,145 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('restores a copied layout that references the same project resource after re-entering', async () => {
|
||||
const firstRender = render(<ImageCanvasEditorView />);
|
||||
await waitFor(() => {
|
||||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||||
});
|
||||
saveEditorProjectLayoutMock.mockClear();
|
||||
createEditorProjectResourceMock.mockClear();
|
||||
|
||||
fireEvent.contextMenu(
|
||||
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
||||
{
|
||||
clientX: 510,
|
||||
clientY: 330,
|
||||
},
|
||||
);
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '复制' }));
|
||||
fireEvent.contextMenu(screen.getByLabelText('画布工作区'), {
|
||||
clientX: 360,
|
||||
clientY: 240,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '粘贴' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
|
||||
});
|
||||
const savedLayers = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]
|
||||
?.layers as Array<{ layerId: string; resourceId: string }>;
|
||||
const copiedPuzzleLayouts = savedLayers.filter(
|
||||
(layer) => layer.resourceId === 'resource-puzzle',
|
||||
);
|
||||
expect(copiedPuzzleLayouts).toHaveLength(2);
|
||||
expect(
|
||||
new Set(copiedPuzzleLayouts.map((layer) => layer.layerId)).size,
|
||||
).toBe(2);
|
||||
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
||||
|
||||
firstRender.unmount();
|
||||
window.sessionStorage.clear();
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
|
||||
withEditorProjectCanvasRevision(
|
||||
{
|
||||
projectId: 'editor-project-default',
|
||||
title: '默认项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: savedLayers,
|
||||
resources: defaultEditorProjectResources,
|
||||
updatedAt: '2026-08-03T00:00:00.000Z',
|
||||
},
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
render(<ImageCanvasEditorView />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByAltText(/画布图片:拼图素材/u)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('restores different layer overrides for one shared resource after re-entering', async () => {
|
||||
const sharedResourceProject = withEditorProjectCanvasRevision(
|
||||
{
|
||||
projectId: 'editor-project-default',
|
||||
title: '共享资源标签项目',
|
||||
viewport: { x: 0, y: 0, scale: 1 },
|
||||
layers: [
|
||||
{
|
||||
...defaultEditorProjectLayers[0],
|
||||
layerId: 'layer-shared-default',
|
||||
title: '共享默认角色',
|
||||
resourceId: 'resource-shared-kind',
|
||||
assetKindOverride: null,
|
||||
},
|
||||
{
|
||||
...defaultEditorProjectLayers[0],
|
||||
layerId: 'layer-shared-override',
|
||||
title: '共享图标覆盖',
|
||||
resourceId: 'resource-shared-kind',
|
||||
assetKindOverride: 'icon',
|
||||
x: 1140,
|
||||
},
|
||||
],
|
||||
resources: [
|
||||
{
|
||||
resourceId: 'resource-shared-kind',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'character',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-08-04T00:00:00.000Z',
|
||||
},
|
||||
1,
|
||||
);
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
|
||||
sharedResourceProject,
|
||||
);
|
||||
|
||||
const firstRender = render(<ImageCanvasEditorView />);
|
||||
const defaultLayer = await screen.findByRole('button', {
|
||||
name: '选择共享默认角色',
|
||||
});
|
||||
const overrideLayer = await screen.findByRole('button', {
|
||||
name: '选择共享图标覆盖',
|
||||
});
|
||||
expect(
|
||||
within(defaultLayer).getByRole('button', { name: '素材标签:角色' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(overrideLayer).getByRole('button', { name: '素材标签:图标' }),
|
||||
).toBeTruthy();
|
||||
|
||||
firstRender.unmount();
|
||||
window.sessionStorage.clear();
|
||||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce(
|
||||
sharedResourceProject,
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const restoredDefaultLayer = await screen.findByRole('button', {
|
||||
name: '选择共享默认角色',
|
||||
});
|
||||
const restoredOverrideLayer = await screen.findByRole('button', {
|
||||
name: '选择共享图标覆盖',
|
||||
});
|
||||
expect(
|
||||
within(restoredDefaultLayer).getByRole('button', {
|
||||
name: '素材标签:角色',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(restoredOverrideLayer).getByRole('button', {
|
||||
name: '素材标签:图标',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drops files into the asset panel only once without creating canvas layers', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -1410,5 +1557,4 @@ describe('ImageCanvasEditorView asset library integration', () => {
|
||||
});
|
||||
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -823,7 +823,8 @@ describe('ImageCanvasEditorView generation integration', () => {
|
||||
.className,
|
||||
).toContain('image-canvas-editor__reference-chip');
|
||||
const generatePrompt = screen.getByLabelText('生成提示词');
|
||||
expect(generatePrompt.className).toContain('platform-text-field');
|
||||
expect(generatePrompt.className).toContain('auto-grow-text-area');
|
||||
expect(generatePrompt.className).not.toContain('platform-text-field');
|
||||
expect(generatePrompt.className).toContain(
|
||||
'image-canvas-editor__generation-prompt',
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
hydrateLayer,
|
||||
normalizeAssetLibrary,
|
||||
normalizeCanvasBackgroundHex,
|
||||
resolveLayerResourceAssetKind,
|
||||
resolveSnappedLayerPosition,
|
||||
serializeCanvasLayout,
|
||||
serializeLayer,
|
||||
@@ -25,6 +26,33 @@ import type {
|
||||
} from './ImageCanvasEditorTypes';
|
||||
|
||||
describe('ImageCanvasEditorModel', () => {
|
||||
it('keeps the resource default kind separate from a layer override', () => {
|
||||
const layer = {
|
||||
id: 'layer-shared',
|
||||
resourceId: 'resource-shared',
|
||||
title: '共享素材',
|
||||
src: '/shared.png',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
originalWidth: 100,
|
||||
originalHeight: 100,
|
||||
zIndex: 1,
|
||||
sourceType: 'uploaded',
|
||||
resourceAssetKind: 'character',
|
||||
assetKindOverride: 'icon',
|
||||
assetKind: 'icon',
|
||||
} satisfies CanvasLayer;
|
||||
|
||||
expect(resolveLayerResourceAssetKind(layer)).toBe('character');
|
||||
expect(serializeLayer(layer)).toMatchObject({
|
||||
resourceId: 'resource-shared',
|
||||
assetKindOverride: 'icon',
|
||||
});
|
||||
expect(serializeLayer(layer)).not.toHaveProperty('assetKind');
|
||||
});
|
||||
|
||||
it('maps viewport scale to the user-facing canvas zoom scale', () => {
|
||||
expect(canvasDisplayScaleToViewportScale(0.5)).toBe(0.25);
|
||||
expect(canvasDisplayScaleToViewportScale(1)).toBe(0.5);
|
||||
@@ -39,9 +67,9 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(
|
||||
viewportToCanvasDisplayViewport({ x: 10, y: 20, scale: 0.25 }),
|
||||
).toEqual({ x: 10, y: 20, scale: 0.5 });
|
||||
expect(
|
||||
canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 }),
|
||||
).toEqual({ x: 10, y: 20, scale: 1 });
|
||||
expect(canvasDisplayViewportToViewport({ x: 10, y: 20, scale: 2 })).toEqual(
|
||||
{ x: 10, y: 20, scale: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes valid canvas background hex values and rejects invalid input', () => {
|
||||
@@ -83,7 +111,9 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
|
||||
expect(layout).toEqual([]);
|
||||
expect(splitCanvasLayoutItems(layout).canvasBackgroundColor).toBeUndefined();
|
||||
expect(
|
||||
splitCanvasLayoutItems(layout).canvasBackgroundColor,
|
||||
).toBeUndefined();
|
||||
expect(DEFAULT_CANVAS_BACKGROUND_COLOR).toBe('#f8fafc');
|
||||
});
|
||||
|
||||
@@ -128,7 +158,8 @@ describe('ImageCanvasEditorModel', () => {
|
||||
assetId: 'asset-audio',
|
||||
folderId: 'project',
|
||||
label: '胜利音效.mp3',
|
||||
imageSrc: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
|
||||
width: 420,
|
||||
height: 120,
|
||||
sourceType: 'uploaded',
|
||||
@@ -139,7 +170,8 @@ describe('ImageCanvasEditorModel', () => {
|
||||
assetId: 'asset-video',
|
||||
folderId: 'project',
|
||||
label: '开场动画.mp4',
|
||||
imageSrc: '/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/asset-library/video/开场动画.mp4',
|
||||
thumbnailSrc:
|
||||
'/generated-character-drafts/editor/asset-library/video/开场动画-cover.png',
|
||||
width: 560,
|
||||
@@ -252,7 +284,8 @@ describe('ImageCanvasEditorModel', () => {
|
||||
assetId: 'asset-generated',
|
||||
folderId: 'project',
|
||||
label: '生成素材',
|
||||
imageSrc: '/generated-character-drafts/editor/asset-library/generated.png',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/asset-library/generated.png',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'generated',
|
||||
@@ -284,6 +317,8 @@ describe('ImageCanvasEditorModel', () => {
|
||||
objectKey: 'generated/object.png',
|
||||
model: 'birefnet',
|
||||
sourceResourceId: 'resource-provider-source',
|
||||
resourceAssetKind: 'character',
|
||||
assetKindOverride: null,
|
||||
assetKind: 'character',
|
||||
generationInputs: {
|
||||
fields: [{ title: '角色设定', value: '骑士' }],
|
||||
@@ -295,6 +330,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
const snapshot = serializeLayer(layer);
|
||||
expect(snapshot).not.toHaveProperty('src');
|
||||
expect(snapshot).not.toHaveProperty('assetKind');
|
||||
expect(snapshot.assetKindOverride).toBeNull();
|
||||
expect(snapshot).not.toHaveProperty('generationInputs');
|
||||
|
||||
const hydrated = hydrateLayer(
|
||||
@@ -327,6 +363,8 @@ describe('ImageCanvasEditorModel', () => {
|
||||
id: 'layer-generated',
|
||||
src: '/read/generated.png',
|
||||
sourceType: 'generated',
|
||||
resourceAssetKind: 'character',
|
||||
assetKindOverride: null,
|
||||
assetKind: 'character',
|
||||
objectKey: 'generated/object.png',
|
||||
model: 'gpt-image-2',
|
||||
@@ -335,6 +373,55 @@ describe('ImageCanvasEditorModel', () => {
|
||||
expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士');
|
||||
});
|
||||
|
||||
it('distinguishes persisted self-contained sequences from unresolved local resources', () => {
|
||||
const validSequence = {
|
||||
layerId: 'layer-sequence',
|
||||
resourceId: 'local-resource-sequence',
|
||||
title: '历史动作',
|
||||
src: '/generated/sequence/frame-1.png',
|
||||
sourceType: 'generated',
|
||||
mediaType: 'image-sequence',
|
||||
imageSequenceFrames: [
|
||||
{
|
||||
frameIndex: 1,
|
||||
imageSrc: '/generated/sequence/frame-1.png',
|
||||
objectKey: 'generated/sequence/frame-1.png',
|
||||
width: 320,
|
||||
height: 240,
|
||||
},
|
||||
],
|
||||
thumbnailSrc: '/generated/sequence/thumbnail.png',
|
||||
previewVideoPath: '/generated/sequence/preview.mp4',
|
||||
};
|
||||
|
||||
expect(hydrateLayer(validSequence, new Map())).toMatchObject({
|
||||
resourcePersistenceState: 'self-contained-local',
|
||||
});
|
||||
expect(
|
||||
hydrateLayer(
|
||||
{
|
||||
...validSequence,
|
||||
imageSequenceFrames: [
|
||||
{ ...validSequence.imageSequenceFrames[0], frameIndex: 0 },
|
||||
],
|
||||
},
|
||||
new Map(),
|
||||
),
|
||||
).toMatchObject({ resourcePersistenceState: 'unresolved-local' });
|
||||
expect(
|
||||
hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-unresolved',
|
||||
resourceId: 'local-resource-unresolved',
|
||||
title: '未登记图片',
|
||||
src: '/generated/unresolved.png',
|
||||
sourceType: 'uploaded',
|
||||
},
|
||||
new Map(),
|
||||
),
|
||||
).toMatchObject({ resourcePersistenceState: 'unresolved-local' });
|
||||
});
|
||||
|
||||
it('recovers the normal source model after the owner payload removes an internal model', () => {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
@@ -391,8 +478,10 @@ describe('ImageCanvasEditorModel', () => {
|
||||
[
|
||||
'resource-uploaded',
|
||||
{
|
||||
imageSrc: '/generated-character-drafts/editor/asset-library/image.png',
|
||||
objectKey: 'generated-character-drafts/editor/asset-library/image.png',
|
||||
imageSrc:
|
||||
'/generated-character-drafts/editor/asset-library/image.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/image.png',
|
||||
assetObjectId: 'asset-object-uploaded',
|
||||
sourceResourceId: 'resource-source',
|
||||
},
|
||||
@@ -409,7 +498,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('hydrates manually selected publication material tags from saved layout', () => {
|
||||
it('hydrates a layer override ahead of the shared resource default', () => {
|
||||
const hydrated = hydrateLayer(
|
||||
{
|
||||
layerId: 'layer-publication',
|
||||
@@ -423,13 +512,20 @@ describe('ImageCanvasEditorModel', () => {
|
||||
originalHeight: 1024,
|
||||
zIndex: 5,
|
||||
sourceType: 'generated',
|
||||
assetKind: 'publication-material',
|
||||
assetKindOverride: 'publication-material',
|
||||
},
|
||||
new Map([['resource-publication', { imageSrc: '/read/publication.png' }]]),
|
||||
new Map([
|
||||
[
|
||||
'resource-publication',
|
||||
{ imageSrc: '/read/publication.png', assetKind: 'ui-design' },
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
id: 'layer-publication',
|
||||
resourceAssetKind: 'ui-design',
|
||||
assetKindOverride: 'publication-material',
|
||||
assetKind: 'publication-material',
|
||||
});
|
||||
});
|
||||
@@ -708,12 +804,16 @@ describe('ImageCanvasEditorModel', () => {
|
||||
});
|
||||
expect(
|
||||
JSON.stringify(
|
||||
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
|
||||
layout.find(
|
||||
(item) => item.resourceId === 'generation-dialog:generation-dialog-9',
|
||||
),
|
||||
),
|
||||
).not.toContain('data:image');
|
||||
expect(
|
||||
JSON.stringify(
|
||||
layout.find((item) => item.resourceId === 'generation-dialog:generation-dialog-9'),
|
||||
layout.find(
|
||||
(item) => item.resourceId === 'generation-dialog:generation-dialog-9',
|
||||
),
|
||||
),
|
||||
).toContain('ref:project-resource:resource-reference-1');
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ export function createLayerFromAsset(
|
||||
return {
|
||||
id: `layer-${asset.id}-${index}`,
|
||||
resourceId: `local-resource-${asset.id}-${index}`,
|
||||
resourcePersistenceState: 'pending',
|
||||
title: asset.label,
|
||||
src: asset.src,
|
||||
mediaType: asset.mediaType,
|
||||
@@ -203,7 +204,9 @@ export function createLayerFromAsset(
|
||||
sourceResourceId: asset.sourceResourceId,
|
||||
durationSeconds: asset.durationSeconds,
|
||||
sourceAssetId: asset.id,
|
||||
assetKind: asset.assetKind ?? assetKind,
|
||||
resourceAssetKind: asset.assetKind ?? assetKind ?? null,
|
||||
assetKindOverride: null,
|
||||
assetKind: asset.assetKind ?? assetKind ?? null,
|
||||
generationInputs: asset.generationInputs,
|
||||
} satisfies CanvasLayer;
|
||||
}
|
||||
@@ -258,7 +261,10 @@ function serializeImageSequenceFrames(
|
||||
return undefined;
|
||||
}
|
||||
const serializedFrames = frames.flatMap((frame) => {
|
||||
const imageSrc = serializeOptionalMediaSource(frame.imageSrc, frame.objectKey);
|
||||
const imageSrc = serializeOptionalMediaSource(
|
||||
frame.imageSrc,
|
||||
frame.objectKey,
|
||||
);
|
||||
return imageSrc
|
||||
? [
|
||||
{
|
||||
@@ -271,9 +277,7 @@ function serializeImageSequenceFrames(
|
||||
return serializedFrames.length ? serializedFrames : undefined;
|
||||
}
|
||||
|
||||
export function serializeLayer(
|
||||
layer: CanvasLayer,
|
||||
): EditorProjectLayerSnapshot {
|
||||
export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot {
|
||||
return {
|
||||
layerId: layer.id,
|
||||
resourceId: layer.resourceId,
|
||||
@@ -291,7 +295,9 @@ export function serializeLayer(
|
||||
layer.thumbnailSrc,
|
||||
layer.objectKey,
|
||||
),
|
||||
imageSequenceFrames: serializeImageSequenceFrames(layer.imageSequenceFrames),
|
||||
imageSequenceFrames: serializeImageSequenceFrames(
|
||||
layer.imageSequenceFrames,
|
||||
),
|
||||
previewVideoPath: serializeOptionalMediaSource(
|
||||
layer.previewVideoPath,
|
||||
layer.objectKey,
|
||||
@@ -307,6 +313,7 @@ export function serializeLayer(
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
sourceAssetId: layer.sourceAssetId,
|
||||
groupId: layer.groupId,
|
||||
assetKindOverride: layer.assetKindOverride ?? null,
|
||||
hidden: layer.hidden,
|
||||
locked: layer.locked,
|
||||
flipX: layer.flipX,
|
||||
@@ -332,8 +339,8 @@ function isPersistedReferenceResourceId(resourceId: string | null | undefined) {
|
||||
const normalizedResourceId = resourceId?.trim();
|
||||
return Boolean(
|
||||
normalizedResourceId &&
|
||||
!normalizedResourceId.startsWith('local-') &&
|
||||
!normalizedResourceId.startsWith('generation-dialog:'),
|
||||
!normalizedResourceId.startsWith('local-') &&
|
||||
!normalizedResourceId.startsWith('generation-dialog:'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -487,9 +494,7 @@ export function isCanvasGenerationDialogLayoutItem(
|
||||
return (
|
||||
item.itemType === 'generation-dialog' &&
|
||||
Boolean(
|
||||
hydrateCanvasGenerationDialog(
|
||||
(item as { dialog?: unknown }).dialog,
|
||||
),
|
||||
hydrateCanvasGenerationDialog((item as { dialog?: unknown }).dialog),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -570,9 +575,7 @@ export function hydrateCanvasGenerationDialog(
|
||||
prompt,
|
||||
status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle',
|
||||
composerOpen:
|
||||
typeof snapshot.composerOpen === 'boolean'
|
||||
? snapshot.composerOpen
|
||||
: true,
|
||||
typeof snapshot.composerOpen === 'boolean' ? snapshot.composerOpen : true,
|
||||
sourceLayerId: stringOrUndefined(snapshot.sourceLayerId),
|
||||
generatedLayerId: stringOrUndefined(snapshot.generatedLayerId),
|
||||
specType: isSpecGenerationType(snapshot.specType)
|
||||
@@ -629,9 +632,7 @@ export function hydrateCanvasGenerationDialog(
|
||||
imageModel: stringOrUndefined(snapshot.imageModel),
|
||||
style,
|
||||
videoModel:
|
||||
typeof snapshot.videoModel === 'string'
|
||||
? snapshot.videoModel
|
||||
: undefined,
|
||||
typeof snapshot.videoModel === 'string' ? snapshot.videoModel : undefined,
|
||||
videoAspectRatio: videoAspectRatioOrUndefined(snapshot.videoAspectRatio),
|
||||
videoResolution:
|
||||
snapshot.videoResolution === '480p' ||
|
||||
@@ -719,10 +720,26 @@ export function hydrateLayer(
|
||||
if (!resourceId || !layerId || !src) {
|
||||
return null;
|
||||
}
|
||||
const resourceAssetKind = canvasAssetKindOrNull(resource?.assetKind);
|
||||
const assetKindOverride = Object.prototype.hasOwnProperty.call(
|
||||
snapshot,
|
||||
'assetKindOverride',
|
||||
)
|
||||
? canvasAssetKindOrNull(snapshot.assetKindOverride)
|
||||
: canvasAssetKindOrNull(snapshot.assetKind);
|
||||
const isSelfContainedLocalResource =
|
||||
!resource && isSelfContainedLegacyLocalImageSequence(snapshot);
|
||||
|
||||
return {
|
||||
id: layerId,
|
||||
resourceId,
|
||||
resourcePersistenceState: resource
|
||||
? 'registered'
|
||||
: isSelfContainedLocalResource
|
||||
? 'self-contained-local'
|
||||
: resourceId.startsWith('local-')
|
||||
? 'unresolved-local'
|
||||
: undefined,
|
||||
title,
|
||||
src,
|
||||
x: numberFromSnapshot(snapshot.x, 0),
|
||||
@@ -751,13 +768,17 @@ export function hydrateLayer(
|
||||
previewVideoPath: stringOrNull(snapshot.previewVideoPath),
|
||||
prompt: stringOrNull(snapshot.prompt) ?? stringOrNull(resource?.prompt),
|
||||
actualPrompt:
|
||||
stringOrNull(snapshot.actualPrompt) ?? stringOrNull(resource?.actualPrompt),
|
||||
stringOrNull(snapshot.actualPrompt) ??
|
||||
stringOrNull(resource?.actualPrompt),
|
||||
model: resolveHydratedLayerModel(snapshot, resource, resourcesById),
|
||||
provider: stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider),
|
||||
provider:
|
||||
stringOrNull(snapshot.provider) ?? stringOrNull(resource?.provider),
|
||||
taskId: stringOrNull(snapshot.taskId) ?? stringOrNull(resource?.taskId),
|
||||
objectKey: stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey),
|
||||
objectKey:
|
||||
stringOrNull(snapshot.objectKey) ?? stringOrNull(resource?.objectKey),
|
||||
assetObjectId:
|
||||
stringOrNull(snapshot.assetObjectId) ?? stringOrNull(resource?.assetObjectId),
|
||||
stringOrNull(snapshot.assetObjectId) ??
|
||||
stringOrNull(resource?.assetObjectId),
|
||||
durationSeconds:
|
||||
audioDurationOrNull(snapshot.durationSeconds) ??
|
||||
audioDurationOrNull(resource?.durationSeconds),
|
||||
@@ -766,9 +787,9 @@ export function hydrateLayer(
|
||||
stringOrNull(resource?.sourceResourceId),
|
||||
sourceAssetId: stringOrNull(snapshot.sourceAssetId),
|
||||
groupId: stringOrNull(snapshot.groupId),
|
||||
assetKind:
|
||||
canvasAssetKindOrNull(resource?.assetKind) ??
|
||||
canvasAssetKindOrNull(snapshot.assetKind),
|
||||
resourceAssetKind,
|
||||
assetKindOverride,
|
||||
assetKind: assetKindOverride ?? resourceAssetKind,
|
||||
generationInputs:
|
||||
generationInputsOrNull(resource?.generationInputs) ??
|
||||
generationInputsOrNull(snapshot.generationInputs),
|
||||
@@ -779,6 +800,120 @@ export function hydrateLayer(
|
||||
};
|
||||
}
|
||||
|
||||
function isSelfContainedLegacyLocalImageSequence(
|
||||
snapshot: EditorProjectLayerSnapshot,
|
||||
) {
|
||||
if (
|
||||
!snapshot.resourceId.startsWith('local-') ||
|
||||
snapshot.sourceType !== 'generated' ||
|
||||
snapshot.mediaType !== 'image-sequence'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!isEmptyLegacyLocalSequenceField(snapshot.imageSrc) ||
|
||||
!isEmptyLegacyLocalSequenceField(snapshot.objectKey) ||
|
||||
!isEmptyLegacyLocalSequenceField(snapshot.assetObjectId)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!Array.isArray(snapshot.imageSequenceFrames) ||
|
||||
snapshot.imageSequenceFrames.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let firstFrameSrc = '';
|
||||
for (const [index, value] of snapshot.imageSequenceFrames.entries()) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const frame = value as Record<string, unknown>;
|
||||
const imageSrc =
|
||||
typeof frame.imageSrc === 'string' ? frame.imageSrc.trim() : '';
|
||||
if (
|
||||
frame.frameIndex !== index + 1 ||
|
||||
typeof frame.width !== 'number' ||
|
||||
!Number.isFinite(frame.width) ||
|
||||
frame.width <= 0 ||
|
||||
typeof frame.height !== 'number' ||
|
||||
!Number.isFinite(frame.height) ||
|
||||
frame.height <= 0 ||
|
||||
!isStableLegacyCanvasObjectPath(imageSrc)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (index === 0) {
|
||||
firstFrameSrc = imageSrc;
|
||||
}
|
||||
if (frame.objectKey !== undefined) {
|
||||
if (
|
||||
typeof frame.objectKey !== 'string' ||
|
||||
!isStableLegacyCanvasObjectKey(frame.objectKey) ||
|
||||
frame.objectKey.trim() !== imageSrc.replace(/^\/+/, '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const source = typeof snapshot.src === 'string' ? snapshot.src.trim() : '';
|
||||
if (
|
||||
source &&
|
||||
(!isStableLegacyCanvasObjectPath(source) || source !== firstFrameSrc)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return [snapshot.thumbnailSrc, snapshot.previewVideoPath].every(
|
||||
(value) =>
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' && isStableLegacyCanvasObjectPath(value)),
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyLegacyLocalSequenceField(value: unknown) {
|
||||
return (
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' && !value.trim())
|
||||
);
|
||||
}
|
||||
|
||||
function isStableLegacyCanvasObjectPath(value: string) {
|
||||
const normalized = value.trim();
|
||||
return (
|
||||
normalized.startsWith('/') &&
|
||||
!normalized.startsWith('//') &&
|
||||
!normalized.includes('?') &&
|
||||
!normalized.includes('#') &&
|
||||
!normalized.includes('\\') &&
|
||||
!normalized.split('/').includes('..') &&
|
||||
!hasControlCharacter(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function isStableLegacyCanvasObjectKey(value: string) {
|
||||
const normalized = value.trim();
|
||||
return (
|
||||
Boolean(normalized) &&
|
||||
!normalized.startsWith('/') &&
|
||||
!normalized.includes('?') &&
|
||||
!normalized.includes('#') &&
|
||||
!normalized.includes('\\') &&
|
||||
!normalized.split('/').includes('..') &&
|
||||
!hasControlCharacter(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string) {
|
||||
return Array.from(value).some((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
return codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveHydratedLayerModel(
|
||||
snapshot: EditorProjectLayerSnapshot,
|
||||
resource: CanvasLayerResourceMetadata | undefined,
|
||||
@@ -806,9 +941,7 @@ function resolveHydratedLayerModel(
|
||||
return persistedModel;
|
||||
}
|
||||
|
||||
export function mapAssetLibrarySnapshot(
|
||||
library: EditorAssetLibrarySnapshot,
|
||||
): {
|
||||
export function mapAssetLibrarySnapshot(library: EditorAssetLibrarySnapshot): {
|
||||
folders: EditorAssetFolder[];
|
||||
assets: EditorAsset[];
|
||||
} {
|
||||
@@ -929,6 +1062,17 @@ export type CanvasLayerResourceMetadata = {
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export function resolveLayerResourceAssetKind(
|
||||
layer: CanvasLayer,
|
||||
): CanvasAssetKind | null {
|
||||
if (layer.resourceAssetKind !== undefined) {
|
||||
return layer.resourceAssetKind;
|
||||
}
|
||||
return layer.assetKindOverride === undefined
|
||||
? (layer.assetKind ?? null)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) {
|
||||
const mapped = mapAssetLibrarySnapshot(library);
|
||||
let hasDefaultFolder = false;
|
||||
@@ -992,7 +1136,8 @@ export function soundDurationOrDefault(
|
||||
value: unknown,
|
||||
fallbackValue?: unknown,
|
||||
) {
|
||||
const resolvedValue = audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue);
|
||||
const resolvedValue =
|
||||
audioDurationOrNull(value) ?? audioDurationOrNull(fallbackValue);
|
||||
if (!resolvedValue) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -1103,10 +1248,7 @@ export function resolveContextMenuPosition(
|
||||
};
|
||||
}
|
||||
|
||||
export function hasDataTransferType(
|
||||
dataTransfer: DataTransfer,
|
||||
type: string,
|
||||
) {
|
||||
export function hasDataTransferType(dataTransfer: DataTransfer, type: string) {
|
||||
return Array.from(dataTransfer.types).includes(type);
|
||||
}
|
||||
|
||||
@@ -1136,10 +1278,12 @@ export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) {
|
||||
layer.sourceAssetId === asset.id ||
|
||||
Boolean(
|
||||
assetSourceResourceId &&
|
||||
(layer.resourceId === assetSourceResourceId ||
|
||||
layer.sourceResourceId === assetSourceResourceId),
|
||||
(layer.resourceId === assetSourceResourceId ||
|
||||
layer.sourceResourceId === assetSourceResourceId),
|
||||
) ||
|
||||
Boolean(
|
||||
asset.assetObjectId && layer.assetObjectId === asset.assetObjectId,
|
||||
) ||
|
||||
Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) ||
|
||||
Boolean(assetObjectKey && layerObjectKey === assetObjectKey) ||
|
||||
Boolean(assetSrc && layerSrc === assetSrc)
|
||||
);
|
||||
@@ -1295,9 +1439,7 @@ function hydrateSpecFormValues(
|
||||
artStyle: typeof snapshot.artStyle === 'string' ? snapshot.artStyle : '',
|
||||
bodyRatio: typeof snapshot.bodyRatio === 'string' ? snapshot.bodyRatio : '',
|
||||
characterView:
|
||||
typeof snapshot.characterView === 'string'
|
||||
? snapshot.characterView
|
||||
: '',
|
||||
typeof snapshot.characterView === 'string' ? snapshot.characterView : '',
|
||||
customPrompt:
|
||||
typeof snapshot.customPrompt === 'string' ? snapshot.customPrompt : '',
|
||||
};
|
||||
@@ -1355,7 +1497,8 @@ function hydrateCharacterReference(
|
||||
return null;
|
||||
}
|
||||
const objectKey =
|
||||
stringOrUndefined(snapshot.objectKey) ?? stringOrUndefined(resource?.objectKey);
|
||||
stringOrUndefined(snapshot.objectKey) ??
|
||||
stringOrUndefined(resource?.objectKey);
|
||||
const assetObjectId =
|
||||
stringOrUndefined(snapshot.assetObjectId) ??
|
||||
stringOrUndefined(resource?.assetObjectId);
|
||||
@@ -1683,8 +1826,7 @@ function snapItemsOverlapOnCrossAxis(
|
||||
const itemCrossStart = getSnapItemCrossStart(item, axis);
|
||||
const itemCrossEnd = itemCrossStart + getSnapItemCrossSize(item, axis);
|
||||
return (
|
||||
proposedCrossStart <=
|
||||
itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
|
||||
proposedCrossStart <= itemCrossEnd - SNAP_DISTRIBUTION_OVERLAP_TOLERANCE &&
|
||||
movingCrossEnd >= itemCrossStart + SNAP_DISTRIBUTION_OVERLAP_TOLERANCE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,6 +180,8 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
contextMenuLayer: null,
|
||||
contextShouldShowLayer: false,
|
||||
contextShouldUnlockLayer: false,
|
||||
canCopyContextLayers: true,
|
||||
isCanvasClipboardCopyBlocked: false,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
historyFeedback: null,
|
||||
|
||||
@@ -84,6 +84,8 @@ export type CanvasGenerationInputs = {
|
||||
export type CanvasLayer = {
|
||||
id: string;
|
||||
resourceId: string;
|
||||
resourcePersistenceState?:
|
||||
'pending' | 'registered' | 'self-contained-local' | 'unresolved-local';
|
||||
title: string;
|
||||
src: string;
|
||||
mediaType?: CanvasMediaType;
|
||||
@@ -107,6 +109,8 @@ export type CanvasLayer = {
|
||||
sourceAssetId?: string | null;
|
||||
generatedAssetSnapshot?: EditorAssetSnapshot | null;
|
||||
groupId?: string | null;
|
||||
resourceAssetKind?: CanvasAssetKind | null;
|
||||
assetKindOverride?: CanvasAssetKind | null;
|
||||
assetKind?: CanvasAssetKind | null;
|
||||
generationInputs?: CanvasGenerationInputs | null;
|
||||
durationSeconds?: number;
|
||||
@@ -390,12 +394,7 @@ export type CropExpandPanelState = {
|
||||
};
|
||||
|
||||
export type CropExpandRatioValue =
|
||||
| 'free'
|
||||
| '1:1'
|
||||
| '4:3'
|
||||
| '3:4'
|
||||
| '16:9'
|
||||
| '9:16';
|
||||
'free' | '1:1' | '4:3' | '3:4' | '16:9' | '9:16';
|
||||
|
||||
export type CropExpandResizeHandle =
|
||||
| 'left'
|
||||
|
||||
@@ -2032,42 +2032,31 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
await screen.findByRole('button', { name: '素材标签:角色' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
||||
'editor-project-default',
|
||||
expect.objectContaining({
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
assetKind: 'character',
|
||||
}),
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
const savedLayout = saveEditorProjectLayoutMock.mock.calls
|
||||
.map((call) => call[1])
|
||||
.find((layout) =>
|
||||
layout.layers.some(
|
||||
(layer: { resourceId?: unknown; title?: unknown }) =>
|
||||
(layer: {
|
||||
resourceId?: unknown;
|
||||
title?: unknown;
|
||||
assetKindOverride?: unknown;
|
||||
}) =>
|
||||
layer.title === '拼图素材' &&
|
||||
layer.resourceId === 'resource-editor-project-default-640',
|
||||
layer.resourceId === 'resource-puzzle' &&
|
||||
layer.assetKindOverride === 'character',
|
||||
),
|
||||
);
|
||||
|
||||
expect(savedLayout).toBeTruthy();
|
||||
expect(savedLayout.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.not.objectContaining({
|
||||
title: '拼图素材',
|
||||
assetKind: expect.anything(),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps atlas splitting busy until the new asset kind resource is confirmed', async () => {
|
||||
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock.mockImplementationOnce(
|
||||
() => persistedResource.promise,
|
||||
it('flushes the layer override before splitting without replacing its resource', async () => {
|
||||
const savedLayout = createDeferred<unknown>();
|
||||
saveEditorProjectLayoutMock.mockImplementationOnce(
|
||||
() => savedLayout.promise,
|
||||
);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
@@ -2085,80 +2074,48 @@ describe('ImageCanvasEditorView', () => {
|
||||
),
|
||||
);
|
||||
|
||||
const pendingSplitButton = await screen.findByRole('button', {
|
||||
name: '素材类型保存中',
|
||||
});
|
||||
expect((pendingSplitButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(pendingSplitButton.getAttribute('aria-busy')).toBe('true');
|
||||
fireEvent.click(pendingSplitButton);
|
||||
const splitButton = await screen.findByRole('button', { name: '拆分图集' });
|
||||
fireEvent.click(splitButton);
|
||||
expect(splitEditorIconSpritesheetMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
persistedResource.resolve({
|
||||
resourceId: 'resource-confirmed-atlas',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spritesheet',
|
||||
});
|
||||
await persistedResource.promise;
|
||||
savedLayout.resolve({});
|
||||
await savedLayout.promise;
|
||||
});
|
||||
|
||||
const splitButton = await screen.findByRole('button', {
|
||||
name: '拆分图集',
|
||||
});
|
||||
expect((splitButton as HTMLButtonElement).disabled).toBe(false);
|
||||
fireEvent.click(splitButton);
|
||||
await waitFor(() => {
|
||||
expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: 'editor-project-default',
|
||||
sourceResourceId: 'resource-confirmed-atlas',
|
||||
sourceLayerId: 'layer-puzzle',
|
||||
sourceResourceId: 'resource-puzzle',
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rolls back the asset kind and resource when persistence fails', async () => {
|
||||
const persistedResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock.mockImplementationOnce(
|
||||
() => persistedResource.promise,
|
||||
);
|
||||
it('undoes and redoes only the layer override while keeping the resource stable', async () => {
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('button', { name: '素材类型保存中' });
|
||||
|
||||
await act(async () => {
|
||||
persistedResource.reject(new Error('资源类型写入失败'));
|
||||
try {
|
||||
await persistedResource.promise;
|
||||
} catch {
|
||||
// The component consumes the rejection and restores the prior layer.
|
||||
}
|
||||
});
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:角色' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:未知',
|
||||
});
|
||||
expect(
|
||||
screen.getByText('素材类型保存失败,已恢复原标签:资源类型写入失败'),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
@@ -2166,45 +2123,13 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
assetKindOverride: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back when the resource response keeps the old asset kind', async () => {
|
||||
createEditorProjectResourceMock.mockResolvedValueOnce({
|
||||
resourceId: 'resource-stale-icon-spec',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spec',
|
||||
});
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
expect(
|
||||
screen.getByText(
|
||||
'素材类型保存失败,已恢复原标签:服务器未确认新的素材类型',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '恢复' }));
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:角色' });
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
@@ -2212,249 +2137,12 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
assetKindOverride: 'character',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists an undone asset kind and ignores the older pending response', async () => {
|
||||
const characterResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
const restoredResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock
|
||||
.mockImplementationOnce(() => characterResource.promise)
|
||||
.mockImplementationOnce(() => restoredResource.promise);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('button', { name: '素材标签:角色' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(createEditorProjectResourceMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'editor-project-default',
|
||||
expect.objectContaining({ assetKind: null }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
restoredResource.resolve({
|
||||
resourceId: 'resource-restored-kind',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: null,
|
||||
});
|
||||
await restoredResource.promise;
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
saveEditorProjectLayoutMock.mock.calls.some((call) =>
|
||||
call[1].layers.some(
|
||||
(layer: { resourceId?: unknown }) =>
|
||||
layer.resourceId === 'resource-restored-kind',
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
characterResource.resolve({
|
||||
resourceId: 'resource-stale-character',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'character',
|
||||
});
|
||||
await characterResource.promise;
|
||||
});
|
||||
expect(
|
||||
saveEditorProjectLayoutMock.mock.calls.some((call) =>
|
||||
call[1].layers.some(
|
||||
(layer: { resourceId?: unknown }) =>
|
||||
layer.resourceId === 'resource-stale-character',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rolls back an overlapping undo failure to the last confirmed resource', async () => {
|
||||
const staleCharacterResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
const failedUndoResource = createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock
|
||||
.mockImplementationOnce(() => staleCharacterResource.promise)
|
||||
.mockImplementationOnce(() => failedUndoResource.promise);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:角色',
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '撤销' }));
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
await waitFor(() => {
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
failedUndoResource.reject(new Error('撤销后资源写入失败'));
|
||||
try {
|
||||
await failedUndoResource.promise;
|
||||
} catch {
|
||||
// The latest failure must restore the server-confirmed original layer.
|
||||
}
|
||||
});
|
||||
|
||||
await within(layerButton).findByRole('button', { name: '素材标签:未知' });
|
||||
expect(
|
||||
screen.getByText('素材类型保存失败,已恢复原标签:撤销后资源写入失败'),
|
||||
).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1];
|
||||
expect(latestLayout.layers).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
layerId: 'layer-puzzle',
|
||||
resourceId: 'resource-puzzle',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
const undoButton = screen.getByRole('button', { name: '撤销' });
|
||||
expect((undoButton as HTMLButtonElement).disabled).toBe(true);
|
||||
fireEvent.click(undoButton);
|
||||
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
await act(async () => {
|
||||
staleCharacterResource.resolve({
|
||||
resourceId: 'resource-stale-character-after-failed-undo',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'character',
|
||||
});
|
||||
await staleCharacterResource.promise;
|
||||
});
|
||||
|
||||
expect(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
saveEditorProjectLayoutMock.mock.calls.some((call) =>
|
||||
call[1].layers.some(
|
||||
(layer: { resourceId?: unknown }) =>
|
||||
layer.resourceId === 'resource-stale-character-after-failed-undo',
|
||||
),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the newer asset kind pending when an older request fails', async () => {
|
||||
const staleCharacterResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
const currentAtlasResource =
|
||||
createDeferred<EditorProjectResourceSnapshot>();
|
||||
createEditorProjectResourceMock
|
||||
.mockImplementationOnce(() => staleCharacterResource.promise)
|
||||
.mockImplementationOnce(() => currentAtlasResource.promise);
|
||||
render(<ImageCanvasEditorView />);
|
||||
|
||||
const layerButton = await screen.findByRole('button', {
|
||||
name: '选择拼图素材',
|
||||
});
|
||||
fireEvent.click(layerButton);
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '角色' },
|
||||
),
|
||||
);
|
||||
await within(layerButton).findByRole('button', {
|
||||
name: '素材标签:角色',
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:角色' }),
|
||||
);
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
|
||||
'menuitem',
|
||||
{ name: '图集' },
|
||||
),
|
||||
);
|
||||
await screen.findByRole('button', { name: '素材类型保存中' });
|
||||
|
||||
await act(async () => {
|
||||
staleCharacterResource.reject(new Error('旧请求失败'));
|
||||
try {
|
||||
await staleCharacterResource.promise;
|
||||
} catch {
|
||||
// The stale rejection must not affect the newer request state.
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
within(layerButton).getByRole('button', { name: '素材标签:图集' }),
|
||||
).toBeTruthy();
|
||||
const stillPendingButton = screen.getByRole('button', {
|
||||
name: '素材类型保存中',
|
||||
});
|
||||
expect((stillPendingButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.queryByText(/素材类型保存失败/u)).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
currentAtlasResource.resolve({
|
||||
resourceId: 'resource-current-atlas',
|
||||
projectId: 'editor-project-default',
|
||||
imageSrc: '/creation-type-references/puzzle.webp',
|
||||
width: 640,
|
||||
height: 640,
|
||||
sourceType: 'uploaded',
|
||||
assetKind: 'icon-spritesheet',
|
||||
});
|
||||
await currentAtlasResource.promise;
|
||||
});
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '拆分图集' }),
|
||||
).toBeTruthy();
|
||||
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers Lovart-style zoom menu commands', async () => {
|
||||
@@ -2580,10 +2268,10 @@ describe('ImageCanvasEditorView', () => {
|
||||
expect(
|
||||
bigFishLayer.classList.contains('image-canvas-editor__layer--selected'),
|
||||
).toBe(false);
|
||||
fireEvent.contextMenu(
|
||||
within(messageLog).getByRole('presentation'),
|
||||
{ clientX: 30, clientY: 40 },
|
||||
);
|
||||
fireEvent.contextMenu(within(messageLog).getByRole('presentation'), {
|
||||
clientX: 30,
|
||||
clientY: 40,
|
||||
});
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '在画布中定位' }));
|
||||
expect(world.style.transform).toBe(initialTransform);
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
||||
generationInputsOrNull,
|
||||
isInlineEditorMediaSource,
|
||||
resolveLayerResourceAssetKind,
|
||||
resolveContextMenuPosition,
|
||||
} from './ImageCanvasEditorModel';
|
||||
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
|
||||
@@ -66,6 +67,7 @@ import {
|
||||
} from './ImageCanvasGenerationModel';
|
||||
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
|
||||
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
|
||||
import type { CanvasLayerCopyBlockReason } from './ImageCanvasLayerCommandModel';
|
||||
import {
|
||||
isCanvasGenerationComposerVisible,
|
||||
resolveQuickEditFocusViewport,
|
||||
@@ -319,15 +321,7 @@ export function ImageCanvasEditorView({
|
||||
const discardCanvasHistoryEntriesContainingLayerRef = useRef<
|
||||
(matchesLayer: (layer: CanvasLayer) => boolean) => void
|
||||
>(() => {});
|
||||
const persistRestoredAssetKindRef = useRef<
|
||||
(layer: CanvasLayer, historyEntryId?: number) => void
|
||||
>(() => {});
|
||||
const layerResourcePersistenceVersionRef = useRef(new Map<string, number>());
|
||||
const assetKindRollbackLayerByIdRef = useRef(new Map<string, CanvasLayer>());
|
||||
const assetKindHistoryEntryIdsByLayerIdRef = useRef(
|
||||
new Map<string, Set<number>>(),
|
||||
);
|
||||
const persistingAssetKindLayerIdsRef = useRef(new Set<string>());
|
||||
const resetCanvasInteractionStateRef = useRef<() => void>(() => {});
|
||||
const closeGenerationTransientStateRef = useRef<() => void>(() => {});
|
||||
const specToolWrapRef = useRef<HTMLSpanElement | null>(null);
|
||||
@@ -795,35 +789,24 @@ export function ImageCanvasEditorView({
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const canvasHistoryEffects = useMemo(
|
||||
() => ({
|
||||
persistRestoredAssetKind: (layer: CanvasLayer, historyEntryId?: number) =>
|
||||
persistRestoredAssetKindRef.current(layer, historyEntryId),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
canUndo,
|
||||
canRedo,
|
||||
getCanvasHistorySnapshot,
|
||||
captureCanvasHistory,
|
||||
discardCanvasHistoryEntriesContainingLayer,
|
||||
discardCanvasHistoryEntriesById,
|
||||
undoCanvasChange,
|
||||
redoCanvasChange,
|
||||
} = useCanvasHistory({
|
||||
refs: canvasHistoryRefs,
|
||||
setters: canvasHistorySetters,
|
||||
resetters: canvasHistoryResetters,
|
||||
effects: canvasHistoryEffects,
|
||||
});
|
||||
captureCanvasHistoryRef.current = captureCanvasHistory;
|
||||
discardCanvasHistoryEntriesContainingLayerRef.current =
|
||||
discardCanvasHistoryEntriesContainingLayer;
|
||||
const [historyFeedback, setHistoryFeedback] =
|
||||
useState<CanvasHistoryFeedback | null>(null);
|
||||
const [persistingAssetKindLayerIds, setPersistingAssetKindLayerIds] =
|
||||
useState<ReadonlySet<string>>(() => new Set());
|
||||
useEffect(() => {
|
||||
if (!historyFeedback) {
|
||||
return;
|
||||
@@ -831,6 +814,21 @@ export function ImageCanvasEditorView({
|
||||
const timer = window.setTimeout(() => setHistoryFeedback(null), 3000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [historyFeedback]);
|
||||
const showCanvasLayerCopyWarning = useCallback(
|
||||
(reason: CanvasLayerCopyBlockReason) => {
|
||||
setHistoryFeedback((currentFeedback) => ({
|
||||
id: (currentFeedback?.id ?? 0) + 1,
|
||||
tone: 'warning',
|
||||
text:
|
||||
reason === 'self-contained-local-unsupported'
|
||||
? '历史序列帧素材暂不支持复制'
|
||||
: reason === 'unresolved-resource'
|
||||
? '素材资源尚未登记,暂不能复制'
|
||||
: '素材仍在保存,请稍后重试',
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleUndoCanvasChange = useCallback(() => {
|
||||
const result = undoCanvasChange();
|
||||
if (result.status === 'empty') {
|
||||
@@ -1237,105 +1235,19 @@ export function ImageCanvasEditorView({
|
||||
.then(applyGeneratedProjectSnapshot)
|
||||
.catch(() => undefined);
|
||||
}, [applyGeneratedProjectSnapshot, projectId]);
|
||||
const setLayerAssetKindPersistencePending = useCallback(
|
||||
(layerId: string, isPending: boolean) => {
|
||||
const nextLayerIds = new Set(persistingAssetKindLayerIdsRef.current);
|
||||
if (isPending) {
|
||||
nextLayerIds.add(layerId);
|
||||
} else {
|
||||
nextLayerIds.delete(layerId);
|
||||
}
|
||||
persistingAssetKindLayerIdsRef.current = nextLayerIds;
|
||||
setPersistingAssetKindLayerIds(nextLayerIds);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const persistUpdatedLayerResource = useCallback(
|
||||
(
|
||||
layer: CanvasLayer,
|
||||
rollbackLayer?: CanvasLayer,
|
||||
historyEntryId?: number,
|
||||
) => {
|
||||
(layer: CanvasLayer) => {
|
||||
const requestVersion =
|
||||
(layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1;
|
||||
layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion);
|
||||
const confirmedRollbackLayer = rollbackLayer
|
||||
? (assetKindRollbackLayerByIdRef.current.get(layer.id) ?? rollbackLayer)
|
||||
: undefined;
|
||||
if (confirmedRollbackLayer) {
|
||||
assetKindRollbackLayerByIdRef.current.set(
|
||||
layer.id,
|
||||
confirmedRollbackLayer,
|
||||
);
|
||||
if (historyEntryId !== undefined) {
|
||||
const historyEntryIds = new Set(
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id),
|
||||
);
|
||||
historyEntryIds.add(historyEntryId);
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.set(
|
||||
layer.id,
|
||||
historyEntryIds,
|
||||
);
|
||||
}
|
||||
setLayerAssetKindPersistencePending(layer.id, true);
|
||||
}
|
||||
const rollbackLatestRequest = (error: unknown) => {
|
||||
if (
|
||||
layerResourcePersistenceVersionRef.current.get(layer.id) !==
|
||||
requestVersion ||
|
||||
!confirmedRollbackLayer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setLayers((currentLayers) => {
|
||||
const nextLayers = currentLayers.map((currentLayer) =>
|
||||
currentLayer.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: confirmedRollbackLayer.assetKind,
|
||||
resourceId: confirmedRollbackLayer.resourceId,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
layersRef.current = nextLayers;
|
||||
return nextLayers;
|
||||
});
|
||||
setMetadataLayer((currentLayer) =>
|
||||
currentLayer?.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: confirmedRollbackLayer.assetKind,
|
||||
resourceId: confirmedRollbackLayer.resourceId,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
const historyEntryIds =
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id);
|
||||
if (historyEntryIds) {
|
||||
discardCanvasHistoryEntriesById(historyEntryIds);
|
||||
}
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id);
|
||||
assetKindRollbackLayerByIdRef.current.delete(layer.id);
|
||||
setLayerAssetKindPersistencePending(layer.id, false);
|
||||
const reason =
|
||||
error instanceof Error && error.message.trim()
|
||||
? error.message.trim()
|
||||
: '请稍后重试';
|
||||
setHistoryFeedback((currentFeedback) => ({
|
||||
id: (currentFeedback?.id ?? 0) + 1,
|
||||
tone: 'warning',
|
||||
text: `素材类型保存失败,已恢复原标签:${reason}`,
|
||||
}));
|
||||
};
|
||||
if (!projectId) {
|
||||
rollbackLatestRequest(new Error('项目尚未就绪'));
|
||||
return;
|
||||
}
|
||||
const imageSrc = resolveLayerProjectResourceImageSrc(layer);
|
||||
if (!imageSrc) {
|
||||
rollbackLatestRequest(new Error('素材图片尚未就绪'));
|
||||
return;
|
||||
}
|
||||
const resourceAssetKind = resolveLayerResourceAssetKind(layer);
|
||||
void createEditorProjectResource(projectId, {
|
||||
imageSrc,
|
||||
objectKey: layer.objectKey,
|
||||
@@ -1350,7 +1262,7 @@ export function ImageCanvasEditorView({
|
||||
taskId: layer.taskId,
|
||||
durationSeconds: layer.durationSeconds,
|
||||
sourceResourceId: layer.sourceResourceId,
|
||||
assetKind: layer.assetKind,
|
||||
assetKind: resourceAssetKind,
|
||||
generationInputs: layer.generationInputs,
|
||||
})
|
||||
.then((resource) => {
|
||||
@@ -1360,21 +1272,19 @@ export function ImageCanvasEditorView({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
confirmedRollbackLayer &&
|
||||
(resource.assetKind !== layer.assetKind ||
|
||||
!resource.resourceId.trim())
|
||||
) {
|
||||
rollbackLatestRequest(new Error('服务器未确认新的素材类型'));
|
||||
return;
|
||||
}
|
||||
const confirmedResourceAssetKind =
|
||||
canvasAssetKindOrNull(resource.assetKind) ?? resourceAssetKind;
|
||||
setLayers((currentLayers) => {
|
||||
const nextLayers = currentLayers.map((currentLayer) =>
|
||||
currentLayer.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: layer.assetKind,
|
||||
resourceId: resource.resourceId,
|
||||
resourcePersistenceState: 'registered' as const,
|
||||
resourceAssetKind: confirmedResourceAssetKind,
|
||||
assetKind:
|
||||
currentLayer.assetKindOverride ??
|
||||
confirmedResourceAssetKind,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
@@ -1385,31 +1295,20 @@ export function ImageCanvasEditorView({
|
||||
currentLayer?.id === layer.id
|
||||
? {
|
||||
...currentLayer,
|
||||
assetKind: layer.assetKind,
|
||||
resourceId: resource.resourceId,
|
||||
resourcePersistenceState: 'registered' as const,
|
||||
resourceAssetKind: confirmedResourceAssetKind,
|
||||
assetKind:
|
||||
currentLayer.assetKindOverride ??
|
||||
confirmedResourceAssetKind,
|
||||
}
|
||||
: currentLayer,
|
||||
);
|
||||
if (confirmedRollbackLayer) {
|
||||
assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id);
|
||||
assetKindRollbackLayerByIdRef.current.delete(layer.id);
|
||||
setLayerAssetKindPersistencePending(layer.id, false);
|
||||
}
|
||||
})
|
||||
.catch(rollbackLatestRequest);
|
||||
.catch(() => undefined);
|
||||
},
|
||||
[
|
||||
discardCanvasHistoryEntriesById,
|
||||
projectId,
|
||||
setLayerAssetKindPersistencePending,
|
||||
],
|
||||
[projectId],
|
||||
);
|
||||
persistRestoredAssetKindRef.current = (layer, historyEntryId) => {
|
||||
const rollbackLayer = layersRef.current.find(
|
||||
(currentLayer) => currentLayer.id === layer.id,
|
||||
);
|
||||
persistUpdatedLayerResource(layer, rollbackLayer ?? layer, historyEntryId);
|
||||
};
|
||||
const {
|
||||
assetExportStatus,
|
||||
isExportingAssets,
|
||||
@@ -1517,11 +1416,7 @@ export function ImageCanvasEditorView({
|
||||
}
|
||||
refreshEditorWalletState();
|
||||
},
|
||||
[
|
||||
projectId,
|
||||
refreshEditorWalletState,
|
||||
showGenerationWarning,
|
||||
],
|
||||
[projectId, refreshEditorWalletState, showGenerationWarning],
|
||||
);
|
||||
const effectiveIsAgentConversationOpen =
|
||||
isAgentConversationEnabled && isAgentConversationOpen;
|
||||
@@ -1778,6 +1673,9 @@ export function ImageCanvasEditorView({
|
||||
: null;
|
||||
const {
|
||||
canvasClipboard,
|
||||
canCopyContextLayers,
|
||||
contextLayerCopyBlockReason,
|
||||
isCanvasClipboardCopyBlocked,
|
||||
pasteCanvasClipboard,
|
||||
copyContextLayers,
|
||||
copySelectedLayers,
|
||||
@@ -1802,6 +1700,7 @@ export function ImageCanvasEditorView({
|
||||
selectedLayerIds,
|
||||
canvasGenerationDialogs,
|
||||
setLayers,
|
||||
appendCanvasLayersWithResources,
|
||||
setSelectedLayerId,
|
||||
setSelectedLayerIds,
|
||||
setHoveredLayerId,
|
||||
@@ -1814,6 +1713,7 @@ export function ImageCanvasEditorView({
|
||||
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
|
||||
onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById,
|
||||
exportLayerImage,
|
||||
onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning,
|
||||
});
|
||||
const {
|
||||
canvasMarquee,
|
||||
@@ -1958,18 +1858,23 @@ export function ImageCanvasEditorView({
|
||||
if (!targetLayer || targetLayer.assetKind === assetKind) {
|
||||
return;
|
||||
}
|
||||
const historyEntryId = captureCanvasHistory({
|
||||
captureCanvasHistory({
|
||||
type: 'change-asset-kind',
|
||||
count: 1,
|
||||
layerIds: [layerId],
|
||||
});
|
||||
const resourceAssetKind = resolveLayerResourceAssetKind(targetLayer);
|
||||
const assetKindOverride =
|
||||
assetKind === resourceAssetKind ? null : assetKind;
|
||||
const nextLayer = {
|
||||
...targetLayer,
|
||||
assetKind,
|
||||
resourceAssetKind,
|
||||
assetKindOverride,
|
||||
assetKind: assetKindOverride ?? resourceAssetKind,
|
||||
};
|
||||
setLayers((currentLayers) => {
|
||||
const nextLayers = currentLayers.map((layer) =>
|
||||
layer.id === layerId ? { ...layer, assetKind } : layer,
|
||||
layer.id === layerId ? nextLayer : layer,
|
||||
);
|
||||
layersRef.current = nextLayers;
|
||||
return nextLayers;
|
||||
@@ -1977,11 +1882,10 @@ export function ImageCanvasEditorView({
|
||||
setMetadataLayer((currentLayer) =>
|
||||
currentLayer?.id === layerId ? nextLayer : currentLayer,
|
||||
);
|
||||
persistUpdatedLayerResource(nextLayer, targetLayer, historyEntryId);
|
||||
setImageContextMenu(null);
|
||||
setContextMenu(null);
|
||||
},
|
||||
[captureCanvasHistory, persistUpdatedLayerResource],
|
||||
[captureCanvasHistory],
|
||||
);
|
||||
const selectAllCanvasObjects = useCallback(() => {
|
||||
const layerSelectionIds = layersRef.current
|
||||
@@ -2384,7 +2288,6 @@ export function ImageCanvasEditorView({
|
||||
generationComposerStyle,
|
||||
selectedToolbarStyle,
|
||||
splittingIconSpritesheetLayerIds,
|
||||
persistingAssetKindLayerIds,
|
||||
uploadDropTarget,
|
||||
contextMenu,
|
||||
canvasClipboard,
|
||||
@@ -2392,6 +2295,9 @@ export function ImageCanvasEditorView({
|
||||
imageContextMenuLayer,
|
||||
contextShouldShowLayer,
|
||||
contextShouldUnlockLayer,
|
||||
canCopyContextLayers,
|
||||
contextLayerCopyBlockReason,
|
||||
isCanvasClipboardCopyBlocked,
|
||||
canUndo,
|
||||
canRedo,
|
||||
historyFeedback,
|
||||
@@ -2435,10 +2341,9 @@ export function ImageCanvasEditorView({
|
||||
onOpenCropExpandPanel: openCropExpandPanel,
|
||||
onRemoveBackground: removeSelectedLayerBackground,
|
||||
onSplitIconSpritesheet: (layer: CanvasLayer) => {
|
||||
if (persistingAssetKindLayerIdsRef.current.has(layer.id)) {
|
||||
return;
|
||||
}
|
||||
void splitSelectedIconSpritesheet(layer);
|
||||
void flushProjectPersistence().then(() =>
|
||||
splitSelectedIconSpritesheet(layer),
|
||||
);
|
||||
},
|
||||
onExtractUiDesignAssets: extractUiDesignAssets,
|
||||
onUiAssetExtractionToolChange: changeUiAssetExtractionTool,
|
||||
|
||||
@@ -534,6 +534,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
render(<VideoHarness />);
|
||||
|
||||
const panel = screen.getByRole('dialog', { name: '生成视频' });
|
||||
const prompt = within(panel).getByRole('textbox', { name: '视频描述' });
|
||||
expect(prompt.className).toContain('auto-grow-text-area');
|
||||
expect(prompt.className).not.toContain('platform-text-field');
|
||||
expect(
|
||||
within(panel).getByRole('button', {
|
||||
name: '视频参数 16:9 · 4秒 · 480p',
|
||||
@@ -658,7 +661,9 @@ describe('ImageCanvasGenerationComposerView', () => {
|
||||
expect(footer?.children[2]?.className).toContain(
|
||||
'image-canvas-editor__generation-submit',
|
||||
);
|
||||
expect(within(panel).getByRole('textbox', { name: 'prompt' })).toBeTruthy();
|
||||
const prompt = within(panel).getByRole('textbox', { name: 'prompt' });
|
||||
expect(prompt.className).toContain('auto-grow-text-area');
|
||||
expect(prompt.className).not.toContain('platform-text-field');
|
||||
expect(screen.getByLabelText('当前音效模型').textContent).toBe('audio1.0');
|
||||
const soundModelButton = within(panel).getByRole('button', {
|
||||
name: '音效模型 Vidu',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import { ImageCanvasBasicGenerationComposerView } from './ImageCanvasBasicGenerationComposerView';
|
||||
import { ImageCanvasCharacterAnimationPanelView } from './ImageCanvasCharacterAnimationPanelView';
|
||||
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
|
||||
@@ -384,14 +384,11 @@ function ImageCanvasVideoGenerationComposerView({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
<AutoGrowTextArea
|
||||
aria-label="视频描述"
|
||||
value={dialog.prompt}
|
||||
disabled={isGenerating}
|
||||
placeholder="你希望生成什么视频?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) =>
|
||||
updateVideoDialog({ prompt: event.target.value })
|
||||
@@ -768,8 +765,7 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
<AutoGrowTextArea
|
||||
aria-label={isSoundEffect ? 'prompt' : 'gpt_description_prompt'}
|
||||
value={dialog.prompt}
|
||||
disabled={isGenerating}
|
||||
@@ -778,8 +774,6 @@ function ImageCanvasAudioGenerationComposerView({
|
||||
? '你希望生成什么音效?'
|
||||
: '你希望生成什么音乐?'
|
||||
}
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
|
||||
/>
|
||||
|
||||
@@ -136,13 +136,27 @@ function mergeLayerForHistoryRestore(
|
||||
if (!current) {
|
||||
return { ...target };
|
||||
}
|
||||
const resourceAssetKind =
|
||||
current.resourceAssetKind ?? target.resourceAssetKind;
|
||||
const restoresLayerOverride =
|
||||
target.assetKindOverride !== undefined ||
|
||||
current.assetKindOverride !== undefined;
|
||||
const assetKindOverride = restoreAssetKind
|
||||
? (target.assetKindOverride ?? null)
|
||||
: current.assetKindOverride;
|
||||
return {
|
||||
...current,
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
zIndex: target.zIndex,
|
||||
groupId: target.groupId,
|
||||
assetKind: restoreAssetKind ? target.assetKind : current.assetKind,
|
||||
...(resourceAssetKind !== undefined ? { resourceAssetKind } : {}),
|
||||
...(restoresLayerOverride ? { assetKindOverride } : {}),
|
||||
assetKind: restoreAssetKind
|
||||
? restoresLayerOverride
|
||||
? (assetKindOverride ?? resourceAssetKind ?? null)
|
||||
: target.assetKind
|
||||
: current.assetKind,
|
||||
hidden: target.hidden,
|
||||
locked: target.locked,
|
||||
flipX: target.flipX,
|
||||
|
||||
@@ -193,7 +193,11 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('素材描述'), {
|
||||
const description = screen.getByLabelText('素材描述');
|
||||
expect(description.className).toContain('auto-grow-text-area');
|
||||
expect(description.className).not.toContain('platform-text-field');
|
||||
|
||||
fireEvent.change(description, {
|
||||
target: { value: '魔法剑\n圆盾' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
@@ -7,12 +7,12 @@ import {
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
|
||||
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from '../common/PlatformFloatingMenu';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type {
|
||||
GenerateDialogState,
|
||||
SpecGenerationType,
|
||||
@@ -210,14 +210,11 @@ export function ImageCanvasIconSpritesheetComposerView({
|
||||
/>
|
||||
</div>
|
||||
<label className="image-canvas-editor__field-block image-canvas-editor__field-block--single">
|
||||
<PlatformTextField
|
||||
variant="textarea"
|
||||
<AutoGrowTextArea
|
||||
aria-label="素材描述"
|
||||
value={descriptionText}
|
||||
disabled={dialog.status === 'generating'}
|
||||
placeholder="你需要哪些图标?"
|
||||
size="sm"
|
||||
density="compact"
|
||||
className="image-canvas-editor__generation-prompt"
|
||||
onChange={(event) => onUpdateIconDescriptionText(event.target.value)}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user