open-design/apps/web/tests/components/ManualEditPanel.test.tsx
lefarcen 755d84e64c
feat(web): merge Draw + Screenshot into one Studio mark tool (#3081) (#3277)
Forward-ports chaoxiaoche's Studio toolbar work from #3081 onto current
main. The preview toolbar drops to 4 controls — Comment, Mark (the merged
Draw/Screenshot tool with box-select + pen sub-tools), Edit, Comments —
matching the latest design. The standalone Screenshot button and its
copy-to-clipboard path are removed; capture now flows through the mark
overlay. Also carries #3081's comment select-all/clear-selection panel and
keeps the Draw send guard added in #3270 (Send disabled mid-run, Queue stays).

Reconciled with main work that postdates #3081's base so nothing is lost:
- Preserves #2190's preview iframe keep-alive pool and the AnnotationHoverPopover
  hover card (re-added on top of #3081's BoardComposerPopover, with its own
  anchor helper so it doesn't clash with the composer popover anchoring).
- i18n: keeps every locale key main added; adopts #3081's mark wording.

Behavior change: the comment side-panel Clear now deselects instead of
batch-deleting selected comments (per #3081); per-comment delete and
send-selected remain.

Validation: pnpm --filter @open-design/web typecheck (clean),
full web vitest (2354 passed), pnpm guard.

Co-authored-by: chaoxiaoche <fanzhen910412@gmail.com>
2026-05-29 06:51:38 +00:00

556 lines
20 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import type { CSSProperties } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Simulate } from 'react-dom/test-utils';
import { JSDOM } from 'jsdom';
import { ManualEditPanel, emptyManualEditDraft, manualEditPatchSummary, normalizeManualEditStyles, type ManualEditDraft } from '../../src/components/ManualEditPanel';
import { emptyManualEditStyles, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../../src/edit-mode/types';
const target: ManualEditTarget = {
id: 'hero-title',
kind: 'text',
label: 'Hero Title',
tagName: 'h1',
className: 'hero',
text: 'Original',
rect: { x: 0, y: 0, width: 120, height: 40 },
fields: { text: 'Original' },
attributes: { 'data-od-id': 'hero-title' },
styles: emptyManualEditStyles(),
isLayoutContainer: false,
outerHtml: '<h1 data-od-id="hero-title">Original</h1>',
};
type OnDraftChange = (draft: ManualEditDraft) => void;
type OnStyleChange = (id: string, styles: Partial<ManualEditStyles>, label: string) => void;
type OnInvalidStyle = (id: string, keys: Array<keyof ManualEditStyles>) => void;
type OnApplyPatch = (patch: ManualEditPatch, label: string) => void;
type OnError = (message: string) => void;
type OnClearSelection = () => void;
type OnSaveDraft = () => void;
type OnCancelDraft = () => void;
describe('ManualEditPanel', () => {
let dom: JSDOM;
let host: HTMLDivElement;
let root: Root;
beforeEach(() => {
dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>');
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
host = dom.window.document.querySelector('#root') as HTMLDivElement;
root = createRoot(host);
});
afterEach(() => {
act(() => root.unmount());
dom.window.close();
Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'document');
Reflect.deleteProperty(globalThis, 'HTMLElement');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
it('renders the style inspector without the advanced editor entry', () => {
renderPanel();
expect(host.textContent).toContain('TYPOGRAPHY');
expect(host.textContent).not.toContain('Advanced');
});
it('shows a readable selected element name in the titlebar', () => {
renderPanel({
selectedTarget: {
...target,
id: 'path-0-0',
kind: 'container',
label: 'div.container.hero-split',
className: 'container hero-split',
text: 'Turn a brand brief into an editorial collage system.',
attributes: { 'data-od-source-path': 'path-0-0' },
},
});
expect(host.querySelector('.manual-edit-titlebar')?.textContent).toContain('Hero split');
expect(host.querySelector('.manual-edit-titlebar')?.textContent).not.toContain('div.container');
});
it('shows a drag handle for floating edit panels', () => {
renderPanel({ floatingStyle: { left: 20, top: 24, width: 320, height: 380 } });
expect(host.querySelector('.manual-edit-drag-handle')).not.toBeNull();
expect(host.querySelector('.manual-edit-drag-handle')?.getAttribute('aria-label')).toBe('Move edit panel');
});
it('does not show page-level controls inside an element inspector', () => {
const onClearSelection = vi.fn();
renderPanel({ onClearSelection });
expect(host.querySelector('button[aria-label="Show page inspector"]')).toBeNull();
expect(host.textContent).not.toContain('PAGE');
expect(onClearSelection).not.toHaveBeenCalled();
});
it('keeps inspector controls scrollable separately from footer actions', () => {
renderPanel();
const scrollRegion = host.querySelector('.manual-edit-scroll');
const footer = host.querySelector('.manual-edit-footer');
const deleteButton = host.querySelector('button[aria-label="Delete element"]');
expect(scrollRegion?.textContent).toContain('TYPOGRAPHY');
expect(scrollRegion?.contains(deleteButton)).toBe(false);
expect(footer?.contains(deleteButton)).toBe(true);
expect(footer?.textContent).toContain('Cancel');
expect(footer?.textContent).toContain('Save');
});
it('routes footer cancel and save actions', () => {
const onCancelDraft = vi.fn<OnCancelDraft>();
const onSaveDraft = vi.fn<OnSaveDraft>();
renderPanel({ onCancelDraft, onSaveDraft });
const footerButtons = Array.from(host.querySelectorAll('.manual-edit-footer button'));
const cancel = footerButtons.find((button) => button.textContent === 'Cancel') as HTMLButtonElement | undefined;
const save = footerButtons.find((button) => button.textContent === 'Save') as HTMLButtonElement | undefined;
if (!cancel || !save) throw new Error('Footer action buttons not found');
act(() => {
cancel.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
save.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onCancelDraft).toHaveBeenCalledTimes(1);
expect(onSaveDraft).toHaveBeenCalledTimes(1);
});
it('normalizes font stacks and writes a usable font-family value', () => {
const onDraftChange = vi.fn();
const onStyleChange = vi.fn();
renderPanel({
onDraftChange,
onStyleChange,
styles: {
...emptyManualEditStyles(),
fontFamily: '"Roboto", sans-serif',
fontSize: '32px',
color: '#111111',
paddingTop: '8px',
},
});
const fontSelect = host.querySelector('select') as HTMLSelectElement | null;
if (!fontSelect) throw new Error('Font select not found');
expect(fontSelect.value).toBe('Roboto, Arial, sans-serif');
act(() => {
fontSelect.value = 'Georgia, serif';
fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
});
expect(onDraftChange).toHaveBeenCalledWith(expect.objectContaining({
styles: expect.objectContaining({ fontFamily: 'Georgia, serif' }),
}));
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { fontFamily: 'Georgia, serif' }, 'Style: Hero Title');
expect(onStyleChange).not.toHaveBeenCalledWith(
'hero-title',
expect.objectContaining({ fontSize: '32px', color: '#111111', paddingTop: '8px' }),
'Style: Hero Title',
);
});
it('shows px-backed values without px in numeric inputs', () => {
renderPanel({
styles: {
...emptyManualEditStyles(),
fontSize: '32px',
},
});
const sizeRow = Array.from(host.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Size'));
const sizeInput = sizeRow?.querySelector('input') as HTMLInputElement | null;
if (!sizeInput) throw new Error('Size input not found');
expect(sizeInput.value).toBe('32');
});
it('increments text typography rows with normalized values', () => {
const onStyleChange = vi.fn();
renderPanel({
onStyleChange,
styles: {
...emptyManualEditStyles(),
fontSize: '32px',
lineHeight: '1.4',
letterSpacing: '1px',
},
});
const sizeIncrease = host.querySelector('button[aria-label="Size increase"]') as HTMLButtonElement | null;
const lineIncrease = host.querySelector('button[aria-label="Line increase"]') as HTMLButtonElement | null;
const trackingDecrease = host.querySelector('button[aria-label="Tracking decrease"]') as HTMLButtonElement | null;
if (!sizeIncrease || !lineIncrease || !trackingDecrease) throw new Error('Stepper button not found');
act(() => {
sizeIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
lineIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
trackingDecrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { fontSize: '33px' }, 'Style: Hero Title');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { lineHeight: '1.5' }, 'Style: Hero Title');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { letterSpacing: '0px' }, 'Style: Hero Title');
expect(host.textContent).not.toContain('Opacity');
expect(host.textContent).not.toContain('Padding');
});
it('does not persist an unchanged target style when the inspector opens', () => {
vi.useFakeTimers();
try {
const onApplyPatch = vi.fn();
renderPanel({ onApplyPatch });
act(() => {
vi.advanceTimersByTime(1600);
});
expect(onApplyPatch).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('normalizes valid style values before host preview/persistence', () => {
expect(normalizeManualEditStyles({
fontSize: '48',
color: '#f00',
opacity: '2',
lineHeight: '1.4',
}, { layoutEnabled: true })).toEqual({
ok: true,
styles: {
fontSize: '48px',
color: '#ff0000',
opacity: '1',
lineHeight: '1.4',
},
});
expect(normalizeManualEditStyles({ lineHeight: '49px' }, { layoutEnabled: true })).toEqual({
ok: true,
styles: { lineHeight: '49px' },
});
});
it('rejects invalid style values before host preview/persistence', () => {
expect(normalizeManualEditStyles({ color: 'tomato' }, { layoutEnabled: true })).toEqual({
ok: false,
error: 'color must be a hex color.',
});
expect(normalizeManualEditStyles({ lineHeight: '-1px' }, { layoutEnabled: true })).toEqual({
ok: false,
error: 'Line height must be a positive number or px value.',
});
});
it('treats empty values as inline style clears', () => {
expect(normalizeManualEditStyles({ fontSize: '', color: '' }, { layoutEnabled: true })).toEqual({
ok: true,
styles: { fontSize: '', color: '' },
});
});
it('does not validate unchanged computed line-height values on blur', () => {
const onError = vi.fn();
const onStyleChange = vi.fn();
renderPanel({
onError,
onStyleChange,
styles: {
...emptyManualEditStyles(),
lineHeight: '48.96px',
},
});
const lineInput = Array.from(host.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Line'))
?.querySelector('input') as HTMLInputElement | null;
if (!lineInput) throw new Error('Line input not found');
act(() => {
lineInput.dispatchEvent(new dom.window.FocusEvent('blur', { bubbles: true }));
});
expect(onError).not.toHaveBeenCalled();
expect(onStyleChange).not.toHaveBeenCalled();
});
it('accepts edited computed pixel line-height values', () => {
const onError = vi.fn();
const onStyleChange = vi.fn();
renderPanel({
onError,
onStyleChange,
styles: {
...emptyManualEditStyles(),
lineHeight: '48.96px',
},
});
const lineInput = Array.from(host.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Line'))
?.querySelector('input') as HTMLInputElement | null;
if (!lineInput) throw new Error('Line input not found');
act(() => {
lineInput.value = '49px';
Simulate.change(lineInput);
});
expect(onError).toHaveBeenCalledWith('');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { lineHeight: '49px' }, 'Style: Hero Title');
});
it('does not persist unchanged page styles when no target is selected', () => {
vi.useFakeTimers();
try {
const onApplyPatch = vi.fn();
renderPanel({ onApplyPatch, selectedTarget: null });
act(() => {
vi.advanceTimersByTime(1600);
});
expect(onApplyPatch).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('emits only the changed page style field', () => {
const onStyleChange = vi.fn();
renderPanel({ onStyleChange, selectedTarget: null });
const bgSwatch = host.querySelector('button[aria-label="Pick Background"]') as HTMLButtonElement | null;
if (!bgSwatch) throw new Error('Background swatch not found');
act(() => {
bgSwatch.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
const colorTile = host.querySelector('button[aria-label="#3b82f6"]') as HTMLButtonElement | null;
if (!colorTile) throw new Error('Background color tile not found');
act(() => {
colorTile.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('__body__', { backgroundColor: '#3b82f6' }, 'Page styles');
expect(onStyleChange).not.toHaveBeenCalledWith(
'__body__',
expect.objectContaining({ fontFamily: expect.any(String) }),
'Page styles',
);
expect(onStyleChange).not.toHaveBeenCalledWith(
'__body__',
expect.objectContaining({ fontSize: expect.any(String) }),
'Page styles',
);
});
it('does not emit untouched page fields when changing the page font', () => {
const onStyleChange = vi.fn();
renderPanel({ onStyleChange, selectedTarget: null });
const fontSelect = host.querySelector('.cc-row select') as HTMLSelectElement | null;
if (!fontSelect) throw new Error('Font select not found');
act(() => {
fontSelect.value = 'Georgia, serif';
fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('__body__', { fontFamily: 'Georgia, serif' }, 'Page styles');
expect(onStyleChange).not.toHaveBeenCalledWith(
'__body__',
expect.objectContaining({ backgroundColor: expect.any(String) }),
'Page styles',
);
expect(onStyleChange).not.toHaveBeenCalledWith(
'__body__',
expect.objectContaining({ fontSize: expect.any(String) }),
'Page styles',
);
});
it('shows an inactive Page inspector for fragment HTML sources', () => {
const onStyleChange = vi.fn();
renderPanel({ onStyleChange, selectedTarget: null, pageStylesEnabled: false });
expect(host.textContent).toContain('Page styles are available only for full HTML documents.');
expect(host.textContent).not.toContain('Background');
expect(host.querySelector('input')).toBeNull();
expect(host.querySelector('select')).toBeNull();
expect(onStyleChange).not.toHaveBeenCalled();
});
it('keeps explicit empty page values as field-specific clears', () => {
const onStyleChange = vi.fn();
renderPanel({ onStyleChange, selectedTarget: null });
const fontSelect = host.querySelector('.cc-row select') as HTMLSelectElement | null;
if (!fontSelect) throw new Error('Font select not found');
act(() => {
fontSelect.value = '';
fontSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('__body__', { fontFamily: '' }, 'Page styles');
expect(onStyleChange).not.toHaveBeenCalledWith(
'__body__',
expect.objectContaining({ backgroundColor: expect.any(String), fontFamily: expect.any(String) }),
'Page styles',
);
});
it('hides layout controls for non-layout single targets', () => {
const onStyleChange = vi.fn();
renderPanel({
onStyleChange,
styles: {
...emptyManualEditStyles(),
gap: 'normal',
flexDirection: 'row',
},
});
const layoutSection = Array.from(host.querySelectorAll('.cc-section')).find((section) => (
section.textContent?.includes('LAYOUT')
));
expect(layoutSection).toBeUndefined();
expect(normalizeManualEditStyles({ gap: '12', flexDirection: 'column' }, { layoutEnabled: false })).toEqual({
ok: true,
styles: {},
});
});
it('enables layout controls for flex or grid containers', () => {
const onStyleChange = vi.fn();
renderPanel({
onStyleChange,
selectedTarget: { ...target, isLayoutContainer: true },
styles: {
...emptyManualEditStyles(),
gap: '8px',
flexDirection: 'row',
},
});
const layoutSection = sectionByTitle('LAYOUT');
expect(layoutSection.classList.contains('cc-section-inactive')).toBe(false);
expect(layoutSection.textContent).not.toContain('Select a container or group to edit layout.');
const gapInput = layoutSection.querySelector('input') as HTMLInputElement | null;
const directionSelect = layoutSection.querySelector('select') as HTMLSelectElement | null;
const gapIncrease = layoutSection.querySelector('button[aria-label="Gap increase"]') as HTMLButtonElement | null;
if (!gapInput || !directionSelect) throw new Error('Layout controls not found');
expect(gapInput.disabled).toBe(false);
expect(directionSelect.disabled).toBe(false);
if (!gapIncrease) throw new Error('Gap increase control not found');
act(() => {
gapIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
directionSelect.value = 'column';
directionSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { gap: '9px' }, 'Style: Hero Title');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { flexDirection: 'column' }, 'Style: Hero Title');
});
it('summarizes full-source history entries without rendering the full file', () => {
const source = '<html><body>' + 'x'.repeat(10_000) + '</body></html>';
expect(manualEditPatchSummary({ kind: 'set-full-source', source })).toBe(
JSON.stringify({ kind: 'set-full-source', bytes: source.length }),
);
expect(manualEditPatchSummary({ kind: 'set-full-source', source })).not.toContain('x'.repeat(100));
});
function sectionByTitle(title: string): HTMLElement {
const section = Array.from(host.querySelectorAll('.cc-section'))
.find((candidate) => candidate.querySelector('.cc-section-head')?.textContent === title) as HTMLElement | undefined;
if (!section) throw new Error(`${title} section not found`);
return section;
}
function renderPanel({
onDraftChange = vi.fn<OnDraftChange>(),
onApplyPatch = vi.fn<OnApplyPatch>(),
onError = vi.fn<OnError>(),
onStyleChange = vi.fn<OnStyleChange>(),
onInvalidStyle = vi.fn<OnInvalidStyle>(),
onClearSelection = vi.fn<OnClearSelection>(),
onCancelDraft = vi.fn<OnCancelDraft>(),
onSaveDraft = vi.fn<OnSaveDraft>(),
attributesText = '{}',
selectedTarget = target,
styles = emptyManualEditStyles(),
pageStylesEnabled = true,
floatingStyle,
onFloatingPositionChange,
}: {
onDraftChange?: OnDraftChange;
onApplyPatch?: OnApplyPatch;
onError?: OnError;
onStyleChange?: OnStyleChange;
onInvalidStyle?: OnInvalidStyle;
onClearSelection?: OnClearSelection;
onCancelDraft?: OnCancelDraft;
onSaveDraft?: OnSaveDraft;
attributesText?: string;
selectedTarget?: ManualEditTarget | null;
styles?: ReturnType<typeof emptyManualEditStyles>;
pageStylesEnabled?: boolean;
floatingStyle?: CSSProperties;
onFloatingPositionChange?: (position: { left: number; top: number }) => void;
} = {}) {
const draft = {
...emptyManualEditDraft('<html></html>'),
text: 'Updated copy',
attributesText,
styles,
outerHtml: target.outerHtml,
};
act(() => {
root.render(
<ManualEditPanel
targets={[target]}
selectedTarget={selectedTarget}
draft={draft}
history={[]}
error={null}
canUndo={false}
canRedo={false}
pageStylesEnabled={pageStylesEnabled}
onSelectTarget={vi.fn<(target: ManualEditTarget) => void>()}
onDraftChange={onDraftChange}
onStyleChange={onStyleChange}
onInvalidStyle={onInvalidStyle}
onApplyPatch={onApplyPatch}
onError={onError}
onClearSelection={onClearSelection}
onCancelDraft={onCancelDraft}
onSaveDraft={onSaveDraft}
onUndo={vi.fn<() => void>()}
onRedo={vi.fn<() => void>()}
floatingStyle={floatingStyle}
onFloatingPositionChange={onFloatingPositionChange}
/>,
);
});
}
});