项目文件夹

文件
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

1068 行
43 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// @vitest-environment jsdom
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PreviewDrawOverlay } from '../../src/components/PreviewDrawOverlay';
import { requestPreviewSnapshot } from '../../src/runtime/exports';
vi.mock('../../src/runtime/exports', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/runtime/exports')>();
return {
...actual,
requestPreviewSnapshot: vi.fn(async () => ({ dataUrl: 'data:image/png;base64,AAAA', w: 10, h: 10 })),
};
});
afterEach(() => {
cleanup();
vi.mocked(requestPreviewSnapshot).mockClear();
});
function mockElementRect(
element: Element,
rect: Partial<DOMRect> & Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>,
) {
const fullRect = {
x: rect.left,
y: rect.top,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
toJSON: () => ({}),
} as DOMRect;
Object.defineProperty(element, 'getBoundingClientRect', {
configurable: true,
value: () => fullRect,
});
}
function drawSelectionBox(
canvas: HTMLCanvasElement,
start: { x: number; y: number },
end: { x: number; y: number },
) {
fireEvent.pointerDown(canvas, { clientX: start.x, clientY: start.y, pointerId: 1 });
fireEvent.pointerMove(canvas, { clientX: end.x, clientY: end.y, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: end.x, clientY: end.y, pointerId: 1 });
}
function drawPenStroke(
canvas: HTMLCanvasElement,
points: Array<{ x: number; y: number }>,
) {
const [first, ...rest] = points;
fireEvent.pointerDown(canvas, { clientX: first!.x, clientY: first!.y, pointerId: 1 });
for (const point of rest) {
fireEvent.pointerMove(canvas, { clientX: point.x, clientY: point.y, pointerId: 1 });
}
const last = points[points.length - 1]!;
fireEvent.pointerUp(canvas, { clientX: last.x, clientY: last.y, pointerId: 1 });
}
function installImageCompositeMocks() {
const originalImage = globalThis.Image;
class MockImage {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
set src(_value: string) {
window.setTimeout(() => this.onload?.(), 0);
}
}
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: MockImage,
writable: true,
});
const getContext = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation((() => ({
beginPath: vi.fn(),
clearRect: vi.fn(),
drawImage: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn(),
lineCap: 'round',
lineJoin: 'round',
lineTo: vi.fn(),
lineWidth: 1,
measureText: vi.fn(() => ({ width: 0 })),
moveTo: vi.fn(),
restore: vi.fn(),
save: vi.fn(),
scale: vi.fn(),
setLineDash: vi.fn(),
stroke: vi.fn(),
strokeRect: vi.fn(),
fillStyle: '',
font: '',
strokeStyle: '',
}) as unknown as CanvasRenderingContext2D) as unknown as HTMLCanvasElement['getContext']);
const toBlob = vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback: BlobCallback) => {
callback(new Blob(['png'], { type: 'image/png' }));
});
return () => {
getContext.mockRestore();
toBlob.mockRestore();
if (originalImage) {
Object.defineProperty(globalThis, 'Image', {
configurable: true,
value: originalImage,
writable: true,
});
} else {
delete (globalThis as { Image?: unknown }).Image;
}
};
}
// Capture the ResizeObserver callback so a test can re-run the overlay's
// canvas-sizing logic after overriding the wrap's layout vs. visual geometry.
function installResizeObserver() {
let callback: ResizeObserverCallback | null = null;
class MockResizeObserver {
constructor(cb: ResizeObserverCallback) {
callback = cb;
}
observe() {}
unobserve() {}
disconnect() {}
}
const previous = (globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
writable: true,
value: MockResizeObserver as unknown as typeof ResizeObserver,
});
return {
trigger: () => callback?.([], {} as ResizeObserver),
restore: () => {
if (previous) {
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
writable: true,
value: previous,
});
} else {
delete (globalThis as { ResizeObserver?: unknown }).ResizeObserver;
}
},
};
}
describe('PreviewDrawOverlay', () => {
it('sizes the ink canvas from the frame layout box, not its CSS-scaled rect', () => {
// A tablet/phone device frame wraps this overlay in a `transform: scale()`
// shell. getBoundingClientRect() reports the already-scaled width, so sizing
// the canvas from it and then letting the ancestor transform shrink it again
// left the canvas covering only the left slice of the frame — the right half
// was un-drawable. offsetWidth is the untransformed layout size and fixes it.
const observer = installResizeObserver();
try {
const { container } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const wrap = container.querySelector<HTMLElement>('.preview-draw-overlay')!;
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
// Layout box is the full 800×600 frame; the visually-scaled rect is half.
Object.defineProperty(wrap, 'offsetWidth', { configurable: true, get: () => 800 });
Object.defineProperty(wrap, 'offsetHeight', { configurable: true, get: () => 600 });
wrap.getBoundingClientRect = () =>
({ x: 0, y: 0, left: 0, top: 0, right: 400, bottom: 300, width: 400, height: 300, toJSON: () => ({}) }) as DOMRect;
observer.trigger();
expect(canvas.style.width).toBe('800px');
expect(canvas.style.height).toBe('600px');
} finally {
observer.restore();
}
});
it('keeps the draw toolbar responsive inside narrow preview surfaces', () => {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas');
// The warning / attached-image strip / toolbar share one bottom-anchored
// floating dock. The toolbar stays one compact row and keeps overflow
// visible so its popover menus are not clipped by the dock.
const dock = container.querySelector<HTMLElement>('.preview-draw-dock');
const toolbar = container.querySelector<HTMLElement>('.preview-draw-toolbar');
const toolCluster = container.querySelector<HTMLElement>('.preview-draw-tool-cluster');
const subTools = container.querySelectorAll<HTMLElement>('.preview-draw-subtool-action');
const noteActions = container.querySelector<HTMLElement>('.preview-draw-note-actions');
const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input');
const close = getByRole('button', { name: 'Close' }) as HTMLButtonElement;
expect(canvas?.style.zIndex).toBe('80');
expect(canvas?.style.touchAction).toBe('none');
expect(canvas?.style.userSelect).toBe('none');
expect(container.querySelector('.preview-draw-overlay-active')).toBeTruthy();
expect(container.querySelector('style')?.textContent).toContain('.preview-draw-overlay-active iframe');
expect(dock?.style.zIndex).toBe('91');
expect(dock?.dataset.drawLayout).toBe('docked');
expect(dock?.style.flexDirection).toBe('column');
expect(dock?.style.left).toBe('calc(50% - 52px)');
expect(dock?.style.maxWidth).toContain('100% - 144px');
expect(toolbar?.style.flexWrap).toBe('nowrap');
expect(toolbar?.style.overflow).toBe('visible');
expect(toolCluster?.style.flex).toBe('0 0 auto');
expect(subTools).toHaveLength(1);
expect(subTools[0]?.style.flex).toBe('0 0 54px');
expect(subTools[0]?.style.minWidth).toBe('54px');
expect(subTools[0]?.style.background).not.toBe('var(--accent)');
expect(subTools[0]?.style.color).toBe('rgb(255, 255, 255)');
expect(subTools[0]?.parentElement?.style.background).toBe('transparent');
expect(subTools[0]?.parentElement?.style.borderStyle).toBe('none');
expect(subTools[0]?.querySelector('.ri-arrow-down-s-line')).toBeTruthy();
expect(noteActions?.style.flex).toBe('1 1 360px');
expect(noteActions?.style.minWidth).toBe('0px');
expect(noteActions?.style.maxWidth).toBe('420px');
expect(close.style.flex).toBe('0 0 30px');
expect(close.style.minWidth).toBe('30px');
expect(close.style.aspectRatio).toBe('1 / 1');
expect(input?.style.flexGrow).toBe('1');
expect(input?.style.flexShrink).toBe('1');
expect(input?.style.flexBasis).toBe('240px');
expect(input?.style.minWidth).toBe('0px');
expect(input?.style.maxWidth).toBe('100%');
});
it('uses a single current-tool button with a box select and pen menu', () => {
const onToolbarClick = vi.fn();
const { container, getByRole, queryByRole } = render(
<PreviewDrawOverlay active onToolbarClick={onToolbarClick}>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
expect(container.querySelectorAll('.preview-draw-subtool-action')).toHaveLength(1);
fireEvent.click(getByRole('button', { name: 'Box select' }));
expect(getByRole('menu', { name: 'Mark tool' })).toBeTruthy();
expect(getByRole('menuitemradio', { name: 'Box select' })).toBeTruthy();
expect(getByRole('menuitemradio', { name: 'Pen' })).toBeTruthy();
fireEvent.click(getByRole('menuitemradio', { name: 'Pen' }));
expect(onToolbarClick).toHaveBeenLastCalledWith('pen');
expect(queryByRole('menu', { name: 'Mark tool' })).toBeNull();
expect(queryByRole('button', { name: 'Box select' })).toBeNull();
expect(getByRole('button', { name: 'Pen' })).toBeTruthy();
fireEvent.click(getByRole('button', { name: 'Pen' }));
fireEvent.click(getByRole('menuitemradio', { name: 'Box select' }));
expect(onToolbarClick).toHaveBeenLastCalledWith('rect');
expect(getByRole('button', { name: 'Box select' })).toBeTruthy();
expect(queryByRole('button', { name: 'Pen' })).toBeNull();
});
it('offers a text tool in the mark menu and switches to it', () => {
const onToolbarClick = vi.fn();
const { getByRole, queryByRole } = render(
<PreviewDrawOverlay active onToolbarClick={onToolbarClick}>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
fireEvent.click(getByRole('button', { name: 'Box select' }));
expect(getByRole('menuitemradio', { name: 'Text' })).toBeTruthy();
fireEvent.click(getByRole('menuitemradio', { name: 'Text' }));
expect(onToolbarClick).toHaveBeenLastCalledWith('text');
expect(getByRole('button', { name: 'Text' })).toBeTruthy();
expect(queryByRole('button', { name: 'Box select' })).toBeNull();
});
it('drops focusable, independent text labels where the canvas is pressed', () => {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
canvas.getBoundingClientRect = () =>
({ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200, width: 200, height: 200, toJSON: () => ({}) }) as DOMRect;
fireEvent.click(getByRole('button', { name: 'Box select' }));
fireEvent.click(getByRole('menuitemradio', { name: 'Text' }));
// First press drops a label and focuses it.
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40, pointerId: 1 });
let textareas = container.querySelectorAll<HTMLTextAreaElement>('.preview-draw-text-layer textarea');
expect(textareas).toHaveLength(1);
expect(document.activeElement).toBe(textareas[0]);
fireEvent.change(textareas[0]!, { target: { value: 'Look here' } });
expect(textareas[0]!.value).toBe('Look here');
// A second press adds another independent label, not replacing the first.
fireEvent.pointerDown(canvas, { clientX: 120, clientY: 120, pointerId: 1 });
textareas = container.querySelectorAll<HTMLTextAreaElement>('.preview-draw-text-layer textarea');
expect(textareas).toHaveLength(2);
expect(textareas[0]!.value).toBe('Look here');
});
it('discards a text label left empty on blur', () => {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
canvas.getBoundingClientRect = () =>
({ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200, width: 200, height: 200, toJSON: () => ({}) }) as DOMRect;
fireEvent.click(getByRole('button', { name: 'Box select' }));
fireEvent.click(getByRole('menuitemradio', { name: 'Text' }));
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40, pointerId: 1 });
const textarea = container.querySelector<HTMLTextAreaElement>('.preview-draw-text-layer textarea')!;
expect(textarea).toBeTruthy();
fireEvent.blur(textarea);
expect(container.querySelectorAll('.preview-draw-text-layer textarea')).toHaveLength(0);
});
it('places a plain caret label with no placeholder, and reveals remove on hover only', () => {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
canvas.getBoundingClientRect = () =>
({ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200, width: 200, height: 200, toJSON: () => ({}) }) as DOMRect;
fireEvent.click(getByRole('button', { name: 'Box select' }));
fireEvent.click(getByRole('menuitemradio', { name: 'Text' }));
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40, pointerId: 1 });
const textarea = container.querySelector<HTMLTextAreaElement>('.preview-draw-text-layer textarea')!;
// No placeholder — the label is just glyphs + a blinking caret.
expect(textarea.getAttribute('placeholder')).toBeNull();
// The remove control is hover-gated via CSS, not always-on.
const remove = container.querySelector<HTMLElement>('.preview-draw-text-remove');
expect(remove).toBeTruthy();
const style = container.querySelector('style')?.textContent ?? '';
expect(style).toContain('.preview-draw-text-mark:hover .preview-draw-text-remove');
});
it('moves a placed text label by dragging it', () => {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
canvas.getBoundingClientRect = () =>
({ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200, width: 200, height: 200, toJSON: () => ({}) }) as DOMRect;
fireEvent.click(getByRole('button', { name: 'Box select' }));
fireEvent.click(getByRole('menuitemradio', { name: 'Text' }));
// Drop at 20%,20% and give it text so it survives blur as a placed label.
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40, pointerId: 1 });
const textarea = container.querySelector<HTMLTextAreaElement>('.preview-draw-text-layer textarea')!;
fireEvent.change(textarea, { target: { value: 'drag me' } });
fireEvent.blur(textarea);
const wrap = container.querySelector<HTMLElement>('.preview-draw-text-mark')!;
expect(wrap.style.left).toBe('20%');
// Grab at the drop point, drag to 60%,60%.
fireEvent.pointerDown(wrap, { clientX: 40, clientY: 40, pointerId: 2 });
fireEvent.pointerMove(wrap, { clientX: 120, clientY: 120, pointerId: 2 });
fireEvent.pointerUp(wrap, { clientX: 120, clientY: 120, pointerId: 2 });
expect(wrap.style.left).toBe('60%');
expect(wrap.style.top).toBe('60%');
});
it('re-opens a placed label for editing on double tap', () => {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
canvas.getBoundingClientRect = () =>
({ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200, width: 200, height: 200, toJSON: () => ({}) }) as DOMRect;
fireEvent.click(getByRole('button', { name: 'Box select' }));
fireEvent.click(getByRole('menuitemradio', { name: 'Text' }));
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40, pointerId: 1 });
const textarea = container.querySelector<HTMLTextAreaElement>('.preview-draw-text-layer textarea')!;
fireEvent.change(textarea, { target: { value: 'edit me' } });
fireEvent.blur(textarea);
// Placed: read-only until re-opened.
expect(textarea.readOnly).toBe(true);
const wrap = container.querySelector<HTMLElement>('.preview-draw-text-mark')!;
// Two taps in the same spot re-open it for editing.
fireEvent.pointerDown(wrap, { clientX: 40, clientY: 40, pointerId: 2 });
fireEvent.pointerUp(wrap, { clientX: 40, clientY: 40, pointerId: 2 });
fireEvent.pointerDown(wrap, { clientX: 40, clientY: 40, pointerId: 3 });
fireEvent.pointerUp(wrap, { clientX: 40, clientY: 40, pointerId: 3 });
expect(container.querySelector<HTMLTextAreaElement>('.preview-draw-text-layer textarea')!.readOnly).toBe(false);
});
it('captures and sends a text-only annotation', async () => {
const restoreCompositeMocks = installImageCompositeMocks();
const annotation = vi.fn((event: Event) => {
const detail = (event as CustomEvent<{ ack?: (result: { ok: boolean }) => void }>).detail;
detail.ack?.({ ok: true });
});
window.addEventListener('opendesign:annotation', annotation);
try {
const { container, getByRole } = render(
<PreviewDrawOverlay active captureViewport captureSnapshot={async () => ({ dataUrl: 'data:image/png;base64,cG5n', w: 20, h: 12 })}>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
canvas.getBoundingClientRect = () =>
({ x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200, width: 200, height: 200, toJSON: () => ({}) }) as DOMRect;
fireEvent.click(getByRole('button', { name: 'Box select' }));
fireEvent.click(getByRole('menuitemradio', { name: 'Text' }));
fireEvent.pointerDown(canvas, { clientX: 40, clientY: 40, pointerId: 1 });
const textarea = container.querySelector<HTMLTextAreaElement>('.preview-draw-text-layer textarea')!;
fireEvent.change(textarea, { target: { value: 'Annotate me' } });
fireEvent.click(getByRole('button', { name: 'Send' }));
await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1));
const detail = (annotation.mock.calls[0]?.[0] as CustomEvent).detail as { file: File | null; markKind?: string };
expect(detail.file).toBeInstanceOf(File);
expect(detail.markKind).toBe('stroke');
} finally {
window.removeEventListener('opendesign:annotation', annotation);
restoreCompositeMocks();
}
});
it('prevents preview text selection while drawing on touch-sized frames', () => {
const { container } = render(
<PreviewDrawOverlay active>
<iframe title="preview" />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas');
expect(canvas).toBeTruthy();
expect(fireEvent.pointerDown(canvas!, { clientX: 10, clientY: 10, pointerId: 1 })).toBe(false);
expect(fireEvent.pointerMove(canvas!, { clientX: 40, clientY: 40, pointerId: 1 })).toBe(false);
expect(fireEvent.pointerUp(canvas!, { clientX: 40, clientY: 40, pointerId: 1 })).toBe(false);
});
it('queues a note when Enter submits from the draw input', async () => {
const annotation = vi.fn();
window.addEventListener('opendesign:annotation', annotation);
try {
const { container } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input');
expect(input).toBeTruthy();
fireEvent.change(input!, { target: { value: 'Please inspect this panel.' } });
fireEvent.keyDown(input!, { key: 'Enter' });
await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1));
expect(annotation.mock.calls[0]?.[0].detail).toMatchObject({
action: 'queue',
note: 'Please inspect this panel.',
});
} finally {
window.removeEventListener('opendesign:annotation', annotation);
}
});
it('does not submit a note when Enter confirms IME composition', () => {
const annotation = vi.fn();
window.addEventListener('opendesign:annotation', annotation);
try {
const { container } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input');
expect(input).toBeTruthy();
fireEvent.change(input!, { target: { value: '检查这个面板' } });
fireEvent.compositionStart(input!);
fireEvent.keyDown(input!, { key: 'Enter', keyCode: 229 });
expect(annotation).not.toHaveBeenCalled();
} finally {
window.removeEventListener('opendesign:annotation', annotation);
}
});
it('disables only the primary send action when sending is blocked', async () => {
const annotation = vi.fn((event: Event) => {
const detail = (event as CustomEvent<{ ack?: (result: { ok: boolean }) => void }>).detail;
detail.ack?.({ ok: true });
});
window.addEventListener('opendesign:annotation', annotation);
try {
const { container, getByRole } = render(
<PreviewDrawOverlay active sendDisabled sendDisabledReason="Task running">
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input');
expect(input).toBeTruthy();
fireEvent.change(input!, { target: { value: 'Please queue this note.' } });
// Send is the split button's default action; Queue and Add-to-input now
// live in its dropdown, opened via the chevron.
const sendButton = getByRole('button', { name: 'Send' }) as HTMLButtonElement;
fireEvent.click(getByRole('button', { name: 'Submit options' }));
const queueButton = getByRole('menuitemradio', { name: 'Queue' }) as HTMLButtonElement;
const addToInputButton = getByRole('menuitemradio', { name: 'Add to input' }) as HTMLButtonElement;
expect(sendButton.disabled).toBe(true);
expect(sendButton.title).toBe('Task running');
expect(queueButton.disabled).toBe(false);
expect(addToInputButton.disabled).toBe(false);
fireEvent.keyDown(input!, { key: 'Enter' });
await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1));
expect(annotation.mock.calls[0]?.[0]).toMatchObject({
detail: expect.objectContaining({ action: 'queue' }),
});
fireEvent.click(sendButton);
expect(annotation).toHaveBeenCalledTimes(1);
// The dropdown stays open through this flow, so click the Queue item
// directly (re-clicking the chevron would just toggle it shut).
fireEvent.change(input!, { target: { value: 'Queue another note.' } });
fireEvent.click(getByRole('menuitemradio', { name: 'Queue' }));
await waitFor(() => expect(annotation).toHaveBeenCalledTimes(2));
} finally {
window.removeEventListener('opendesign:annotation', annotation);
}
});
it('can append a note to the composer input instead of queueing or sending it', async () => {
const annotation = vi.fn((event: Event) => {
const detail = (event as CustomEvent<{ ack?: (result: { ok: boolean }) => void }>).detail;
detail.ack?.({ ok: true });
});
window.addEventListener('opendesign:annotation', annotation);
try {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input');
expect(input).toBeTruthy();
fireEvent.change(input!, { target: { value: 'Keep this in the input.' } });
// Add-to-input is now a choice in the submit dropdown.
fireEvent.click(getByRole('button', { name: 'Submit options' }));
fireEvent.click(getByRole('menuitemradio', { name: 'Add to input' }));
await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1));
expect(annotation.mock.calls[0]?.[0]).toMatchObject({
detail: expect.objectContaining({
action: 'draft',
note: 'Keep this in the input.',
}),
});
} finally {
window.removeEventListener('opendesign:annotation', annotation);
}
});
it('clears transient ink when draw mode exits', async () => {
const { container, rerender } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector('canvas');
expect(canvas).toBeTruthy();
fireEvent.pointerDown(canvas!, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerMove(canvas!, { clientX: 40, clientY: 40, pointerId: 1 });
fireEvent.pointerUp(canvas!, { pointerId: 1 });
rerender(
<PreviewDrawOverlay active={false}>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
await waitFor(() => expect(container.querySelector('canvas')).toBeNull());
});
it('accumulates multiple box selections and undoes them one at a time', () => {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
// jsdom reports a zero-size rect; give the canvas real geometry so pointer
// fractions resolve to committable (non-degenerate) normalized boxes.
canvas.getBoundingClientRect = () =>
({
x: 0, y: 0, left: 0, top: 0, right: 200, bottom: 200, width: 200, height: 200,
toJSON: () => ({}),
}) as DOMRect;
const undo = getByRole('button', { name: 'Undo' }) as HTMLButtonElement;
expect(undo.disabled).toBe(true);
// Box-select is the default tool. Draw the first region.
fireEvent.pointerDown(canvas, { clientX: 10, clientY: 10, pointerId: 1 });
fireEvent.pointerMove(canvas, { clientX: 60, clientY: 60, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 60, clientY: 60, pointerId: 1 });
expect(undo.disabled).toBe(false);
// A second region must accumulate, not replace the first.
fireEvent.pointerDown(canvas, { clientX: 120, clientY: 120, pointerId: 1 });
fireEvent.pointerMove(canvas, { clientX: 170, clientY: 170, pointerId: 1 });
fireEvent.pointerUp(canvas, { clientX: 170, clientY: 170, pointerId: 1 });
// First undo removes only the latest box; one still remains.
fireEvent.click(undo);
expect(undo.disabled).toBe(false);
// Second undo clears the remaining box.
fireEvent.click(undo);
expect(undo.disabled).toBe(true);
});
it('floats the draw composer next to the latest box selection', async () => {
const { container } = render(
<PreviewDrawOverlay active>
<div style={{ width: 700, height: 320 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
const dock = container.querySelector<HTMLElement>('.preview-draw-dock')!;
const wrap = canvas.parentElement as HTMLElement;
mockElementRect(canvas, { left: 0, top: 0, width: 700, height: 320 });
mockElementRect(wrap, { left: 0, top: 0, width: 700, height: 320 });
mockElementRect(dock, { left: 0, top: 0, width: 180, height: 96 });
drawSelectionBox(canvas, { x: 80, y: 80 }, { x: 180, y: 160 });
await waitFor(() => expect(dock.dataset.drawLayout).toBe('floating'));
expect(dock.dataset.drawSide).toBe('right');
expect(dock.style.left).toBe('192px');
expect(dock.style.top).toBe('72px');
drawSelectionBox(canvas, { x: 320, y: 180 }, { x: 420, y: 260 });
await waitFor(() => expect(dock.style.left).toBe('432px'));
expect(dock.style.top).toBe('172px');
});
it('flips the floating composer away from the nearest clipped edge', async () => {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 700, height: 320 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
const dock = container.querySelector<HTMLElement>('.preview-draw-dock')!;
const wrap = canvas.parentElement as HTMLElement;
mockElementRect(canvas, { left: 0, top: 0, width: 700, height: 320 });
mockElementRect(wrap, { left: 0, top: 0, width: 700, height: 320 });
mockElementRect(dock, { left: 0, top: 0, width: 180, height: 96 });
drawSelectionBox(canvas, { x: 520, y: 120 }, { x: 620, y: 200 });
await waitFor(() => expect(dock.dataset.drawSide).toBe('left'));
expect(dock.style.left).toBe('328px');
fireEvent.click(getByRole('button', { name: 'Undo' }));
await waitFor(() => expect(dock.dataset.drawLayout).toBe('docked'));
drawSelectionBox(canvas, { x: 12, y: 100 }, { x: 112, y: 180 });
await waitFor(() => expect(dock.dataset.drawSide).toBe('right'));
expect(dock.style.left).toBe('124px');
});
it('follows the latest pen stroke instead of the aggregate stroke bounds', async () => {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 700, height: 320 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
const dock = container.querySelector<HTMLElement>('.preview-draw-dock')!;
const wrap = canvas.parentElement as HTMLElement;
mockElementRect(canvas, { left: 0, top: 0, width: 700, height: 320 });
mockElementRect(wrap, { left: 0, top: 0, width: 700, height: 320 });
mockElementRect(dock, { left: 0, top: 0, width: 180, height: 96 });
fireEvent.click(getByRole('button', { name: 'Box select' }));
fireEvent.click(getByRole('menuitemradio', { name: 'Pen' }));
drawPenStroke(canvas, [{ x: 40, y: 40 }, { x: 100, y: 90 }]);
await waitFor(() => expect(dock.dataset.drawLayout).toBe('floating'));
const firstPosition = dock.style.left;
drawPenStroke(canvas, [{ x: 360, y: 200 }, { x: 420, y: 240 }, { x: 470, y: 250 }]);
await waitFor(() => expect(dock.style.left).not.toBe(firstPosition));
expect(dock.dataset.drawSide).toBe('right');
expect(dock.style.left).toBe('490px');
});
it('returns to the previous box after undo and docks after clearing all marks', async () => {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 700, height: 320 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
const dock = container.querySelector<HTMLElement>('.preview-draw-dock')!;
const wrap = canvas.parentElement as HTMLElement;
mockElementRect(canvas, { left: 0, top: 0, width: 700, height: 320 });
mockElementRect(wrap, { left: 0, top: 0, width: 700, height: 320 });
mockElementRect(dock, { left: 0, top: 0, width: 180, height: 96 });
drawSelectionBox(canvas, { x: 60, y: 70 }, { x: 160, y: 150 });
await waitFor(() => expect(dock.style.left).toBe('172px'));
drawSelectionBox(canvas, { x: 340, y: 100 }, { x: 440, y: 180 });
await waitFor(() => expect(dock.style.left).toBe('452px'));
fireEvent.click(getByRole('button', { name: 'Undo' }));
await waitFor(() => expect(dock.style.left).toBe('172px'));
fireEvent.click(getByRole('button', { name: 'Undo' }));
await waitFor(() => expect(dock.dataset.drawLayout).toBe('docked'));
});
it('keeps the input mounted and preserves its value while the floating position updates', async () => {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 700, height: 320 }} />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
const dock = container.querySelector<HTMLElement>('.preview-draw-dock')!;
const wrap = canvas.parentElement as HTMLElement;
mockElementRect(canvas, { left: 0, top: 0, width: 700, height: 320 });
mockElementRect(wrap, { left: 0, top: 0, width: 700, height: 320 });
mockElementRect(dock, { left: 0, top: 0, width: 180, height: 96 });
const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input')!;
fireEvent.change(input, { target: { value: 'keep me here' } });
fireEvent.click(getByRole('button', { name: 'Submit options' }));
drawSelectionBox(canvas, { x: 80, y: 80 }, { x: 180, y: 160 });
await waitFor(() => expect(dock.dataset.drawLayout).toBe('floating'));
const inputAfter = container.querySelector<HTMLInputElement>('.preview-draw-note-input')!;
expect(inputAfter).toBe(input);
expect(inputAfter.value).toBe('keep me here');
expect(getByRole('menu')).toBeTruthy();
});
it('remembers the submit action chosen from the dropdown', async () => {
const annotation = vi.fn((event: Event) => {
const detail = (event as CustomEvent<{ ack?: (result: { ok: boolean }) => void }>).detail;
detail.ack?.({ ok: true });
});
window.addEventListener('opendesign:annotation', annotation);
try {
const { container, getByRole } = render(
<PreviewDrawOverlay active>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
const input = container.querySelector<HTMLInputElement>('.preview-draw-note-input');
fireEvent.change(input!, { target: { value: 'ship it' } });
// Default main action is Send.
expect(getByRole('button', { name: 'Send' })).toBeTruthy();
// Pick Queue from the dropdown: it runs the action and becomes the new default.
fireEvent.click(getByRole('button', { name: 'Submit options' }));
fireEvent.click(getByRole('menuitemradio', { name: 'Queue' }));
await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1));
expect(annotation.mock.calls[0]?.[0]).toMatchObject({
detail: expect.objectContaining({ action: 'queue' }),
});
// The split button's default action is now Queue.
await waitFor(() => expect(getByRole('button', { name: 'Queue' })).toBeTruthy());
} finally {
window.removeEventListener('opendesign:annotation', annotation);
}
});
it('forwards wheel scrolling to the preview iframe while drawing', () => {
const { container } = render(
<PreviewDrawOverlay active>
<iframe title="preview" />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector('canvas');
const iframe = container.querySelector('iframe');
expect(canvas).toBeTruthy();
expect(iframe?.contentWindow).toBeTruthy();
const scrollBy = vi.fn();
Object.defineProperty(iframe!.contentWindow!, 'scrollBy', {
value: scrollBy,
configurable: true,
});
fireEvent.wheel(canvas!, {
deltaX: 12,
deltaY: 180,
});
expect(scrollBy).toHaveBeenCalledWith({ left: 12, top: 180, behavior: 'auto' });
});
it('uses the postMessage scroll bridge for sandboxed preview iframes', () => {
const { container } = render(
<PreviewDrawOverlay active>
<iframe title="preview" sandbox="allow-scripts allow-downloads" />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector('canvas');
const iframe = container.querySelector('iframe');
expect(canvas).toBeTruthy();
expect(iframe?.contentWindow).toBeTruthy();
const postMessage = vi.fn();
Object.defineProperty(iframe!.contentWindow!, 'postMessage', {
value: postMessage,
configurable: true,
});
fireEvent.wheel(canvas!, {
deltaX: 8,
deltaY: 96,
});
expect(postMessage).toHaveBeenCalledWith(
{ type: 'od:preview-scroll-by', left: 8, top: 96 },
'*',
);
});
it('falls back to the scroll bridge when direct frame scroll is cross-origin blocked', () => {
const { container } = render(
<PreviewDrawOverlay active>
<iframe title="preview" />
</PreviewDrawOverlay>,
);
const canvas = container.querySelector('canvas');
const iframe = container.querySelector('iframe');
expect(canvas).toBeTruthy();
expect(iframe?.contentWindow).toBeTruthy();
const postMessage = vi.fn();
Object.defineProperty(iframe!.contentWindow!, 'postMessage', {
value: postMessage,
configurable: true,
});
Object.defineProperty(iframe!.contentWindow!, 'scrollBy', {
get() {
throw new DOMException('Blocked a frame from accessing a cross-origin frame.', 'SecurityError');
},
configurable: true,
});
fireEvent.wheel(canvas!, {
deltaX: 4,
deltaY: 72,
});
expect(postMessage).toHaveBeenCalledWith(
{ type: 'od:preview-scroll-by', left: 4, top: 72 },
'*',
);
});
it('closes the draw toolbar from an explicit close button', async () => {
const onActiveChange = vi.fn();
const { getByRole } = render(
<PreviewDrawOverlay active onActiveChange={onActiveChange}>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
fireEvent.click(getByRole('button', { name: 'Close' }));
expect(onActiveChange).toHaveBeenCalledWith(false);
});
it('snapshots the srcDoc bridge iframe, not the visible URL-load frame', async () => {
const snapshot = vi.mocked(requestPreviewSnapshot);
const { getByRole } = render(
<PreviewDrawOverlay active captureViewport>
{/* URL-load frame is the visible/active one (e.g. a deck) but has no bridge */}
<iframe title="url" data-od-active="true" />
{/* srcDoc frame is mounted but hidden; it hosts the snapshot bridge */}
<iframe title="srcdoc" data-od-render-mode="srcdoc" data-od-active="false" />
</PreviewDrawOverlay>,
);
fireEvent.click(getByRole('button', { name: 'Send' }));
await waitFor(() => expect(snapshot).toHaveBeenCalled());
const usedIframe = snapshot.mock.calls[0]?.[0] as HTMLIFrameElement;
expect(usedIframe.getAttribute('data-od-render-mode')).toBe('srcdoc');
});
it('portals the draw toolbar out of the scaled/clipped device frame to the preview body', async () => {
const { container } = render(
<div className="viewer-body">
<div className="comment-preview-layer">
<div className="comment-frame-clip">
<PreviewDrawOverlay active>
<iframe title="preview" />
</PreviewDrawOverlay>
</div>
</div>
</div>,
);
const body = container.querySelector('.viewer-body')!;
const iframe = body.querySelector('iframe')!;
// The overlay wrap (and its ink canvas) stays inside the clipped device frame…
const wrap = iframe.parentElement!;
await waitFor(() => {
const input = body.querySelector<HTMLInputElement>('.preview-draw-note-input');
expect(input).toBeTruthy();
// …but the toolbar is portaled out to the non-scrolling preview body, escaping
// the clip so it can never be cut off by the device frame (issue #3455).
expect(wrap.contains(input!)).toBe(false);
expect(body.contains(input!)).toBe(true);
});
});
it('uses the caller-provided preview viewport as the draw toolbar host', async () => {
const toolbarHost = document.createElement('div');
toolbarHost.className = 'preview-viewport';
document.body.appendChild(toolbarHost);
try {
const { container } = render(
<PreviewDrawOverlay active toolbarHost={toolbarHost}>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
await waitFor(() => {
const input = toolbarHost.querySelector<HTMLInputElement>('.preview-draw-note-input');
expect(input).toBeTruthy();
expect(container.querySelector<HTMLInputElement>('.preview-draw-note-input')).toBeNull();
});
} finally {
toolbarHost.remove();
}
});
it('positions the floating dock in viewer-body coordinates when portaled', async () => {
const { container } = render(
<div className="viewer-body">
<div className="comment-preview-layer">
<div className="comment-frame-clip">
<PreviewDrawOverlay active>
<div style={{ width: 400, height: 280 }} />
</PreviewDrawOverlay>
</div>
</div>
</div>,
);
const body = container.querySelector<HTMLElement>('.viewer-body')!;
const canvas = container.querySelector<HTMLCanvasElement>('canvas')!;
const dock = body.querySelector<HTMLElement>('.preview-draw-dock')!;
const wrap = canvas.parentElement as HTMLElement;
mockElementRect(body, { left: 0, top: 0, width: 900, height: 700 });
mockElementRect(wrap, { left: 120, top: 160, width: 400, height: 280 });
mockElementRect(canvas, { left: 120, top: 160, width: 400, height: 280 });
mockElementRect(dock, { left: 0, top: 0, width: 180, height: 96 });
drawSelectionBox(canvas, { x: 160, y: 220 }, { x: 260, y: 300 });
await waitFor(() => expect(dock.dataset.drawLayout).toBe('floating'));
expect(dock.dataset.drawSide).toBe('right');
expect(dock.style.left).toBe('272px');
expect(dock.style.top).toBe('212px');
expect(body.contains(dock)).toBe(true);
expect(wrap.contains(dock)).toBe(false);
});
it('hides draw chrome before a compositor annotation snapshot', async () => {
const restoreCompositeMocks = installImageCompositeMocks();
const annotation = vi.fn((event: Event) => {
const detail = (event as CustomEvent<{ ack?: (result: { ok: boolean }) => void }>).detail;
detail.ack?.({ ok: true });
});
window.addEventListener('opendesign:annotation', annotation);
let host: HTMLElement | null = null;
const captureSnapshot = vi.fn(async () => {
expect(host?.querySelector('canvas')?.style.visibility).toBe('hidden');
expect(host?.querySelector<HTMLElement>('.preview-draw-toolbar')?.style.visibility).toBe('hidden');
return { dataUrl: 'data:image/png;base64,cG5n', w: 10, h: 10 };
});
try {
const { container, getByRole } = render(
<PreviewDrawOverlay active captureViewport captureSnapshot={captureSnapshot}>
<div style={{ width: 320, height: 200 }} />
</PreviewDrawOverlay>,
);
host = container;
fireEvent.click(getByRole('button', { name: 'Send' }));
await waitFor(() => expect(captureSnapshot).toHaveBeenCalledTimes(1));
await waitFor(() => expect(annotation).toHaveBeenCalledTimes(1));
expect(container.querySelector<HTMLElement>('.preview-draw-toolbar')?.style.visibility).toBe('');
} finally {
window.removeEventListener('opendesign:annotation', annotation);
restoreCompositeMocks();
}
});
});