Files
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 lines
43 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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();
}
});
});