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

243 lines
7.3 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { TextFormatState } from '../../src/react/text/types';
import { computeToolbarPosition, LineToolbarOverlay, TextToolbarOverlay } from '../../src/ui';
import type { PPTLineElement } from '@openmaic/dsl';
const format: TextFormatState = {
bold: false,
em: false,
underline: false,
strikethrough: false,
superscript: false,
subscript: false,
code: false,
color: '#000000',
backcolor: '',
fontsize: '20px',
fontname: 'Arial',
link: '',
align: 'left',
bulletList: false,
orderedList: false,
blockquote: false,
};
const observers: ResizeObserverMock[] = [];
class ResizeObserverMock {
readonly disconnect = vi.fn();
readonly observe = vi.fn();
constructor(readonly callback: ResizeObserverCallback) {
observers.push(this);
}
unobserve() {}
}
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: () => ({}),
};
}
function addTextAnchor(prefix = 'canvas-element-', elementId = 'text-1') {
const wrapper = document.createElement('div');
wrapper.id = prefix + elementId;
const text = document.createElement('div');
text.className = 'base-element-text';
wrapper.appendChild(text);
document.body.appendChild(wrapper);
return { text, wrapper };
}
function addLineAnchor(prefix = 'canvas-element-', elementId = 'line-1') {
const wrapper = document.createElement('div');
wrapper.id = prefix + elementId;
const line = document.createElement('div');
line.className = 'base-element-line';
wrapper.appendChild(line);
document.body.appendChild(wrapper);
return { line, wrapper };
}
const lineElement: PPTLineElement = {
id: 'line-1',
type: 'line',
left: 0,
top: 0,
width: 2,
start: [0, 0],
end: [120, 80],
style: 'solid',
color: '#333333',
points: ['', ''],
};
function renderOverlay() {
return render(
<TextToolbarOverlay
elementId="text-1"
elementIdPrefix="canvas-element-"
format={format}
onCommand={vi.fn()}
/>,
);
}
afterEach(() => {
cleanup();
observers.length = 0;
document.body.innerHTML = '';
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe('computeToolbarPosition', () => {
it('centers a top toolbar above its anchor', () => {
expect(
computeToolbarPosition(
{ left: 200, top: 200, width: 100, height: 50 },
{ width: 300, height: 48 },
{ width: 1000, height: 700 },
'top',
),
).toEqual({ left: 100, top: 144, side: 'top' });
});
it('flips a top toolbar below its anchor and clamps its left edge', () => {
expect(
computeToolbarPosition(
{ left: 4, top: 10, width: 100, height: 50 },
{ width: 300, height: 48 },
{ width: 320, height: 700 },
'top',
),
).toEqual({ left: 12, top: 68, side: 'bottom' });
});
it('clamps a toolbar against the viewport right edge', () => {
expect(
computeToolbarPosition(
{ left: 930, top: 200, width: 100, height: 50 },
{ width: 300, height: 48 },
{ width: 1000, height: 700 },
'top',
),
).toEqual({ left: 688, top: 144, side: 'top' });
});
it('flips an explicit bottom toolbar upward when it would overflow', () => {
expect(
computeToolbarPosition(
{ left: 200, top: 650, width: 100, height: 40 },
{ width: 300, height: 48 },
{ width: 1000, height: 700 },
'bottom',
),
).toEqual({ left: 100, top: 594, side: 'top' });
});
});
describe('TextToolbarOverlay', () => {
it('portals the toolbar to the body and hides it when its anchor is removed', async () => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 1),
);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
const { text, wrapper } = addTextAnchor();
vi.spyOn(text, 'getBoundingClientRect').mockReturnValue(rect(200, 200, 100, 50));
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement,
) {
return this.hasAttribute('data-toolbar-overlay') ? rect(0, 0, 300, 48) : new DOMRect();
});
renderOverlay();
const toolbar = await screen.findByRole('toolbar');
const overlay = toolbar.parentElement as HTMLDivElement;
expect(overlay.parentElement).toBe(document.body);
expect(overlay.dataset.side).toBe('top');
expect(overlay.style.left).toBe('100px');
expect(overlay.style.top).toBe('144px');
wrapper.remove();
act(() => window.dispatchEvent(new Event('resize')));
await waitFor(() => expect(screen.queryByRole('toolbar')).toBeNull());
});
it('releases its observers, animation frame, and global listeners on unmount', () => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 42),
);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
const { text } = addTextAnchor();
vi.spyOn(text, 'getBoundingClientRect').mockReturnValue(rect(200, 200, 100, 50));
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement,
) {
return this.hasAttribute('data-toolbar-overlay') ? rect(0, 0, 300, 48) : new DOMRect();
});
const windowRemove = vi.spyOn(window, 'removeEventListener');
const documentRemove = vi.spyOn(document, 'removeEventListener');
const { unmount } = renderOverlay();
unmount();
expect(observers).toHaveLength(2);
expect(observers.every((observer) => observer.disconnect.mock.calls.length === 1)).toBe(true);
expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
expect(windowRemove).toHaveBeenCalledWith('resize', expect.any(Function));
expect(windowRemove).toHaveBeenCalledWith('pointerdown', expect.any(Function));
expect(windowRemove).toHaveBeenCalledWith('pointerup', expect.any(Function));
expect(windowRemove).toHaveBeenCalledWith('pointercancel', expect.any(Function));
expect(documentRemove).toHaveBeenCalledWith('scroll', expect.any(Function), true);
});
});
describe('LineToolbarOverlay', () => {
it('uses a line paint node as its toolbar anchor', async () => {
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 1),
);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
const { line } = addLineAnchor();
vi.spyOn(line, 'getBoundingClientRect').mockReturnValue(rect(200, 200, 120, 80));
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
this: HTMLElement,
) {
return this.hasAttribute('data-toolbar-overlay') ? rect(0, 0, 300, 48) : new DOMRect();
});
render(
<LineToolbarOverlay
element={lineElement}
elementIdPrefix="canvas-element-"
onChange={vi.fn()}
/>,
);
const toolbar = await screen.findByRole('toolbar', { name: 'Line toolbar' });
expect((toolbar.parentElement as HTMLElement).style.visibility).toBe('visible');
expect((toolbar.parentElement as HTMLElement).style.left).toBe('110px');
});
});