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

327 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { act, fireEvent, render } from '@testing-library/react';
import { EditorView } from 'prosemirror-view';
import { describe, expect, it, vi } from 'vitest';
import type { Slide } from '@openmaic/dsl';
import { EditableSlideCanvas } from '../../src/react/EditableSlideCanvas';
import type { TextEditorController } from '../../src/react/text/types';
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',
lineHeight: 1.4,
} as const;
const slide = {
id: 'slide-1',
viewportSize: 1000,
viewportRatio: 0.5625,
elements: [
textElement,
{ ...textElement, id: 'text-2', top: 140 },
{ ...textElement, id: 'locked-text', lock: true },
{ ...textElement, id: 'hidden-text' },
],
} as unknown as Slide;
describe('EditableSlideCanvas text rendering', () => {
it('mounts one renderer editor only for the active editable text', () => {
const onTextEditorChange = vi.fn();
const { container } = render(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: ['text-1'], primaryId: 'text-1', editingId: 'text-1' }}
hiddenElementIds={['hidden-text']}
onSelectionChange={vi.fn()}
onElementsChange={vi.fn()}
onTextContentChange={vi.fn()}
onTextEditorChange={onTextEditorChange}
/>,
);
expect(container.querySelectorAll('[data-renderer-text-editor]')).toHaveLength(1);
expect(container.querySelector('[data-renderer-text-editor="text-1"]')).not.toBeNull();
expect(container.querySelector('#slide-element-text-1 .ProseMirror-static')).toBeNull();
expect(onTextEditorChange).toHaveBeenCalledWith(
expect.objectContaining({ elementId: 'text-1' }),
);
});
it('injects ProseMirror list styles when used without the UI wrapper', () => {
const { container } = render(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: ['text-1'], primaryId: 'text-1', editingId: 'text-1' }}
onSelectionChange={vi.fn()}
onElementsChange={vi.fn()}
onTextContentChange={vi.fn()}
/>,
);
expect(
[...container.querySelectorAll('style')].some((style) =>
style.textContent?.includes('.renderer-prosemirror-editor ul {'),
),
).toBe(true);
});
it.each([
['locked text', 'locked-text', undefined],
['hidden text', 'hidden-text', ['hidden-text']],
['missing text', 'missing-text', undefined],
])('does not mount an editor for %s', (_label, editingId, hiddenElementIds) => {
const { container } = render(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: [editingId], primaryId: editingId, editingId }}
hiddenElementIds={hiddenElementIds}
onSelectionChange={vi.fn()}
onElementsChange={vi.fn()}
/>,
);
expect(container.querySelector('[data-renderer-text-editor]')).toBeNull();
});
it('does not mount an editor for a multi-selection with an editing id', () => {
const { container } = render(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{
elementIds: ['text-1', 'locked-text'],
primaryId: 'text-1',
editingId: 'text-1',
}}
onSelectionChange={vi.fn()}
onElementsChange={vi.fn()}
onTextContentChange={vi.fn()}
/>,
);
expect(container.querySelector('[data-renderer-text-editor]')).toBeNull();
});
it('enters text editing on a click but keeps a drag as a move gesture', () => {
const onSelectionChange = vi.fn();
const onElementsChange = vi.fn();
const { container, rerender } = render(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: [] }}
onSelectionChange={onSelectionChange}
onElementsChange={onElementsChange}
onTextContentChange={vi.fn()}
/>,
);
const body = container.querySelector('[data-select-element-id="text-1"]') as HTMLElement;
fireEvent.pointerDown(body, { pointerId: 1, clientX: 10, clientY: 10 });
fireEvent.pointerUp(body, { pointerId: 1, clientX: 10, clientY: 10 });
expect(onSelectionChange).toHaveBeenLastCalledWith({
elementIds: ['text-1'],
primaryId: 'text-1',
editingId: 'text-1',
});
onSelectionChange.mockClear();
rerender(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: ['text-1'], primaryId: 'text-1' }}
onSelectionChange={onSelectionChange}
onElementsChange={onElementsChange}
onTextContentChange={vi.fn()}
/>,
);
const border = container.querySelector('[data-element-id="text-1"]') as SVGElement;
fireEvent.pointerDown(border, { pointerId: 2, clientX: 10, clientY: 10 });
fireEvent.pointerUp(border, { pointerId: 2, clientX: 30, clientY: 20 });
expect(onElementsChange).toHaveBeenCalledWith([
expect.objectContaining({ type: 'element.update', id: 'text-1' }),
]);
expect(onSelectionChange).not.toHaveBeenCalledWith(
expect.objectContaining({ editingId: 'text-1' }),
);
});
it('places the text caret at the clicked position when entering edit mode', () => {
const posAtCoords = vi
.spyOn(EditorView.prototype, 'posAtCoords')
.mockReturnValue({ pos: 4, inside: 1 });
const onSelectionChange = vi.fn();
const controllerRef: { current: TextEditorController | null } = { current: null };
const { container, rerender } = render(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: [] }}
onSelectionChange={onSelectionChange}
onElementsChange={vi.fn()}
onTextContentChange={vi.fn()}
onTextEditorChange={(next) => {
controllerRef.current = next;
}}
/>,
);
const body = container.querySelector('[data-select-element-id="text-1"]') as HTMLElement;
fireEvent.pointerDown(body, { pointerId: 1, clientX: 180, clientY: 55 });
fireEvent.pointerUp(body, { pointerId: 1, clientX: 180, clientY: 55 });
rerender(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: ['text-1'], primaryId: 'text-1', editingId: 'text-1' }}
onSelectionChange={onSelectionChange}
onElementsChange={vi.fn()}
onTextContentChange={vi.fn()}
onTextEditorChange={(next) => {
controllerRef.current = next;
}}
/>,
);
expect(posAtCoords).toHaveBeenCalledWith({ left: 180, top: 55 });
const controller = controllerRef.current;
if (controller === null)
throw new Error('Expected the text editor controller to be registered');
act(() => controller.execute({ command: 'insert', value: 'X' }));
expect(controller.getHTML()).toContain('HelXlo');
});
it('exposes the active ProseMirror pointer path and exits editing on Escape', () => {
const onSelectionChange = vi.fn();
const { container } = render(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: ['text-1'], primaryId: 'text-1', editingId: 'text-1' }}
onSelectionChange={onSelectionChange}
onElementsChange={vi.fn()}
onTextContentChange={vi.fn()}
/>,
);
expect(container.querySelector('[data-select-element-id="text-1"]')).toBeNull();
expect(container.querySelector('[data-element-id="text-1"]')).not.toBeNull();
expect(
(container.querySelector('[data-marquee-surface]') as HTMLElement).style.pointerEvents,
).toBe('none');
fireEvent.keyDown(container.querySelector('.ProseMirror') as HTMLElement, { key: 'Escape' });
expect(onSelectionChange).toHaveBeenLastCalledWith({
elementIds: ['text-1'],
primaryId: 'text-1',
});
});
it('keeps other elements selectable while ProseMirror is editing', () => {
const { container } = render(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: ['text-1'], primaryId: 'text-1', editingId: 'text-1' }}
onSelectionChange={vi.fn()}
onElementsChange={vi.fn()}
onTextContentChange={vi.fn()}
/>,
);
const editor = container.querySelector('[data-renderer-text-editor]') as HTMLElement;
expect(editor.style.cursor).toBe('text');
expect(editor.style.userSelect).toBe('text');
expect(container.querySelector('[data-select-element-id="text-1"]')).toBeNull();
expect(container.querySelector('[data-select-element-id="text-2"]')).not.toBeNull();
expect(container.querySelectorAll('[data-element-id]')).toHaveLength(1);
expect(container.querySelector('[data-element-id="text-1"]')).not.toBeNull();
expect(container.querySelector('[data-hit-kind="blocker"]')).not.toBeNull();
});
it('switches to another text element in one click while editing', () => {
const onSelectionChange = vi.fn();
const { container } = render(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: ['text-1'], primaryId: 'text-1', editingId: 'text-1' }}
onSelectionChange={onSelectionChange}
onElementsChange={vi.fn()}
onTextContentChange={vi.fn()}
/>,
);
const other = container.querySelector('[data-select-element-id="text-2"]') as HTMLElement;
fireEvent.pointerDown(other, { pointerId: 1, clientX: 10, clientY: 150 });
fireEvent.pointerUp(other, { pointerId: 1, clientX: 10, clientY: 150 });
expect(onSelectionChange).toHaveBeenLastCalledWith({
elementIds: ['text-2'],
primaryId: 'text-2',
editingId: 'text-2',
});
});
it('clears editing selection when the blank canvas is pressed', () => {
const onSelectionChange = vi.fn();
const { container } = render(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: ['text-1'], primaryId: 'text-1', editingId: 'text-1' }}
onSelectionChange={onSelectionChange}
onElementsChange={vi.fn()}
onTextContentChange={vi.fn()}
/>,
);
fireEvent.pointerDown(container.querySelector('[data-editable-slide-canvas]') as HTMLElement, {
button: 0,
pointerId: 1,
});
expect(onSelectionChange).toHaveBeenLastCalledWith({ elementIds: [] });
});
it('deletes semantically empty text without recording an intermediate empty-content edit', () => {
let controller: TextEditorController | null = null;
const onElementsChange = vi.fn();
const onSelectionChange = vi.fn();
const onTextContentChange = vi.fn();
const { container } = render(
<EditableSlideCanvas
slide={slide}
scale={1}
selection={{ elementIds: ['text-1'], primaryId: 'text-1', editingId: 'text-1' }}
onSelectionChange={onSelectionChange}
onElementsChange={onElementsChange}
onTextContentChange={onTextContentChange}
onTextEditorChange={(next) => {
controller = next;
}}
/>,
);
act(() => controller?.execute({ command: 'replace', value: '' }));
fireEvent.keyDown(container.querySelector('.ProseMirror') as HTMLElement, { key: 'Escape' });
expect(onElementsChange).toHaveBeenLastCalledWith([
{ type: 'element.delete', ids: ['text-1'] },
]);
expect(onTextContentChange).not.toHaveBeenCalled();
expect(onSelectionChange).toHaveBeenLastCalledWith({ elementIds: [] });
});
});