mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
fix(web): dock comment side panel outside preview (#2073)
* fix: dock comment board without clipping inspect When the comment-side dock falls back to the stacked layout in narrow panes, collapsing the side panel now shrinks the bottom strip to a horizontal rail height instead of keeping the full panel-height row. commentPreviewCanvasSize() also stops over-deducting the expanded panel height in the stacked-collapsed path so the canvas sizing stays in sync with what is rendered. * fix(web): address docked comment panel review follow-ups * Fix non-docked comment tool tablet scaling * test(web): align comment panel tests with collapse API
This commit is contained in:
parent
729ce2b0cb
commit
def2e9fd2e
3 changed files with 773 additions and 339 deletions
|
|
@ -143,6 +143,14 @@ export type ManualEditPendingStyleSave = {
|
|||
};
|
||||
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
|
||||
type PreviewCanvasSize = { width: number; height: number };
|
||||
type CommentPreviewCanvasOptions = {
|
||||
boardMode: boolean;
|
||||
sidePanelCollapsed: boolean;
|
||||
viewport?: PreviewViewportId;
|
||||
};
|
||||
type PreviewScaleOptions = {
|
||||
canvasPadding?: number;
|
||||
};
|
||||
type PreviewViewportPreset = {
|
||||
id: PreviewViewportId;
|
||||
width: number | null;
|
||||
|
|
@ -214,6 +222,18 @@ const PREVIEW_VIEWPORT_PRESETS: PreviewViewportPreset[] = [
|
|||
},
|
||||
];
|
||||
const EXPORT_READY_NUDGE_STORAGE_PREFIX = 'open-design:export-ready-nudge:';
|
||||
const COMMENT_SIDE_DOCK_WIDTH = 320;
|
||||
const COMMENT_SIDE_DOCK_RAIL_WIDTH = 42;
|
||||
const COMMENT_SIDE_DOCK_GAP = 12;
|
||||
const COMMENT_SIDE_DOCK_PADDING = 8;
|
||||
const COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING = 24;
|
||||
const COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH = 280;
|
||||
const COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT = 220;
|
||||
const COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT = 48;
|
||||
const COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION =
|
||||
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_PANEL_HEIGHT;
|
||||
const COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION =
|
||||
(COMMENT_SIDE_DOCK_PADDING * 2) + COMMENT_SIDE_DOCK_GAP + COMMENT_SIDE_DOCK_STACKED_RAIL_HEIGHT;
|
||||
|
||||
// The five basic style facets the inspect panel exposes. Kept narrow on
|
||||
// purpose — open-slide's design tokens panel only edits global tokens, so
|
||||
|
|
@ -500,10 +520,11 @@ function previewViewportStyle(
|
|||
viewport: PreviewViewportId,
|
||||
previewScale = 1,
|
||||
canvasSize?: PreviewCanvasSize,
|
||||
options?: PreviewScaleOptions,
|
||||
): CSSProperties & Record<string, string | number> {
|
||||
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!;
|
||||
if (!preset.width) return {};
|
||||
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize);
|
||||
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize, options);
|
||||
return {
|
||||
'--preview-viewport-width': `${preset.width}px`,
|
||||
'--preview-viewport-height': `${preset.height}px`,
|
||||
|
|
@ -512,15 +533,54 @@ function previewViewportStyle(
|
|||
};
|
||||
}
|
||||
|
||||
export function commentPreviewCanvasSize(
|
||||
canvasSize: PreviewCanvasSize | undefined,
|
||||
options: CommentPreviewCanvasOptions,
|
||||
): PreviewCanvasSize | undefined {
|
||||
if (!canvasSize || !options.boardMode) return canvasSize;
|
||||
const dockPadding = options.viewport && options.viewport !== 'desktop'
|
||||
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
|
||||
: COMMENT_SIDE_DOCK_PADDING;
|
||||
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
|
||||
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
|
||||
if (usesStackedCommentSideDock(canvasSize, options)) {
|
||||
const stackedHeightDeduction = options.sidePanelCollapsed
|
||||
? COMMENT_SIDE_DOCK_STACKED_COLLAPSED_HEIGHT_DEDUCTION
|
||||
: COMMENT_SIDE_DOCK_STACKED_HEIGHT_DEDUCTION;
|
||||
return {
|
||||
width: Math.max(1, canvasSize.width - (COMMENT_SIDE_DOCK_PADDING * 2)),
|
||||
height: Math.max(1, canvasSize.height - stackedHeightDeduction),
|
||||
};
|
||||
}
|
||||
return {
|
||||
width: Math.max(1, dockedWidth),
|
||||
height: Math.max(1, canvasSize.height - (dockPadding * 2)),
|
||||
};
|
||||
}
|
||||
|
||||
function usesStackedCommentSideDock(
|
||||
canvasSize: PreviewCanvasSize | undefined,
|
||||
options: CommentPreviewCanvasOptions,
|
||||
) {
|
||||
if (!canvasSize || !options.boardMode) return false;
|
||||
const dockPadding = options.viewport && options.viewport !== 'desktop'
|
||||
? COMMENT_SIDE_DOCK_NON_DESKTOP_PADDING
|
||||
: COMMENT_SIDE_DOCK_PADDING;
|
||||
const sideDockWidth = options.sidePanelCollapsed ? COMMENT_SIDE_DOCK_RAIL_WIDTH : COMMENT_SIDE_DOCK_WIDTH;
|
||||
const dockedWidth = canvasSize.width - (dockPadding * 2) - COMMENT_SIDE_DOCK_GAP - sideDockWidth;
|
||||
return dockedWidth < COMMENT_SIDE_DOCK_MIN_CANVAS_WIDTH;
|
||||
}
|
||||
|
||||
export function effectivePreviewScale(
|
||||
viewport: PreviewViewportId,
|
||||
previewScale: number,
|
||||
canvasSize?: PreviewCanvasSize,
|
||||
options?: PreviewScaleOptions,
|
||||
) {
|
||||
if (viewport === 'desktop') return previewScale;
|
||||
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
|
||||
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale;
|
||||
const canvasPadding = 48;
|
||||
const canvasPadding = options?.canvasPadding ?? 48;
|
||||
const availableWidth = Math.max(1, canvasSize.width - canvasPadding);
|
||||
const availableHeight = Math.max(1, canvasSize.height - canvasPadding);
|
||||
const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height);
|
||||
|
|
@ -2086,7 +2146,6 @@ export function CommentSidePanel({
|
|||
activeCommentId,
|
||||
collapsed,
|
||||
onCollapsedChange,
|
||||
onClose,
|
||||
onToggleSelect,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
|
|
@ -2102,7 +2161,6 @@ export function CommentSidePanel({
|
|||
activeCommentId: string | null;
|
||||
collapsed: boolean;
|
||||
onCollapsedChange: (collapsed: boolean) => void;
|
||||
onClose: () => void;
|
||||
onToggleSelect: (commentId: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
|
|
@ -2119,21 +2177,48 @@ export function CommentSidePanel({
|
|||
const selectedCount = visibleSelectedIds.size;
|
||||
const allSelected = comments.length > 0 && selectedCount === comments.length;
|
||||
const commentsLabel = t('chat.tabComments');
|
||||
const collapsedRailRef = useRef<HTMLButtonElement | null>(null);
|
||||
const expandedToggleRef = useRef<HTMLButtonElement | null>(null);
|
||||
const pendingToggleFocusRef = useRef<'collapsed' | 'expanded' | null>(null);
|
||||
const panelId = useId();
|
||||
const canCreateComment = Boolean(onCreateComment) && newCommentDraft.trim().length > 0 && !sending;
|
||||
const submitNewComment = async () => {
|
||||
if (!onCreateComment || !newCommentDraft.trim()) return;
|
||||
const saved = await onCreateComment(newCommentDraft.trim());
|
||||
if (saved) setNewCommentDraft('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const target =
|
||||
pendingToggleFocusRef.current === 'collapsed'
|
||||
? collapsedRailRef.current
|
||||
: pendingToggleFocusRef.current === 'expanded'
|
||||
? expandedToggleRef.current
|
||||
: null;
|
||||
if (!target) return;
|
||||
pendingToggleFocusRef.current = null;
|
||||
target.focus();
|
||||
}, [collapsed]);
|
||||
|
||||
const handleCollapsedChange = (
|
||||
nextCollapsed: boolean,
|
||||
nextFocusTarget: 'collapsed' | 'expanded',
|
||||
) => {
|
||||
pendingToggleFocusRef.current = nextFocusTarget;
|
||||
onCollapsedChange(nextCollapsed);
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<button
|
||||
ref={collapsedRailRef}
|
||||
type="button"
|
||||
className="comment-side-rail"
|
||||
data-testid="comment-side-collapsed-rail"
|
||||
aria-label={t('preview.showSidebar', { label: commentsLabel })}
|
||||
aria-expanded={false}
|
||||
title={t('preview.showSidebar', { label: commentsLabel })}
|
||||
onClick={() => onCollapsedChange(false)}
|
||||
onClick={() => handleCollapsedChange(false, 'expanded')}
|
||||
>
|
||||
<RemixIcon name="message-3-line" size={15} />
|
||||
<span>{commentsLabel}</span>
|
||||
|
|
@ -2143,7 +2228,7 @@ export function CommentSidePanel({
|
|||
}
|
||||
|
||||
return (
|
||||
<aside className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
|
||||
<aside id={panelId} className="comment-side-panel" data-testid="comment-side-panel" aria-label={commentsLabel}>
|
||||
<div className="comment-side-header">
|
||||
<div className="comment-side-title">
|
||||
<RemixIcon name="message-3-line" size={15} />
|
||||
|
|
@ -2160,15 +2245,18 @@ export function CommentSidePanel({
|
|||
{t('chat.comments.selectAll')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="comment-side-close"
|
||||
aria-label={t('common.close')}
|
||||
title={t('common.close')}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon name="close" size={12} />
|
||||
</button>
|
||||
<button
|
||||
ref={expandedToggleRef}
|
||||
type="button"
|
||||
className="comment-side-collapse"
|
||||
aria-label={t('preview.hideSidebar', { label: commentsLabel })}
|
||||
aria-controls={panelId}
|
||||
aria-expanded={true}
|
||||
title={t('preview.hideSidebar', { label: commentsLabel })}
|
||||
onClick={() => handleCollapsedChange(true, 'collapsed')}
|
||||
>
|
||||
<Icon name="chevron-right" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="comment-side-list">
|
||||
|
|
@ -2299,6 +2387,62 @@ export function CommentSidePanel({
|
|||
);
|
||||
}
|
||||
|
||||
function CommentSideDock({
|
||||
comments,
|
||||
selectedIds,
|
||||
activeCommentId,
|
||||
collapsed,
|
||||
onCollapsedChange,
|
||||
onToggleSelect,
|
||||
onSelectAll,
|
||||
onClearSelection,
|
||||
onReply,
|
||||
onSendSelected,
|
||||
onCreateComment,
|
||||
sending,
|
||||
t,
|
||||
composer,
|
||||
}: {
|
||||
comments: PreviewComment[];
|
||||
selectedIds: Set<string>;
|
||||
activeCommentId: string | null;
|
||||
collapsed: boolean;
|
||||
onCollapsedChange: (collapsed: boolean) => void;
|
||||
onToggleSelect: (commentId: string) => void;
|
||||
onSelectAll: () => void;
|
||||
onClearSelection: () => void;
|
||||
onReply: (comment: PreviewComment) => void;
|
||||
onSendSelected: () => void | Promise<void>;
|
||||
onCreateComment?: (note: string) => boolean | Promise<boolean>;
|
||||
sending: boolean;
|
||||
t: TranslateFn;
|
||||
composer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`comment-side-dock${collapsed ? ' collapsed' : ''}`}
|
||||
data-testid="comment-side-dock"
|
||||
>
|
||||
<CommentSidePanel
|
||||
comments={comments}
|
||||
selectedIds={selectedIds}
|
||||
activeCommentId={activeCommentId}
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={onCollapsedChange}
|
||||
onToggleSelect={onToggleSelect}
|
||||
onSelectAll={onSelectAll}
|
||||
onClearSelection={onClearSelection}
|
||||
onReply={onReply}
|
||||
onSendSelected={onSendSelected}
|
||||
onCreateComment={onCreateComment}
|
||||
sending={sending}
|
||||
t={t}
|
||||
composer={composer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Maps a CSS computed value (e.g. "rgb(40, 50, 60)" or "16px") to a form
|
||||
// input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
|
||||
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip
|
||||
|
|
@ -4309,6 +4453,17 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
|
||||
const previewStateKey = `${projectId}:${file.name}`;
|
||||
const previewScale = zoom / 100;
|
||||
const localCommentSideDockActive = commentPanelOpen && !commentPortalHost;
|
||||
const boardPreviewCanvasSize = commentPreviewCanvasSize(previewBodySize, {
|
||||
boardMode: localCommentSideDockActive,
|
||||
sidePanelCollapsed: commentSidePanelCollapsed,
|
||||
viewport: previewViewport,
|
||||
});
|
||||
const boardSideDockStacked = usesStackedCommentSideDock(previewBodySize, {
|
||||
boardMode: localCommentSideDockActive,
|
||||
sidePanelCollapsed: commentSidePanelCollapsed,
|
||||
viewport: previewViewport,
|
||||
});
|
||||
|
||||
function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
|
||||
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
|
||||
|
|
@ -4432,8 +4587,18 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
const [slideState, setSlideState] = useState<SlideState | null>(
|
||||
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
|
||||
);
|
||||
const overlayPreviewTransform = previewOverlayTransform(previewViewport, previewScale, previewBodySize);
|
||||
const overlayPreviewScale = overlayPreviewTransform.scale;
|
||||
const boardPreviewScaleOptions = localCommentSideDockActive ? { canvasPadding: 0 } : undefined;
|
||||
const overlayPreviewScale = effectivePreviewScale(
|
||||
previewViewport,
|
||||
previewScale,
|
||||
boardPreviewCanvasSize,
|
||||
boardPreviewScaleOptions,
|
||||
);
|
||||
const overlayPreviewTransform: PreviewOverlayTransform = {
|
||||
scale: overlayPreviewScale,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
};
|
||||
const shareRef = useRef<HTMLDivElement | null>(null);
|
||||
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
|
|
@ -6479,6 +6644,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
};
|
||||
const boardAvailable = mode === 'preview' && source !== null;
|
||||
const showPreviewToolbarControls = mode === 'preview';
|
||||
const commentPreviewLayoutClass = [
|
||||
'comment-preview-layer',
|
||||
localCommentSideDockActive ? 'comment-preview-layer-with-side-dock' : '',
|
||||
localCommentSideDockActive && commentSidePanelCollapsed ? 'comment-preview-layer-dock-collapsed' : '',
|
||||
boardSideDockStacked ? 'comment-preview-layer-side-dock-stacked' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
const manualEditPanel = manualEditMode ? (
|
||||
<ManualEditPanel
|
||||
targets={manualEditTargets}
|
||||
|
|
@ -6588,19 +6759,12 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
/>
|
||||
) : null;
|
||||
const commentSidePanel = commentPanelOpen ? (
|
||||
<CommentSidePanel
|
||||
<CommentSideDock
|
||||
comments={visibleSideComments}
|
||||
selectedIds={selectedSideCommentIds}
|
||||
activeCommentId={activeSideCommentId}
|
||||
collapsed={commentPortalHost ? false : commentSidePanelCollapsed}
|
||||
onCollapsedChange={setCommentSidePanelCollapsed}
|
||||
onClose={() => {
|
||||
setCommentPanelOpen(false);
|
||||
setCommentSidePanelCollapsed(false);
|
||||
setCommentCreateMode(false);
|
||||
setBoardMode(false);
|
||||
clearBoardComposer();
|
||||
}}
|
||||
onToggleSelect={(commentId) => {
|
||||
setSelectedSideCommentIds((current) => {
|
||||
const next = new Set(current);
|
||||
|
|
@ -7102,201 +7266,258 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||||
) : mode === 'preview' ? (
|
||||
<div
|
||||
className={manualEditMode
|
||||
? `manual-edit-workspace preview-viewport preview-viewport-${previewViewport}`
|
||||
: [
|
||||
'comment-preview-layer',
|
||||
`preview-viewport preview-viewport-${previewViewport}`,
|
||||
].filter(Boolean).join(' ')}
|
||||
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
|
||||
className={`${manualEditMode ? 'manual-edit-workspace' : commentPreviewLayoutClass} preview-viewport preview-viewport-${previewViewport}`}
|
||||
data-testid={manualEditMode ? undefined : 'comment-preview-layout'}
|
||||
style={previewViewportStyle(previewViewport, previewScale, boardPreviewCanvasSize, boardPreviewScaleOptions)}
|
||||
>
|
||||
{manualEditPanel}
|
||||
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
|
||||
<div
|
||||
style={
|
||||
manualEditMode
|
||||
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
|
||||
: previewScaleShellStyle(previewViewport, previewScale)
|
||||
}
|
||||
>
|
||||
<PreviewDrawOverlay
|
||||
active={drawOverlayOpen}
|
||||
onActiveChange={setDrawOverlayOpen}
|
||||
captureTarget={null}
|
||||
filePath={file.name}
|
||||
sendDisabled={streaming}
|
||||
sendDisabledReason={t('chat.annotationSendDisabledReason')}
|
||||
<div
|
||||
className={manualEditMode ? 'manual-edit-canvas' : 'comment-preview-canvas'}
|
||||
data-testid={manualEditMode ? undefined : 'comment-preview-canvas'}
|
||||
>
|
||||
<div className={manualEditMode ? undefined : 'comment-frame-clip'}>
|
||||
<div
|
||||
style={
|
||||
manualEditMode
|
||||
? manualEditPreviewShellStyle(previewViewport, previewScale, manualEditViewportWidth)
|
||||
: previewScaleShellStyle(previewViewport, previewScale)
|
||||
}
|
||||
>
|
||||
<div className="artifact-preview-transport-stack">
|
||||
{OD_PREVIEW_KEEP_ALIVE ? (
|
||||
<PooledIframe
|
||||
ref={urlPreviewIframeRef}
|
||||
cacheKey={urlPreviewKeepAliveKey}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||
data-od-render-mode="url-load"
|
||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
src={urlTransportSrc}
|
||||
onLoad={() => {
|
||||
const frame = urlPreviewIframeRef.current;
|
||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
frame?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
syncBridgeModes(frame);
|
||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<PreviewDrawOverlay
|
||||
active={drawOverlayOpen}
|
||||
onActiveChange={setDrawOverlayOpen}
|
||||
captureTarget={null}
|
||||
filePath={file.name}
|
||||
sendDisabled={streaming}
|
||||
sendDisabledReason={t('chat.annotationSendDisabledReason')}
|
||||
>
|
||||
<div className="artifact-preview-transport-stack">
|
||||
{OD_PREVIEW_KEEP_ALIVE ? (
|
||||
<PooledIframe
|
||||
ref={urlPreviewIframeRef}
|
||||
cacheKey={urlPreviewKeepAliveKey}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||
data-od-render-mode="url-load"
|
||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
src={urlTransportSrc}
|
||||
onLoad={() => {
|
||||
const frame = urlPreviewIframeRef.current;
|
||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
frame?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
syncBridgeModes(frame);
|
||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
ref={urlPreviewIframeRef}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||
data-od-render-mode="url-load"
|
||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
src={urlTransportSrc}
|
||||
onLoad={() => {
|
||||
const frame = urlPreviewIframeRef.current;
|
||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
frame?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
syncBridgeModes(frame);
|
||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<iframe
|
||||
ref={urlPreviewIframeRef}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame' : 'artifact-preview-frame-url-load'}
|
||||
data-od-render-mode="url-load"
|
||||
data-od-active={useUrlLoadPreview ? 'true' : 'false'}
|
||||
aria-hidden={useUrlLoadPreview ? undefined : true}
|
||||
tabIndex={useUrlLoadPreview ? 0 : -1}
|
||||
key={srcDocTransportResetKey}
|
||||
ref={srcDocPreviewIframeRef}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
|
||||
data-od-render-mode="srcdoc"
|
||||
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
|
||||
aria-hidden={useUrlLoadPreview ? true : undefined}
|
||||
tabIndex={useUrlLoadPreview ? -1 : 0}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
src={urlTransportSrc}
|
||||
srcDoc={srcDocTransportContent}
|
||||
onLoad={() => {
|
||||
const frame = urlPreviewIframeRef.current;
|
||||
if (useUrlLoadPreview) iframeRef.current = frame;
|
||||
const frame = srcDocPreviewIframeRef.current;
|
||||
if (!useUrlLoadPreview) iframeRef.current = frame;
|
||||
// Reset the activation dedupe exactly ONCE per
|
||||
// freshly mounted iframe DOM node, never on the
|
||||
// subsequent load events that the same node
|
||||
// emits during normal srcDoc rendering.
|
||||
//
|
||||
// The iframe's load event fires twice for one
|
||||
// successful activation: once when the lazy
|
||||
// transport shell HTML loads, and again when
|
||||
// our own document.open/write/close inside the
|
||||
// shell finishes. PR #2699 reset the dedupe on
|
||||
// every load so that switching
|
||||
// preview -> source -> preview (which remounts
|
||||
// this iframe as a fresh DOM node) would
|
||||
// re-activate the new shell. But resetting on
|
||||
// every load also re-activated on the SECOND
|
||||
// load of a non-remounted frame, which
|
||||
// re-triggered document.open/write/close, which
|
||||
// re-fired the load event, ad infinitum. The
|
||||
// dedupe ref oscillated between null and the
|
||||
// current srcDoc thousands of times per render
|
||||
// and each iteration restarted every CSS
|
||||
// animation from its `from` keyframe. Designs
|
||||
// using `animation-fill-mode: both` with
|
||||
// `from { opacity: 0 }` stayed at opacity 0
|
||||
// forever and the preview read as blank.
|
||||
// That is issue #2361.
|
||||
//
|
||||
// Tracking the last frame we reset for lets us
|
||||
// keep PR #2699's "remount after Source toggle"
|
||||
// fix while breaking the loop on plain renders.
|
||||
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
|
||||
srcDocFrameDedupeResetForRef.current = frame;
|
||||
activatedSrcDocTransportHtmlRef.current = null;
|
||||
}
|
||||
if (useLazySrcDocTransport) setSrcDocShellReady(true);
|
||||
activateLoadedSrcDocTransport(frame);
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
frame?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
replayInspectOverridesToIframe(frame);
|
||||
syncBridgeModes(frame);
|
||||
if (useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
if (!useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<iframe
|
||||
key={srcDocTransportResetKey}
|
||||
ref={srcDocPreviewIframeRef}
|
||||
data-testid={useUrlLoadPreview ? 'artifact-preview-frame-srcdoc' : 'artifact-preview-frame'}
|
||||
data-od-render-mode="srcdoc"
|
||||
data-od-active={useUrlLoadPreview ? 'false' : 'true'}
|
||||
aria-hidden={useUrlLoadPreview ? true : undefined}
|
||||
tabIndex={useUrlLoadPreview ? -1 : 0}
|
||||
title={file.name}
|
||||
sandbox="allow-scripts allow-downloads"
|
||||
srcDoc={srcDocTransportContent}
|
||||
onLoad={() => {
|
||||
const frame = srcDocPreviewIframeRef.current;
|
||||
if (!useUrlLoadPreview) iframeRef.current = frame;
|
||||
// Reset the activation dedupe exactly ONCE per
|
||||
// freshly mounted iframe DOM node, never on the
|
||||
// subsequent load events that the same node
|
||||
// emits during normal srcDoc rendering.
|
||||
//
|
||||
// The iframe's load event fires twice for one
|
||||
// successful activation: once when the lazy
|
||||
// transport shell HTML loads, and again when
|
||||
// our own document.open/write/close inside the
|
||||
// shell finishes. PR #2699 reset the dedupe on
|
||||
// every load so that switching
|
||||
// preview -> source -> preview (which remounts
|
||||
// this iframe as a fresh DOM node) would
|
||||
// re-activate the new shell. But resetting on
|
||||
// every load also re-activated on the SECOND
|
||||
// load of a non-remounted frame, which
|
||||
// re-triggered document.open/write/close, which
|
||||
// re-fired the load event, ad infinitum. The
|
||||
// dedupe ref oscillated between null and the
|
||||
// current srcDoc thousands of times per render
|
||||
// and each iteration restarted every CSS
|
||||
// animation from its `from` keyframe. Designs
|
||||
// using `animation-fill-mode: both` with
|
||||
// `from { opacity: 0 }` stayed at opacity 0
|
||||
// forever and the preview read as blank.
|
||||
// That is issue #2361.
|
||||
//
|
||||
// Tracking the last frame we reset for lets us
|
||||
// keep PR #2699's "remount after Source toggle"
|
||||
// fix while breaking the loop on plain renders.
|
||||
if (frame && srcDocFrameDedupeResetForRef.current !== frame) {
|
||||
srcDocFrameDedupeResetForRef.current = frame;
|
||||
activatedSrcDocTransportHtmlRef.current = null;
|
||||
}
|
||||
if (useLazySrcDocTransport) setSrcDocShellReady(true);
|
||||
activateLoadedSrcDocTransport(frame);
|
||||
dcViewportRestoreAtRef.current = Date.now();
|
||||
frame?.contentWindow?.postMessage({
|
||||
type: '__dc_set_viewport',
|
||||
...dcViewportRef.current,
|
||||
}, '*');
|
||||
replayInspectOverridesToIframe(frame);
|
||||
syncBridgeModes(frame);
|
||||
if (!useUrlLoadPreview) restorePreviewScrollPosition();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PreviewDrawOverlay>
|
||||
</div>
|
||||
</PreviewDrawOverlay>
|
||||
</div>
|
||||
</div>
|
||||
{boardMode ? (
|
||||
<CommentPreviewOverlays
|
||||
comments={commentCreateMode ? visibleSideComments : []}
|
||||
liveTargets={liveCommentTargets}
|
||||
hoveredTarget={hoveredCommentTarget}
|
||||
hoveredPodMemberId={hoveredPodMemberId}
|
||||
activeTarget={activeCommentTarget}
|
||||
boardTool={boardTool}
|
||||
showActivePin={commentCreateMode}
|
||||
scale={overlayPreviewScale}
|
||||
offsetX={overlayPreviewTransform.offsetX}
|
||||
offsetY={overlayPreviewTransform.offsetY}
|
||||
strokePoints={strokePoints}
|
||||
onOpenComment={(comment, snapshot) => {
|
||||
setCommentPanelOpen(true);
|
||||
setCommentSidePanelCollapsed(false);
|
||||
setCommentCreateMode(true);
|
||||
setBoardMode(true);
|
||||
setActiveCommentTarget(snapshot);
|
||||
setHoveredCommentTarget(snapshot);
|
||||
setActivePreviewCommentId(comment.id);
|
||||
setCommentDraft(comment.note);
|
||||
setQueuedBoardNotes([]);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{exportToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={exportToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setExportToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{commentSavedToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={commentSavedToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setCommentSavedToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{templateSavedToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={templateSavedToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setTemplateSavedToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{commentComposer}
|
||||
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
|
||||
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
|
||||
) : null}
|
||||
{/*
|
||||
Hint banner for Inspect / Picker modes. The bridge in
|
||||
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
|
||||
with every element annotated with `data-od-id` /
|
||||
`data-screen-label`, so `liveCommentTargets.size` is the
|
||||
authoritative annotation count for the current artifact.
|
||||
|
||||
Two states:
|
||||
- "has targets": the existing copy ("Click any element with
|
||||
`data-od-id` to tune its style.") for users who just don't
|
||||
see the crosshair cursor.
|
||||
- "no targets" (issue #890): a freeform-generated artifact
|
||||
(e.g. PRD → HTML through a Claude-Code-compatible CLI
|
||||
without a skill) ships zero `data-od-id` annotations. The
|
||||
bridge's click handler walks up to <html>, finds nothing,
|
||||
and bails — clicks no-op silently. The static copy made
|
||||
this look broken; the empty-state copy explains what's
|
||||
missing and how to fix it. Mirrored across Inspect and
|
||||
element-pick annotation mode because the failure surface is identical.
|
||||
*/}
|
||||
{inspectMode
|
||||
&& openHintBox
|
||||
&& !activeInspectTarget
|
||||
&& !activeCommentTarget ? (
|
||||
<div
|
||||
className="inspect-empty-hint-container"
|
||||
data-testid="inspect-empty-hint-container"
|
||||
>
|
||||
{liveCommentTargets.size === 0 ? (
|
||||
<div
|
||||
className="inspect-empty-hint"
|
||||
data-testid="inspect-empty-hint-no-targets"
|
||||
>
|
||||
{inspectMode
|
||||
? t('chat.inspect.noEditableTargets')
|
||||
: t('chat.inspect.noCommentTargets')}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="inspect-empty-hint"
|
||||
data-testid="inspect-empty-hint"
|
||||
>
|
||||
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title="Close Inspect Hint"
|
||||
aria-label="Close Inspect Hint"
|
||||
onClick={() => setOpenHintBox(false)}
|
||||
className="orbit-artifact-ghost"
|
||||
>
|
||||
<Icon className="" name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{boardMode ? (
|
||||
<CommentPreviewOverlays
|
||||
comments={commentCreateMode ? visibleSideComments : []}
|
||||
liveTargets={liveCommentTargets}
|
||||
hoveredTarget={hoveredCommentTarget}
|
||||
hoveredPodMemberId={hoveredPodMemberId}
|
||||
activeTarget={activeCommentTarget}
|
||||
boardTool={boardTool}
|
||||
showActivePin={commentCreateMode}
|
||||
scale={overlayPreviewScale}
|
||||
offsetX={overlayPreviewTransform.offsetX}
|
||||
offsetY={overlayPreviewTransform.offsetY}
|
||||
strokePoints={strokePoints}
|
||||
onOpenComment={(comment, snapshot) => {
|
||||
setCommentPanelOpen(true);
|
||||
setCommentSidePanelCollapsed(false);
|
||||
setCommentCreateMode(true);
|
||||
setBoardMode(true);
|
||||
setActiveCommentTarget(snapshot);
|
||||
setHoveredCommentTarget(snapshot);
|
||||
setActivePreviewCommentId(comment.id);
|
||||
setCommentDraft(comment.note);
|
||||
setQueuedBoardNotes([]);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{exportToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={exportToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setExportToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{commentSavedToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={commentSavedToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setCommentSavedToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{templateSavedToast ? (
|
||||
<div className="comment-toast-anchor">
|
||||
<Toast
|
||||
message={templateSavedToast}
|
||||
ttlMs={2200}
|
||||
onDismiss={() => setTemplateSavedToast(null)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{commentComposer}
|
||||
{boardMode && !commentCreateMode && hoveredCommentTarget && (!activeCommentTarget || commentPortalHost) ? (
|
||||
<AnnotationHoverPopover target={hoveredCommentTarget} scale={overlayPreviewScale} />
|
||||
) : null}
|
||||
{commentPortalHost && commentSidePanel
|
||||
? createPortal(commentSidePanel, commentPortalHost)
|
||||
: commentPortalId
|
||||
|
|
@ -7339,64 +7560,6 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
|
|||
error={inspectError}
|
||||
/>
|
||||
) : null}
|
||||
{/*
|
||||
Hint banner for Inspect / Picker modes. The bridge in
|
||||
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
|
||||
with every element annotated with `data-od-id` /
|
||||
`data-screen-label`, so `liveCommentTargets.size` is the
|
||||
authoritative annotation count for the current artifact.
|
||||
|
||||
Two states:
|
||||
- "has targets": the existing copy ("Click any element with
|
||||
`data-od-id` to tune its style.") for users who just don't
|
||||
see the crosshair cursor.
|
||||
- "no targets" (issue #890): a freeform-generated artifact
|
||||
(e.g. PRD → HTML through a Claude-Code-compatible CLI
|
||||
without a skill) ships zero `data-od-id` annotations. The
|
||||
bridge's click handler walks up to <html>, finds nothing,
|
||||
and bails — clicks no-op silently. The static copy made
|
||||
this look broken; the empty-state copy explains what's
|
||||
missing and how to fix it. Mirrored across Inspect and
|
||||
element-pick annotation mode because the failure surface is identical.
|
||||
*/}
|
||||
{inspectMode
|
||||
&& openHintBox
|
||||
&& !activeInspectTarget
|
||||
&& !activeCommentTarget ? (
|
||||
<div
|
||||
className={`inspect-empty-hint-container${
|
||||
commentPanelOpen && !commentSidePanelCollapsed ? ' comment-side-panel-open' : ''
|
||||
}`}
|
||||
data-testid="inspect-empty-hint-container"
|
||||
>
|
||||
{liveCommentTargets.size === 0 ? (
|
||||
<div
|
||||
className="inspect-empty-hint"
|
||||
data-testid="inspect-empty-hint-no-targets"
|
||||
>
|
||||
{inspectMode
|
||||
? t('chat.inspect.noEditableTargets')
|
||||
: t('chat.inspect.noCommentTargets')}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="inspect-empty-hint"
|
||||
data-testid="inspect-empty-hint"
|
||||
>
|
||||
{inspectMode ? t('chat.inspect.editHint') : t('chat.inspect.commentHint')}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title="Close Inspect Hint"
|
||||
aria-label="Close Inspect Hint"
|
||||
onClick={() => setOpenHintBox(false)}
|
||||
className="orbit-artifact-ghost"
|
||||
>
|
||||
<Icon className="" name="close" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="viewer-source">{source}</pre>
|
||||
|
|
|
|||
|
|
@ -978,6 +978,53 @@
|
|||
.live-artifact-preview-layer.preview-viewport[data-active='false'] {
|
||||
display: none;
|
||||
}
|
||||
.comment-preview-layer {
|
||||
--comment-side-dock-width: 320px;
|
||||
--comment-side-dock-rail-width: 42px;
|
||||
--comment-side-dock-stacked-height: 220px;
|
||||
--comment-side-dock-stacked-rail-height: 48px;
|
||||
}
|
||||
.comment-preview-layer-with-side-dock {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--comment-side-dock-width);
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-with-side-dock {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
padding: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-side-dock-stacked {
|
||||
padding: 8px;
|
||||
}
|
||||
.comment-preview-layer-with-side-dock.comment-preview-layer-dock-collapsed {
|
||||
grid-template-columns: minmax(0, 1fr) var(--comment-side-dock-rail-width);
|
||||
}
|
||||
.comment-preview-layer-with-side-dock.comment-preview-layer-side-dock-stacked {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) var(--comment-side-dock-stacked-height);
|
||||
}
|
||||
.comment-preview-layer-with-side-dock.comment-preview-layer-side-dock-stacked.comment-preview-layer-dock-collapsed {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) var(--comment-side-dock-stacked-rail-height);
|
||||
}
|
||||
.comment-preview-canvas {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.comment-preview-layer-with-side-dock .comment-preview-canvas {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop) {
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
|
|
@ -995,7 +1042,7 @@
|
|||
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
|
||||
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip,
|
||||
.preview-viewport:not(.preview-viewport-desktop):not(.comment-preview-layer-with-side-dock) .comment-preview-canvas,
|
||||
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
|
||||
width: calc(var(--preview-viewport-width) * var(--preview-scale, 1));
|
||||
height: calc(var(--preview-viewport-height) * var(--preview-scale, 1));
|
||||
|
|
@ -1008,19 +1055,23 @@
|
|||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.22);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop).comment-preview-layer-with-side-dock .comment-preview-canvas {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip > div,
|
||||
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip > div,
|
||||
.preview-viewport:not(.preview-viewport-desktop) .comment-preview-canvas > .comment-frame-clip > div,
|
||||
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas > div {
|
||||
will-change: transform;
|
||||
}
|
||||
.preview-viewport:not(.preview-viewport-desktop) .preview-frame-clip,
|
||||
.preview-viewport:not(.preview-viewport-desktop) .comment-frame-clip,
|
||||
.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
}
|
||||
.preview-viewport-mobile .preview-frame-clip,
|
||||
.preview-viewport-mobile .comment-frame-clip,
|
||||
.preview-viewport-mobile:not(.comment-preview-layer-with-side-dock) .comment-preview-canvas,
|
||||
.preview-viewport-mobile.manual-edit-workspace .manual-edit-canvas {
|
||||
border-radius: 28px;
|
||||
}
|
||||
|
|
@ -1324,29 +1375,59 @@
|
|||
color: var(--red);
|
||||
}
|
||||
/* Right-side comment thread panel. Shown while board (comment) mode
|
||||
is on; takes the place of the chat sidebar's removed Comments tab.
|
||||
Floats over the artifact preview at the right edge. */
|
||||
is on; it docks beside the artifact preview so canvas clicks remain
|
||||
available for placing comments. */
|
||||
.comment-side-dock {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: var(--comment-side-dock-width);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
.comment-side-dock.collapsed {
|
||||
width: var(--comment-side-dock-rail-width);
|
||||
}
|
||||
.comment-preview-layer-side-dock-stacked .comment-side-dock {
|
||||
width: 100%;
|
||||
height: var(--comment-side-dock-stacked-height);
|
||||
}
|
||||
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed {
|
||||
width: 100%;
|
||||
height: var(--comment-side-dock-stacked-rail-height);
|
||||
}
|
||||
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed .comment-side-rail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
.comment-preview-layer-side-dock-stacked .comment-side-dock.collapsed .comment-side-rail span {
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
.comment-side-panel {
|
||||
--comment-accent: #ff5a3c;
|
||||
--comment-accent-strong: color-mix(in srgb, var(--comment-accent) 78%, var(--text));
|
||||
--comment-accent-surface: color-mix(in srgb, var(--comment-accent) 10%, var(--bg-panel));
|
||||
--comment-accent-surface-strong: color-mix(in srgb, var(--comment-accent) 18%, var(--bg-panel));
|
||||
--comment-accent-border: color-mix(in srgb, var(--comment-accent) 64%, var(--border));
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
position: relative;
|
||||
width: 320px;
|
||||
max-width: calc(100% - 16px);
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
background: var(--bg-panel, #fff);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 30;
|
||||
overflow: hidden;
|
||||
}
|
||||
.comment-preview-layer-side-dock-stacked .comment-side-panel {
|
||||
width: 100%;
|
||||
}
|
||||
.comment-side-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1394,7 +1475,7 @@
|
|||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.comment-side-close {
|
||||
.comment-side-collapse {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
|
|
@ -1408,18 +1489,15 @@
|
|||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.comment-side-close:hover {
|
||||
.comment-side-collapse:hover {
|
||||
background: var(--bg-subtle);
|
||||
border-color: var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.comment-side-rail {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
z-index: 30;
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
|
@ -1689,16 +1767,15 @@
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
/* Inspect panel — sibling of the comment popover. Anchored to the
|
||||
right side of the preview surface. Width is fixed so layout doesn't
|
||||
reflow as the user scrubs slider values; controls reserve space for
|
||||
their numeric readouts. */
|
||||
/* Inspect panel — sibling of the preview canvas and comment popover.
|
||||
Keep the usual 296px width, but allow narrow board layouts to shrink it
|
||||
inside the unclipped preview layer instead of losing controls. */
|
||||
.inspect-panel {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 5;
|
||||
width: 296px;
|
||||
width: min(296px, calc(100% - 28px));
|
||||
max-height: calc(100% - 28px);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
|
|
@ -1885,20 +1962,6 @@ button.ghost.mcp-copy-btn:hover:not(:disabled) {
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inspect-empty-hint-container.comment-side-panel-open {
|
||||
right: 340px;
|
||||
max-width: min(480px, calc(100% - 368px));
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.inspect-empty-hint-container.comment-side-panel-open {
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
top: 54px;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.inspect-empty-hint-container button,
|
||||
.inspect-empty-hint-container .close-button {
|
||||
pointer-events: auto;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import {
|
|||
LiveArtifactRefreshHistoryPanel,
|
||||
SvgViewer,
|
||||
applyInspectOverridesToSource,
|
||||
commentPreviewCanvasSize,
|
||||
effectivePreviewScale,
|
||||
parseInspectOverridesFromSource,
|
||||
previewOverlayTransform,
|
||||
|
|
@ -75,6 +76,30 @@ function deferredResponse() {
|
|||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
|
||||
return calls
|
||||
.map(([message]) => message)
|
||||
.filter((message): message is { type: 'od:srcdoc-transport-activate'; html: string } => {
|
||||
if (typeof message !== 'object' || message === null) return false;
|
||||
const data = message as { type?: unknown; html?: unknown };
|
||||
return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
|
||||
});
|
||||
}
|
||||
|
||||
function testRect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width,
|
||||
height,
|
||||
top,
|
||||
left,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function clickAgentTool(testId: string) {
|
||||
fireEvent.click(screen.getByTestId(testId));
|
||||
}
|
||||
|
|
@ -95,7 +120,10 @@ describe('FileViewer preview scale', () => {
|
|||
'.preview-viewport:not(.preview-viewport-desktop).manual-edit-workspace .manual-edit-canvas',
|
||||
);
|
||||
expect(css).toMatch(
|
||||
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\) \.comment-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*position: relative;/,
|
||||
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\):not\(\.comment-preview-layer-with-side-dock\) \.comment-preview-canvas,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*width: calc\(var\(--preview-viewport-width\) \* var\(--preview-scale, 1\)\);/,
|
||||
);
|
||||
expect(css).toMatch(
|
||||
/\.preview-viewport:not\(\.preview-viewport-desktop\) \.preview-frame-clip,\s*\n\.preview-viewport:not\(\.preview-viewport-desktop\)\.manual-edit-workspace \.manual-edit-canvas \{\s*\n\s*position: relative;/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -121,6 +149,60 @@ describe('FileViewer preview scale', () => {
|
|||
expect(effectivePreviewScale('tablet', 1.25, { width: 820, height: 700 })).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('uses the reduced board canvas size when the side dock is open', () => {
|
||||
const dockedCanvas = commentPreviewCanvasSize(
|
||||
{ width: 900, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: false },
|
||||
);
|
||||
|
||||
expect(dockedCanvas).toEqual({ width: 552, height: 684 });
|
||||
expect(effectivePreviewScale('tablet', 1, dockedCanvas)).toBeLessThan(
|
||||
effectivePreviewScale('tablet', 1, { width: 900, height: 700 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses stacked canvas sizing for narrow board panes instead of a 1px docked canvas', () => {
|
||||
const narrowCanvas = commentPreviewCanvasSize(
|
||||
{ width: 400, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: false },
|
||||
);
|
||||
|
||||
expect(narrowCanvas).toEqual({ width: 384, height: 452 });
|
||||
});
|
||||
|
||||
it('subtracts only the collapsed stacked rail height when the side dock is collapsed in the stacked layout', () => {
|
||||
const expandedStackedCanvas = commentPreviewCanvasSize(
|
||||
{ width: 300, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: false },
|
||||
);
|
||||
const collapsedStackedCanvas = commentPreviewCanvasSize(
|
||||
{ width: 300, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: true },
|
||||
);
|
||||
|
||||
expect(expandedStackedCanvas).toEqual({ width: 284, height: 452 });
|
||||
expect(collapsedStackedCanvas).toEqual({ width: 284, height: 624 });
|
||||
expect(collapsedStackedCanvas!.height).toBeGreaterThan(expandedStackedCanvas!.height);
|
||||
});
|
||||
|
||||
it('matches the rendered non-desktop dock padding in board canvas sizing', () => {
|
||||
const dockedCanvas = commentPreviewCanvasSize(
|
||||
{ width: 900, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: false, viewport: 'tablet' },
|
||||
);
|
||||
|
||||
expect(dockedCanvas).toEqual({ width: 520, height: 652 });
|
||||
});
|
||||
|
||||
it('fits non-desktop board previews against the inner canvas without subtracting viewport padding again', () => {
|
||||
const dockedCanvas = commentPreviewCanvasSize(
|
||||
{ width: 900, height: 700 },
|
||||
{ boardMode: true, sidePanelCollapsed: false, viewport: 'tablet' },
|
||||
);
|
||||
|
||||
expect(effectivePreviewScale('tablet', 1, dockedCanvas, { canvasPadding: 0 })).toBeCloseTo(652 / 1180);
|
||||
});
|
||||
|
||||
it('offsets tablet and mobile overlays to the centered viewport card', () => {
|
||||
expect(previewOverlayTransform('desktop', 1.25, { width: 1200, height: 800 })).toEqual({
|
||||
scale: 1.25,
|
||||
|
|
@ -2034,23 +2116,69 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
expect(screen.queryByTestId('inspect-empty-hint-container')).toBeNull();
|
||||
});
|
||||
|
||||
it('exits comment mode when closing the comments side panel', () => {
|
||||
const openComment: PreviewComment = {
|
||||
id: 'comment-open',
|
||||
projectId: 'project-1',
|
||||
conversationId: 'conversation-1',
|
||||
filePath: 'preview.html',
|
||||
elementId: 'pin-open',
|
||||
selector: '[data-od-pin="pin-open"]',
|
||||
label: 'pin-open',
|
||||
text: '',
|
||||
htmlHint: '',
|
||||
position: { x: 24, y: 32, width: 18, height: 18 },
|
||||
note: 'Open comment',
|
||||
status: 'open',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
it('keeps the picker hint inside the canvas and clear of the open comment side panel', () => {
|
||||
render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
projectKind="prototype"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
|
||||
|
||||
const canvas = screen.getByTestId('comment-preview-canvas');
|
||||
const dock = screen.getByTestId('comment-side-dock');
|
||||
|
||||
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
|
||||
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
|
||||
expect(dock.contains(screen.getByTestId('artifact-preview-frame'))).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /hide comments/i }));
|
||||
|
||||
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
|
||||
expect(screen.getByTestId('comment-side-collapsed-rail')).toBeTruthy();
|
||||
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
|
||||
expect(dock.contains(screen.getByTestId('artifact-preview-frame'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps non-docked tablet comment-tool previews fitted to the padded canvas', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
|
||||
if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700);
|
||||
return testRect(0, 0, 0, 0);
|
||||
});
|
||||
|
||||
render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
projectKind="prototype"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Preview viewport'));
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Tablet' }));
|
||||
clickAgentTool('board-mode-toggle');
|
||||
|
||||
const layout = screen.getByTestId('comment-preview-layout');
|
||||
await waitFor(() => {
|
||||
expect(layout.className).not.toContain('comment-preview-layer-with-side-dock');
|
||||
expect(Number(layout.style.getPropertyValue('--preview-scale'))).toBeCloseTo((700 - 48) / 1180);
|
||||
});
|
||||
});
|
||||
|
||||
it('docks the comment side panel outside the clickable preview canvas', () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
|
||||
if (this.classList.contains('viewer-body')) return testRect(0, 0, 900, 700);
|
||||
if (this.dataset.testid === 'comment-preview-canvas') return testRect(8, 8, 552, 684);
|
||||
if (this.dataset.testid === 'comment-side-dock') return testRect(572, 8, 320, 684);
|
||||
if (this.dataset.testid === 'comment-side-panel') return testRect(572, 8, 320, 684);
|
||||
return testRect(0, 0, 0, 0);
|
||||
});
|
||||
|
||||
render(
|
||||
<FileViewer
|
||||
|
|
@ -2058,22 +2186,53 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
projectKind="prototype"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
previewComments={[openComment]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
|
||||
|
||||
expect(screen.getByTestId('comment-side-panel')).toBeTruthy();
|
||||
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('true');
|
||||
expect(screen.getByTestId('comment-saved-marker-pin-open')).toBeTruthy();
|
||||
const canvas = screen.getByTestId('comment-preview-canvas');
|
||||
const dock = screen.getByTestId('comment-side-dock');
|
||||
const panel = screen.getByTestId('comment-side-panel');
|
||||
const canvasBox = canvas.getBoundingClientRect();
|
||||
const dockBox = dock.getBoundingClientRect();
|
||||
const panelBox = panel.getBoundingClientRect();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
expect(canvas.contains(screen.getByTestId('artifact-preview-frame'))).toBe(true);
|
||||
expect(dock.contains(panel)).toBe(true);
|
||||
expect(canvas.contains(panel)).toBe(false);
|
||||
expect(screen.getByTestId('comment-preview-layout').className).toContain(
|
||||
'comment-preview-layer-with-side-dock',
|
||||
);
|
||||
expect(dockBox.left).toBeGreaterThanOrEqual(canvasBox.right);
|
||||
expect(panelBox.left).toBeGreaterThanOrEqual(canvasBox.right);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
|
||||
expect(screen.queryByTestId('comment-saved-marker-pin-open')).toBeNull();
|
||||
expect(screen.getByTestId('comment-panel-toggle').getAttribute('aria-pressed')).toBe('false');
|
||||
expect(screen.getByTestId('board-mode-toggle').getAttribute('aria-pressed')).toBe('false');
|
||||
it('uses the narrow board layout when docking would leave too little canvas', async () => {
|
||||
const getBoundingClientRectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
|
||||
if (this.classList.contains('viewer-body')) return testRect(0, 0, 400, 700);
|
||||
return testRect(0, 0, 0, 0);
|
||||
});
|
||||
|
||||
render(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
projectKind="prototype"
|
||||
file={htmlPreviewFile()}
|
||||
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('comment-panel-toggle'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('comment-preview-layout').className).toContain(
|
||||
'comment-preview-layer-side-dock-stacked',
|
||||
);
|
||||
});
|
||||
|
||||
getBoundingClientRectSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('keeps saved comment pins visible while adding another comment', async () => {
|
||||
|
|
@ -2543,16 +2702,14 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
expect(screen.queryByText('Do not recreate this stale comment')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the comment side panel from the header close button', () => {
|
||||
it('moves focus between comment side panel toggles when collapsing and expanding without a pre-focused click target', async () => {
|
||||
const onCollapseChange = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const onSelectAll = vi.fn();
|
||||
const onReply = vi.fn();
|
||||
|
||||
function Harness() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [open, setOpen] = useState(true);
|
||||
return open ? (
|
||||
return (
|
||||
<CommentSidePanel
|
||||
comments={[
|
||||
{
|
||||
|
|
@ -2579,10 +2736,6 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
onCollapseChange(next);
|
||||
setCollapsed(next);
|
||||
}}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setOpen(false);
|
||||
}}
|
||||
onToggleSelect={() => {}}
|
||||
onSelectAll={onSelectAll}
|
||||
onClearSelection={() => {}}
|
||||
|
|
@ -2591,7 +2744,7 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
sending={false}
|
||||
t={t}
|
||||
/>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
|
@ -2603,13 +2756,69 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
fireEvent.click(screen.getByText('不要github,换成微信').closest('[data-testid="comment-side-item"]')!);
|
||||
expect(onReply).toHaveBeenCalledWith(expect.objectContaining({ id: 'comment-1' }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
const hideComments = screen.getByRole('button', { name: /hide comments/i });
|
||||
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
expect(onCollapseChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(hideComments);
|
||||
|
||||
expect(onCollapseChange).toHaveBeenLastCalledWith(true);
|
||||
expect(screen.queryByText('不要github,换成微信')).toBeNull();
|
||||
expect(screen.queryByTestId('comment-side-selectbar')).toBeNull();
|
||||
expect(screen.queryByTestId('comment-side-collapsed-rail')).toBeNull();
|
||||
const showComments = screen.getByTestId('comment-side-collapsed-rail');
|
||||
await waitFor(() => expect(document.activeElement).toBe(showComments));
|
||||
|
||||
fireEvent.click(showComments);
|
||||
|
||||
expect(onCollapseChange).toHaveBeenLastCalledWith(false);
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: /hide comments/i }));
|
||||
});
|
||||
});
|
||||
|
||||
it('announces comment side dock disclosure state without pointing at an unmounted panel', () => {
|
||||
function Harness() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<CommentSidePanel
|
||||
comments={[]}
|
||||
selectedIds={new Set()}
|
||||
activeCommentId={null}
|
||||
collapsed={collapsed}
|
||||
onCollapsedChange={setCollapsed}
|
||||
onToggleSelect={() => {}}
|
||||
onSelectAll={() => {}}
|
||||
onClearSelection={() => {}}
|
||||
onReply={() => {}}
|
||||
onSendSelected={() => {}}
|
||||
sending={false}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
const panel = screen.getByTestId('comment-side-panel');
|
||||
const hideComments = screen.getByRole('button', { name: /hide comments/i });
|
||||
const panelId = panel.id;
|
||||
|
||||
expect(panelId).toBeTruthy();
|
||||
expect(hideComments.getAttribute('aria-controls')).toBe(panelId);
|
||||
expect(hideComments.getAttribute('aria-expanded')).toBe('true');
|
||||
|
||||
fireEvent.click(hideComments);
|
||||
|
||||
const showComments = screen.getByTestId('comment-side-collapsed-rail');
|
||||
expect(screen.queryByTestId('comment-side-panel')).toBeNull();
|
||||
expect(document.getElementById(panelId)).toBeNull();
|
||||
expect(showComments.getAttribute('aria-controls')).toBeNull();
|
||||
expect(showComments.getAttribute('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('lets the inspect panel shrink inside narrow preview layouts', () => {
|
||||
const css = readFileSync(join(process.cwd(), 'src/styles/viewer/core.css'), 'utf8');
|
||||
const rule = css.match(/\.inspect-panel\s*\{[^}]+\}/)?.[0] ?? '';
|
||||
|
||||
expect(rule).toContain('width: min(296px, calc(100% - 28px));');
|
||||
});
|
||||
|
||||
it('does not classify text labels containing a standalone article as links', () => {
|
||||
|
|
@ -2637,7 +2846,6 @@ describe('FileViewer tweaks toolbar', () => {
|
|||
activeCommentId={null}
|
||||
collapsed={false}
|
||||
onCollapsedChange={() => {}}
|
||||
onClose={() => {}}
|
||||
onToggleSelect={() => {}}
|
||||
onSelectAll={() => {}}
|
||||
onClearSelection={() => {}}
|
||||
|
|
|
|||
Loading…
Reference in a new issue