Files
openmaic/OpenMAIC/packages/@openmaic/editor/test/ui/EditableSlideCanvasWithUI.test.tsx
2026-08-16 14:58:47 +08:00

497 lines
14 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { useCallback, useState, type ComponentProps } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Slide } from '@openmaic/dsl';
import type { EditableSlideCanvasProps, Selection } from '../../src/react/types';
import type {
TextEditCommand,
TextEditorController,
TextFormatState,
} from '../../src/react/text/types';
const canvasMock = vi.hoisted(() => ({
latestProps: null as EditableSlideCanvasProps | null,
activeController: null as { discard: ReturnType<typeof vi.fn> } | null,
format: {
bold: false,
em: false,
underline: false,
strikethrough: false,
superscript: false,
subscript: false,
code: false,
color: '#111111',
backcolor: '',
fontsize: '20px',
fontname: 'Arial',
link: '',
align: 'left',
bulletList: false,
orderedList: false,
blockquote: false,
} satisfies TextFormatState,
}));
function controller(
elementId: string,
onTextContentChange: EditableSlideCanvasProps['onTextContentChange'],
): TextEditorController {
const next = {
elementId,
execute(_command: TextEditCommand | TextEditCommand[]) {
onTextContentChange?.({
intent: {
type: 'text.updateContent',
id: elementId,
content: `<p><strong>${elementId}</strong></p>`,
target: 'text',
},
history: 'record',
});
},
discard: vi.fn(),
flush: vi.fn(),
focus: vi.fn(),
getHTML: () => `<p>${elementId}</p>`,
};
canvasMock.activeController = next;
return next;
}
vi.mock('../../src/react/EditableSlideCanvas', async () => {
const React = await import('react');
return {
EditableSlideCanvas(props: EditableSlideCanvasProps) {
canvasMock.latestProps = props;
const editingId = props.selection?.editingId;
const { onTextContentChange, onTextEditorChange, onTextFormatChange } = props;
React.useEffect(() => {
if (!editingId) return;
onTextEditorChange?.(controller(editingId, onTextContentChange));
onTextFormatChange?.(editingId, canvasMock.format);
return () => onTextEditorChange?.(null);
}, [editingId, onTextContentChange, onTextEditorChange, onTextFormatChange]);
const prefix = props.elementIdPrefix ?? 'slide-element-';
return React.createElement(
'div',
{ 'data-testid': 'editable-slide-canvas' },
props.slide.elements.map((element) =>
React.createElement(
'div',
{
'data-element-id': element.id,
id: `${prefix}${element.id}`,
key: element.id,
},
React.createElement('div', {
className: `base-element-${element.type}`,
}),
),
),
);
},
};
});
import { EditableSlideCanvasWithUI } from '../../src/ui';
const textElement = {
id: 'text-1',
type: 'text',
left: 20,
top: 30,
width: 240,
height: 80,
rotate: 0,
content: '<p>Hello</p>',
defaultFontName: 'Arial',
defaultColor: '#111111',
} as const;
const elements = [
textElement,
{
id: 'line-1',
type: 'line',
left: 40,
top: 160,
width: 2,
start: [0, 0],
end: [120, 80],
style: 'solid',
color: '#333333',
points: ['', ''],
},
{
id: 'table-1',
type: 'table',
left: 40,
top: 260,
width: 240,
height: 80,
rotate: 0,
colWidths: [1],
cellMinHeight: 80,
outline: { width: 1, color: '#333333', style: 'solid' },
data: [[{ id: 'cell-1', colspan: 1, rowspan: 1, text: 'Cell' }]],
},
{
id: 'formula-1',
type: 'latex',
left: 100,
top: 100,
width: 180,
height: 60,
rotate: 0,
latex: 'x^2',
html: '<span>x²</span>',
color: '#2563eb',
align: 'center',
},
{
id: 'video-1',
type: 'video',
left: 100,
top: 100,
width: 320,
height: 180,
rotate: 0,
src: 'video.mp4',
poster: 'cover.png',
autoplay: false,
},
{
id: 'audio-1',
type: 'audio',
left: 100,
top: 100,
width: 240,
height: 64,
rotate: 0,
fixedRatio: true,
color: '#7c3aed',
loop: false,
autoplay: false,
src: 'lesson.mp3',
},
{
id: 'image-1',
type: 'image',
left: 100,
top: 100,
width: 240,
height: 160,
rotate: 0,
fixedRatio: true,
src: 'cover.png',
},
] as const;
const slide = {
id: 'slide-1',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: {
backgroundColor: '#ffffff',
themeColors: ['#2563eb'],
fontColor: '#111111',
fontName: 'Arial',
},
elements,
} as unknown as Slide;
function rect(left: number, top: number, width: number, height: number): DOMRect {
return {
x: left,
y: top,
left,
top,
width,
height,
right: left + width,
bottom: top + height,
toJSON: () => ({}),
};
}
interface HarnessProps extends Omit<ComponentProps<typeof EditableSlideCanvasWithUI>, 'selection'> {
readonly initialSelection?: Selection;
}
function Harness({
initialSelection = { elementIds: [] },
onSelectionChange,
...props
}: HarnessProps) {
const [selection, setSelection] = useState(initialSelection);
const updateSelection = useCallback(
(next: Selection) => {
setSelection(next);
onSelectionChange(next);
},
[onSelectionChange],
);
return (
<EditableSlideCanvasWithUI
{...props}
selection={selection}
onSelectionChange={updateSelection}
/>
);
}
function renderEditor(overrides: Partial<HarnessProps> = {}) {
const onTransaction = vi.fn();
const onSelectionChange = vi.fn();
const view = render(
<Harness
slide={slide}
scale={1}
host={{ locale: 'en-US', createElementId: (type) => `new-${type}` }}
onTransaction={onTransaction}
onSelectionChange={onSelectionChange}
{...overrides}
/>,
);
return { ...view, onTransaction, onSelectionChange };
}
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement,
) {
if (this.hasAttribute('data-toolbar-overlay')) return rect(0, 0, 640, 48);
if (this.classList.contains('base-element-line')) return rect(100, 200, 120, 80);
return rect(100, 100, 240, 80);
});
});
afterEach(() => {
cleanup();
canvasMock.latestProps = null;
canvasMock.activeController = null;
vi.restoreAllMocks();
});
describe('EditableSlideCanvasWithUI canonical host', () => {
it('uses the built-in insert order and emits a canonical text-add transaction', () => {
const { onTransaction, onSelectionChange } = renderEditor();
const toolbar = screen.getByRole('toolbar', { name: 'Insert toolbar' });
expect(
Array.from(toolbar.querySelectorAll('button')).map((button) =>
button.getAttribute('aria-label'),
),
).toEqual([
'Insert text box',
'Insert image',
'Insert table',
'Insert chart',
'Insert line',
'Slide background',
'Insert formula',
'Insert video',
'Insert audio',
]);
fireEvent.click(screen.getByRole('button', { name: 'Insert text box' }));
act(() =>
canvasMock.latestProps?.onTextCreate?.({ left: 10, top: 20, width: 200, height: 60 }),
);
expect(onTransaction).toHaveBeenCalledWith(
expect.objectContaining({
operations: [
expect.objectContaining({
type: 'element.add',
element: expect.objectContaining({ id: 'new-text', type: 'text' }),
}),
],
}),
);
expect(onSelectionChange).toHaveBeenCalledWith({
elementIds: ['new-text'],
primaryId: 'new-text',
editingId: 'new-text',
});
});
it('uses insertItems as the visibility filter and display order', () => {
renderEditor({ insertItems: ['audio', 'text', 'image'] });
const toolbar = screen.getByRole('toolbar', { name: 'Insert toolbar' });
expect(
Array.from(toolbar.querySelectorAll('button')).map((button) =>
button.getAttribute('aria-label'),
),
).toEqual(['Insert audio', 'Insert text box', 'Insert image']);
});
it('updates visible editor labels when the host language changes', () => {
const translate = (language: string) => (key: string) => `${language}:${key}`;
const onTransaction = vi.fn();
const onSelectionChange = vi.fn();
const view = render(
<Harness
slide={slide}
scale={1}
host={{ locale: 'ja-JP', translate: translate('ja') }}
onTransaction={onTransaction}
onSelectionChange={onSelectionChange}
/>,
);
expect(screen.getByRole('toolbar', { name: 'ja:insert.toolbar' })).not.toBeNull();
expect(screen.getByRole('button', { name: 'ja:insert.textBox' })).not.toBeNull();
view.rerender(
<Harness
slide={slide}
scale={1}
host={{ locale: 'fr-FR', translate: translate('fr') }}
onTransaction={onTransaction}
onSelectionChange={onSelectionChange}
/>,
);
expect(screen.getByRole('toolbar', { name: 'fr:insert.toolbar' })).not.toBeNull();
expect(screen.getByRole('button', { name: 'fr:insert.textBox' })).not.toBeNull();
});
it('removes the insert rail for an empty insertItems array', () => {
renderEditor({ insertItems: [] });
expect(screen.queryByRole('toolbar', { name: 'Insert toolbar' })).toBeNull();
expect(screen.getByTestId('editable-slide-canvas').parentElement?.parentElement).toHaveStyle({
height: '100%',
});
});
it('converts text editor changes into canonical transactions', async () => {
const { onTransaction } = renderEditor({
initialSelection: { elementIds: ['text-1'], primaryId: 'text-1', editingId: 'text-1' },
});
expect(canvasMock.latestProps?.selection).toEqual({
elementIds: ['text-1'],
primaryId: 'text-1',
editingId: 'text-1',
});
fireEvent.click(await screen.findByRole('button', { name: 'Bold' }));
expect(onTransaction).toHaveBeenCalledWith(
expect.objectContaining({
operations: [
{
type: 'text.updateContent',
elementId: 'text-1',
content: '<p><strong>text-1</strong></p>',
},
],
}),
);
});
it('discards a buffered text draft before toolbar deletion', async () => {
const { onTransaction } = renderEditor({
initialSelection: { elementIds: ['text-1'], primaryId: 'text-1', editingId: 'text-1' },
});
const activeController = canvasMock.activeController;
expect(activeController).not.toBeNull();
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
expect(activeController?.discard).toHaveBeenCalledBefore(onTransaction);
expect(onTransaction).toHaveBeenCalledWith(
expect.objectContaining({
operations: [{ type: 'element.deleteMany', elementIds: ['text-1'] }],
}),
);
});
it('keeps geometry handles editable and routes canvas changes through transactions', () => {
const { onTransaction } = renderEditor({
initialSelection: { elementIds: ['image-1'], primaryId: 'image-1' },
});
expect(canvasMock.latestProps?.onElementsChange).toBeTypeOf('function');
act(() => {
canvasMock.latestProps?.onElementsChange?.([
{
type: 'element.update',
id: 'image-1',
props: { left: 160, top: 120, width: 300, height: 200 },
},
]);
});
expect(onTransaction).toHaveBeenCalledWith(
expect.objectContaining({
operations: [
{
type: 'element.update',
elementId: 'image-1',
patch: { left: 160, top: 120, width: 300, height: 200 },
},
],
}),
);
});
it.each([
['formula-1', 'Formula toolbar'],
['video-1', 'Video toolbar'],
['audio-1', 'Audio toolbar'],
['image-1', 'Image toolbar'],
['table-1', 'Element toolbar'],
['line-1', 'Line toolbar'],
])('provides the registered %s editing overlay', async (id, toolbarName) => {
renderEditor({ initialSelection: { elementIds: [id], primaryId: id } });
expect(canvasMock.latestProps?.selection).toEqual({ elementIds: [id], primaryId: id });
expect(await screen.findByRole('toolbar', { name: toolbarName })).not.toBeNull();
});
it('routes an audio property edit through the canonical transaction sink', async () => {
const { onTransaction } = renderEditor({
initialSelection: { elementIds: ['audio-1'], primaryId: 'audio-1' },
});
fireEvent.click(await screen.findByRole('button', { name: 'Loop' }));
expect(onTransaction).toHaveBeenCalledWith(
expect.objectContaining({
operations: [{ type: 'element.update', elementId: 'audio-1', patch: { loop: true } }],
}),
);
});
it('keeps exactly one editing style element across rerenders', () => {
const view = renderEditor();
expect(view.container.querySelectorAll('style')).toHaveLength(1);
view.rerender(
<Harness
slide={slide}
scale={1}
host={{ locale: 'en-US' }}
onTransaction={vi.fn()}
onSelectionChange={vi.fn()}
/>,
);
expect(view.container.querySelectorAll('style')).toHaveLength(1);
});
it('uses canonical document data for clipboard commands', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText, readText: vi.fn() },
});
const resolved = { ...slide, elements: [{ ...elements[6], src: 'blob:preview' }] } as Slide;
const canonical = {
...slide,
elements: [{ ...elements[6], src: 'asset://image' }],
} as Slide;
renderEditor({
slide: resolved,
documentSlide: canonical,
initialSelection: { elementIds: ['image-1'], primaryId: 'image-1' },
});
fireEvent.keyDown(document, { key: 'c', metaKey: true });
await waitFor(() => expect(writeText).toHaveBeenCalledOnce());
expect(JSON.parse(writeText.mock.calls[0][0]).elements[0].src).toBe('asset://image');
});
});