From def2e9fd2e21d848e5f46cda19797dc1203c7ba1 Mon Sep 17 00:00:00 2001 From: kami <31983330+bulai0408@users.noreply.github.com> Date: Sun, 31 May 2026 12:36:15 +0800 Subject: [PATCH] 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 --- apps/web/src/components/FileViewer.tsx | 685 +++++++++++------- apps/web/src/styles/viewer/core.css | 137 +++- apps/web/tests/components/FileViewer.test.tsx | 290 ++++++-- 3 files changed, 773 insertions(+), 339 deletions(-) diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index 47c8647b6..93d72edc8 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -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 { 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(null); + const expandedToggleRef = useRef(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 ( ) : null} - +
@@ -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; + activeCommentId: string | null; + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; + onToggleSelect: (commentId: string) => void; + onSelectAll: () => void; + onClearSelection: () => void; + onReply: (comment: PreviewComment) => void; + onSendSelected: () => void | Promise; + onCreateComment?: (note: string) => boolean | Promise; + sending: boolean; + t: TranslateFn; + composer?: ReactNode; +}) { + return ( +
+ +
+ ); +} + // 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 // only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip @@ -4309,6 +4453,17 @@ const [manualEditTargets, setManualEditTargets] = useState([ const [strokePoints, setStrokePoints] = useState([]); 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> = {}; @@ -4432,8 +4587,18 @@ const [manualEditTargets, setManualEditTargets] = useState([ const [slideState, setSlideState] = useState( () => 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(null); const [chromeActionsHost, setChromeActionsHost] = useState(null); useEffect(() => { @@ -6479,6 +6644,12 @@ const [manualEditTargets, setManualEditTargets] = useState([ }; 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 ? ( ([ /> ) : null; const commentSidePanel = commentPanelOpen ? ( - { - 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([
{t('fileViewer.loading')}
) : mode === 'preview' ? (
{manualEditPanel} -
-
- +
+
-
- {OD_PREVIEW_KEEP_ALIVE ? ( - { - 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(); - }} - /> - ) : ( + +
+ {OD_PREVIEW_KEEP_ALIVE ? ( + { + 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(); + }} + /> + ) : ( +