import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { APP_CHROME_FILE_ACTIONS_ID, APP_CHROME_FILE_ACTIONS_SELECTOR } from './AppChromeHeader'; import { anonymizeArtifactId, artifactKindToTracking, type TrackingProjectKind, } from '@open-design/contracts/analytics'; import { useAnalytics } from '../analytics/provider'; import { trackIframeLoad } from '../observability/iframe-error'; import { trackArtifactExportResult, trackArtifactHeaderClick, trackArtifactToolbarClick, trackCommentPopoverClick, trackPageView, trackPresentPopoverClick, trackShareOptionPopoverClick, } from '../analytics/events'; import { MarkdownRenderer, artifactRendererRegistry } from '../artifacts/renderer-registry'; import { renderMarkdownToSafeHtml } from '../artifacts/markdown'; import { useT, useI18n } from '../i18n'; import type { Dict, Locale } from '../i18n/types'; import { fetchLiveArtifact, fetchLiveArtifactCode, fetchLiveArtifactRefreshes, checkDeploymentLink, CLOUDFLARE_PAGES_PROVIDER_ID, DEFAULT_DEPLOY_PROVIDER_ID, deployProjectFile, fetchCloudflarePagesZones, fetchDeployConfig, fetchProjectDeployments, fetchProjectFilePreview, fetchProjectFiles, fetchProjectFileText, uploadProjectFiles, liveArtifactPreviewUrl, projectFileUrl, projectRawUrl, LiveArtifactRefreshError, refreshLiveArtifact, updateDeployConfig, type WebDeployConfigResponse, type WebCloudflarePagesDeploySelection, type WebDeploymentInfo, type WebDeployProjectFileResponse, type WebDeployProviderId, type WebUpdateDeployConfigRequest, writeProjectTextFile, writeProjectTextFileDetailed, } from '../providers/registry'; import type { ProjectFilePreview } from '../providers/registry'; import { downloadImageDataUrl, exportAsHtml, exportAsJsx, exportAsMd, exportAsPdf, exportProjectAsPdf, exportProjectAsZip, exportReactComponentAsHtml, exportReactComponentAsZip, imageDataUrlToBlob, openSandboxedPreviewInNewTab, prepareImageExportTarget, requestPreviewSnapshot, type ImageExportFormat, } from '../runtime/exports'; import { buildReactComponentSrcdoc } from '../runtime/react-component'; import { findHtmlEntriesReferencing } from '../runtime/jsx-module-refs'; import { buildLazySrcdocTransport, buildSrcdoc, canActivateSrcDocTransport } from '../runtime/srcdoc'; import { hasUrlModeBridge, htmlNeedsFocusGuard, htmlNeedsSandboxShim, parseForceInline, shouldUrlLoadHtmlPreview, } from './file-viewer-render-mode'; import { saveTemplate } from '../state/projects'; import type { LiveArtifactEventItem, LiveArtifact, LiveArtifactRefreshLogEntry, LiveArtifactViewerTab, LiveArtifactWorkspaceEntry, ProjectFile, } from '../types'; import { Icon } from './Icon'; import { RemixIcon } from './RemixIcon'; import { Toast } from './Toast'; import { PreviewDrawOverlay } from './PreviewDrawOverlay'; import { buildBoardCommentAttachments, commentTargetDisplayName, commentsToAttachments, liveSnapshotForComment, overlayBoundsFromSnapshot, selectionKindLabel, targetFromSnapshot, type PreviewCommentSnapshot, } from '../comments'; import { applyPodMemberRemoval } from '../lib/pod-members'; import { AnnotationHoverPopover, BoardComposerPopover } from './BoardComposerPopover'; import { OD_PREVIEW_KEEP_ALIVE, PooledIframe, previewIframeKeepAliveKey, } from './IframeKeepAlivePool'; import type { ChatCommentAttachment, PreviewComment, PreviewCommentMember, PreviewCommentTarget, } from '../types'; import { ManualEditPanel, emptyManualEditDraft, type ManualEditDraft } from './ManualEditPanel'; import { applyManualEditPatch, isManualEditFullHtmlDocument, readManualEditAttributes, readManualEditFields, readManualEditOuterHtml, readManualEditStyles, } from '../edit-mode/source-patches'; import { MANUAL_EDIT_STYLE_PROPS, type ManualEditBridgeMessage, type ManualEditHistoryEntry, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../edit-mode/types'; import { isRenderableSketchJson, SketchPreview } from './SketchPreview'; function resolveChromeActionsHost(): HTMLElement | null { return document.querySelector(APP_CHROME_FILE_ACTIONS_SELECTOR) ?? document.getElementById(APP_CHROME_FILE_ACTIONS_ID); } type TranslateFn = (key: keyof Dict, vars?: Record) => string; type SlideState = { active: number; count: number }; type BoardTool = 'inspect' | 'pod'; type StrokePoint = { x: number; y: number }; export type ManualEditPendingStyleSave = { id: string; styles: Partial; label: string; version: number; }; 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; height: number | null; labelKey: keyof Dict; titleKey: keyof Dict; }; const IMAGE_EXPORT_FORMAT_OPTIONS: Array<{ value: ImageExportFormat; label: string; extension: string; }> = [ { value: 'png', label: 'PNG', extension: '.png' }, { value: 'jpeg', label: 'JPEG', extension: '.jpg' }, { value: 'webp', label: 'WebP', extension: '.webp' }, ]; type DeployProviderOption = { id: WebDeployProviderId; labelKey: 'fileViewer.vercelProvider' | 'fileViewer.cloudflarePagesProvider'; tokenLink: string; tokenLinkKey: 'fileViewer.vercelTokenGetLink' | 'fileViewer.cloudflareApiTokenGetLink'; tokenPlaceholderKey: | 'fileViewer.vercelTokenPlaceholder' | 'fileViewer.cloudflareApiTokenPlaceholder'; tokenReuseHintKey: 'fileViewer.vercelTokenReuseHint' | 'fileViewer.cloudflareApiTokenReuseHint'; tokenRequiredKey: 'fileViewer.vercelTokenRequired' | 'fileViewer.cloudflareApiTokenRequired'; previewHintKey: 'fileViewer.vercelPreviewOnly' | 'fileViewer.cloudflarePagesPreviewHint'; tokenLabelKey: | 'fileViewer.vercelToken' | 'fileViewer.cloudflareApiToken'; accountIdLabelKey?: 'fileViewer.cloudflareAccountId'; accountIdHintKey?: 'fileViewer.cloudflareAccountIdHint'; }; type CloudflarePagesZoneOption = { id: string; name: string; status?: string; type?: string; }; type DeployResultCard = { id: string; label: string; url: string; status: string; message?: string; }; const MAX_BRIDGE_COORDINATE = 1_000_000; const PREVIEW_VIEWPORT_PRESETS: PreviewViewportPreset[] = [ { id: 'desktop', width: null, height: null, labelKey: 'fileViewer.viewportDesktop', titleKey: 'fileViewer.viewportDesktopTitle', }, { id: 'tablet', width: 820, height: 1180, labelKey: 'fileViewer.viewportTablet', titleKey: 'fileViewer.viewportTabletTitle', }, { id: 'mobile', width: 390, height: 844, labelKey: 'fileViewer.viewportMobile', titleKey: 'fileViewer.viewportMobileTitle', }, ]; 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 // the per-element delta is small + obvious + cheap to read back from // getComputedStyle on the iframe side. type InspectStyleSnapshot = { color?: string; backgroundColor?: string; fontSize?: string; fontWeight?: string; paddingTop?: string; paddingRight?: string; paddingBottom?: string; paddingLeft?: string; borderRadius?: string; textAlign?: string; fontFamily?: string; lineHeight?: string; }; type InspectClickedDescendant = { label: string; text: string; }; type InspectTarget = { elementId: string; selector: string; label: string; text: string; style: InspectStyleSnapshot; clickedDescendant?: InspectClickedDescendant; }; const MAX_CACHED_SLIDE_STATES = 64; const htmlPreviewSlideState = new Map(); const MAX_CACHED_PREVIEW_VIEWPORTS = 128; const htmlPreviewViewportState = new Map(); const MARKDOWN_CODE_BLOCK_ATTR = 'data-markdown-code-block'; const MARKDOWN_COPY_BLOCK_ATTR = 'data-copy-code-block'; const MARKDOWN_COPY_BUTTON_CLASS = 'markdown-code-copy'; const MARKDOWN_COPY_TOAST_CLASS = 'markdown-code-toast'; const DEPLOY_PROVIDER_OPTIONS: DeployProviderOption[] = [ { id: DEFAULT_DEPLOY_PROVIDER_ID, labelKey: 'fileViewer.vercelProvider', tokenLink: 'https://vercel.com/account/settings/tokens', tokenLinkKey: 'fileViewer.vercelTokenGetLink', tokenPlaceholderKey: 'fileViewer.vercelTokenPlaceholder', tokenReuseHintKey: 'fileViewer.vercelTokenReuseHint', tokenRequiredKey: 'fileViewer.vercelTokenRequired', previewHintKey: 'fileViewer.vercelPreviewOnly', tokenLabelKey: 'fileViewer.vercelToken', }, { id: CLOUDFLARE_PAGES_PROVIDER_ID, labelKey: 'fileViewer.cloudflarePagesProvider', tokenLink: 'https://dash.cloudflare.com/profile/api-tokens', tokenLinkKey: 'fileViewer.cloudflareApiTokenGetLink', tokenPlaceholderKey: 'fileViewer.cloudflareApiTokenPlaceholder', tokenReuseHintKey: 'fileViewer.cloudflareApiTokenReuseHint', tokenRequiredKey: 'fileViewer.cloudflareApiTokenRequired', previewHintKey: 'fileViewer.cloudflarePagesPreviewHint', tokenLabelKey: 'fileViewer.cloudflareApiToken', accountIdLabelKey: 'fileViewer.cloudflareAccountId', accountIdHintKey: 'fileViewer.cloudflareAccountIdHint', }, ]; function mergeManualEditInspectorStyles( sourceStyles: ManualEditStyles, previewStyles: ManualEditStyles, ): ManualEditStyles { return MANUAL_EDIT_STYLE_PROPS.reduce((acc, key) => { const sourceValue = sourceStyles[key]?.trim(); const previewValue = previewStyles[key]?.trim(); const value = sourceValue || previewValue || ''; acc[key] = manualEditInspectorStyleValue(key, value); return acc; }, {} as ManualEditStyles); } function manualEditInspectorStyleValue(key: keyof ManualEditStyles, value: string): string { if (!value) return ''; if (key === 'color' || key === 'backgroundColor' || key === 'borderColor') { return normalizeManualEditInspectorColor(value); } return value; } function normalizeManualEditInspectorColor(value: string): string { const trimmed = value.trim(); if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed.toLowerCase(); if (/^#[0-9a-f]{3}$/i.test(trimmed)) { const r = trimmed[1]!, g = trimmed[2]!, b = trimmed[3]!; return `#${r}${r}${g}${g}${b}${b}`.toLowerCase(); } const rgba = trimmed.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i); if (!rgba) return trimmed; if (rgba[4] !== undefined && Number(rgba[4]) === 0) return ''; const toHex = (raw: string) => Math.max(0, Math.min(255, Math.round(Number(raw)))) .toString(16) .padStart(2, '0'); return `#${toHex(rgba[1]!)}${toHex(rgba[2]!)}${toHex(rgba[3]!)}`; } function manualEditPersistedValueMatchesSavedSnapshot( key: keyof ManualEditStyles, persistedValue: string, savedValue: string, ): boolean { return canonicalManualEditStyleValue(key, persistedValue) === canonicalManualEditStyleValue(key, savedValue); } function canonicalManualEditStyleValue(key: keyof ManualEditStyles, value: string): string { const normalized = manualEditInspectorStyleValue(key, value).trim(); if (!normalized) return ''; return normalized.toLowerCase(); } function getDeployProviderOption(providerId: WebDeployProviderId): DeployProviderOption { return DEPLOY_PROVIDER_OPTIONS.find((option) => option.id === providerId) ?? DEPLOY_PROVIDER_OPTIONS[0]!; } function normalizeCloudflareDomainPrefixInput(raw: string): string { return raw.trim().toLowerCase(); } function isValidCloudflareDomainPrefixInput(raw: string): boolean { const prefix = normalizeCloudflareDomainPrefixInput(raw); return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(prefix); } function deployResultState(status?: string): 'ready' | 'delayed' | 'protected' | 'failed' { if (status === 'protected') return 'protected'; if (status === 'failed' || status === 'conflict') return 'failed'; if (status === 'link-delayed' || status === 'pending') return 'delayed'; return 'ready'; } async function copyTextToClipboard(text: string): Promise { try { await navigator.clipboard.writeText(text); return true; } catch { const priorFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { return document.execCommand('copy'); } catch { return false; } finally { document.body.removeChild(ta); if (priorFocus?.isConnected) { try { priorFocus.focus({ preventScroll: true }); } catch { priorFocus.focus(); } } } } } function decorateMarkdownCodeBlocks(html: string): string { let blockIndex = 0; return html.replace(/]*)>([\s\S]*?)<\/pre>/g, (_match, attrs: string, content: string) => { const blockId = String(blockIndex++); return `
${content}
`; }); } function setMarkdownCodeBlockCopiedState(block: HTMLElement, copied: boolean, t: TranslateFn) { const button = block.querySelector(`.${MARKDOWN_COPY_BUTTON_CLASS}`); if (!button) return; const label = copied ? t('fileViewer.copied') : t('fileViewer.copy'); button.textContent = label; button.setAttribute('aria-label', label); button.title = t('fileViewer.copyTitle'); const existingToast = block.querySelector(`.${MARKDOWN_COPY_TOAST_CLASS}`); if (copied) { if (existingToast instanceof HTMLElement) { existingToast.textContent = t('fileViewer.copied'); return; } const toast = document.createElement('span'); toast.className = MARKDOWN_COPY_TOAST_CLASS; toast.setAttribute('role', 'status'); toast.setAttribute('aria-live', 'polite'); toast.textContent = t('fileViewer.copied'); button.insertAdjacentElement('afterend', toast); return; } existingToast?.remove(); } function PreviewViewportControls({ viewport, onViewport, t, tabIndex, }: { viewport: PreviewViewportId; onViewport: (viewport: PreviewViewportId) => void; t: TranslateFn; tabIndex?: number; }) { const [open, setOpen] = useState(false); const menuRef = useRef(null); const listboxId = useId(); const activePreset = PREVIEW_VIEWPORT_PRESETS.find((preset) => preset.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!; useEffect(() => { if (!open) return; const onPointerDown = (event: PointerEvent) => { if (!menuRef.current) return; if (!menuRef.current.contains(event.target as Node)) setOpen(false); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false); }; document.addEventListener('pointerdown', onPointerDown); document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('pointerdown', onPointerDown); document.removeEventListener('keydown', onKeyDown); }; }, [open]); return (
{open ? (
{PREVIEW_VIEWPORT_PRESETS.map((preset) => { const selected = viewport === preset.id; return ( ); })}
) : null}
); } 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, options); return { '--preview-viewport-width': `${preset.width}px`, '--preview-viewport-height': `${preset.height}px`, '--preview-scale': effectiveScale, '--preview-user-scale': previewScale, }; } 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 = 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); return Math.min(previewScale, fitScale); } type PreviewOverlayTransform = { scale: number; offsetX: number; offsetY: number }; export function previewOverlayTransform( viewport: PreviewViewportId, previewScale: number, canvasSize?: PreviewCanvasSize, ): PreviewOverlayTransform { const scale = effectivePreviewScale(viewport, previewScale, canvasSize); if (viewport === 'desktop') return { scale, offsetX: 0, offsetY: 0 }; const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport); const pad = 24; if (!preset?.width || !preset.height) return { scale, offsetX: pad, offsetY: pad }; const availableWidth = Math.max(1, (canvasSize?.width ?? preset.width * scale + pad * 2) - pad * 2); const scaledWidth = preset.width * scale; return { scale, offsetX: pad + Math.max(0, (availableWidth - scaledWidth) / 2), offsetY: pad, }; } function previewScaleShellStyle( viewport: PreviewViewportId, previewScale: number, ): CSSProperties & Record { if (viewport === 'desktop') { return { width: `${100 / previewScale}%`, height: `${100 / previewScale}%`, transform: `scale(${previewScale})`, transformOrigin: '0 0', }; } return { width: 'var(--preview-viewport-width)', height: 'var(--preview-viewport-height)', transform: 'scale(var(--preview-scale, 1))', transformOrigin: '0 0', }; } function manualEditPreviewShellStyle( viewport: PreviewViewportId, previewScale: number, frozenWidth: number | null, ): CSSProperties & Record { if (viewport === 'desktop' && frozenWidth) { return { width: `${frozenWidth / previewScale}px`, height: `${100 / previewScale}%`, transform: `scale(${previewScale})`, transformOrigin: '0 0', }; } return previewScaleShellStyle(viewport, previewScale); } function manualEditFloatingPanelStyle( target: ManualEditTarget, previewScale: number, canvasSize: PreviewCanvasSize | undefined, ): CSSProperties { const scale = Number.isFinite(previewScale) && previewScale > 0 ? previewScale : 1; const panelWidth = 320; const preferredPanelHeight = 380; const pad = 12; const canvasWidth = canvasSize?.width ?? 1200; const canvasHeight = canvasSize?.height ?? 800; const panelHeight = Math.min(preferredPanelHeight, Math.max(260, canvasHeight - pad * 2)); const targetLeft = target.rect.x * scale; const targetTop = target.rect.y * scale; const targetRight = (target.rect.x + target.rect.width) * scale; let left = targetRight + pad; if (left + panelWidth > canvasWidth - pad) { left = Math.max(pad, targetLeft - panelWidth - pad); } const top = Math.max( pad, Math.min(targetTop, Math.max(pad, canvasHeight - panelHeight - pad)), ); return { left, top, width: panelWidth, height: panelHeight, maxHeight: `calc(100% - ${pad * 2}px)`, }; } export function cancelManualEditPendingStyleSnapshot( pending: ManualEditPendingStyleSave | null, id: string, keys: Array, ): ManualEditPendingStyleSave | null { if (!pending || pending.id !== id || keys.length === 0) return pending; const nextStyles = { ...pending.styles }; for (const key of keys) delete nextStyles[key]; if (Object.keys(nextStyles).length === 0) return null; return { ...pending, styles: nextStyles }; } function usePreviewCanvasSize() { const ref = useRef(null); const [size, setSize] = useState(undefined); useEffect(() => { const el = ref.current; if (!el) return; const measure = () => { const rect = el.getBoundingClientRect(); setSize({ width: rect.width, height: rect.height }); }; measure(); if (typeof ResizeObserver !== 'undefined') { const observer = new ResizeObserver(measure); observer.observe(el); return () => observer.disconnect(); } window.addEventListener('resize', measure); return () => window.removeEventListener('resize', measure); }, []); return [ref, size] as const; } function ensureMarkdownCodeBlockControls(root: HTMLElement, t: TranslateFn) { for (const block of root.querySelectorAll(`[${MARKDOWN_CODE_BLOCK_ATTR}]`)) { let button = block.querySelector(`.${MARKDOWN_COPY_BUTTON_CLASS}`); if (!button) { button = document.createElement('button'); button.type = 'button'; button.className = MARKDOWN_COPY_BUTTON_CLASS; const blockId = block.getAttribute(MARKDOWN_CODE_BLOCK_ATTR) ?? ''; button.setAttribute(MARKDOWN_COPY_BLOCK_ATTR, blockId); block.prepend(button); } setMarkdownCodeBlockCopiedState(block, false, t); } } function setSlideStateCached(key: string, state: SlideState) { htmlPreviewSlideState.set(key, state); if (htmlPreviewSlideState.size > MAX_CACHED_SLIDE_STATES) { const oldest = htmlPreviewSlideState.keys().next().value; if (oldest != null) htmlPreviewSlideState.delete(oldest); } } function waitForIframeLoadOrTimeout(iframe: HTMLIFrameElement, timeout = 750): Promise { return new Promise((resolve) => { let settled = false; const finish = () => { if (settled) return; settled = true; iframe.removeEventListener('load', finish); window.clearTimeout(timer); resolve(); }; const timer = window.setTimeout(finish, timeout); iframe.addEventListener('load', finish, { once: true }); }); } function previewViewportStateKey(projectId: string, file: Pick): string { return `${projectId}:${file.path || file.name}`; } function setPreviewViewportCached(key: string, viewport: PreviewViewportId) { htmlPreviewViewportState.set(key, viewport); if (htmlPreviewViewportState.size > MAX_CACHED_PREVIEW_VIEWPORTS) { const oldest = htmlPreviewViewportState.keys().next().value; if (oldest != null) htmlPreviewViewportState.delete(oldest); } } interface Props { projectId: string; projectKind: TrackingProjectKind; file: ProjectFile; liveHtml?: string; filesRefreshKey?: number; isDeck?: boolean; onExportAsPptx?: ((fileName: string) => void) | undefined; streaming?: boolean; commentSendDisabled?: boolean; previewComments?: PreviewComment[]; onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise; onRemovePreviewComment?: (commentId: string) => Promise; onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise | boolean | void; onFileSaved?: () => Promise | void; // Open `openName` as a tab (focusing it) and close `closeName` in one // atomic tab-state update. The React module pointer uses this to jump to the // HTML entry that renders a module and drop the dead-end module tab. onOpenFileReplacing?: (openName: string, closeName: string) => void; commentPortalId?: string; onCommentModeChange?: (active: boolean) => void; } export function FileViewer({ projectId, projectKind, file, liveHtml, filesRefreshKey = 0, isDeck, onExportAsPptx, streaming, commentSendDisabled = false, previewComments = [], onSavePreviewComment, onRemovePreviewComment, onSendBoardCommentAttachments, onFileSaved, onOpenFileReplacing, commentPortalId, onCommentModeChange, }: Props) { const rendererMatch = artifactRendererRegistry.resolve({ file, isDeckHint: Boolean(isDeck), }); // studio_view artifact — fire once per (project, file) pair so the // activation funnel can attribute "user opened the produced artifact" // even when the sub-viewer below is HtmlViewer / MarkdownViewer / etc. // artifact_id is anonymized to satisfy the CSV's no-filename rule. const analytics = useAnalytics(); const studioViewKeyRef = useRef(null); useEffect(() => { const key = `${projectId}::${file.name}`; if (studioViewKeyRef.current === key) return; studioViewKeyRef.current = key; trackPageView(analytics.track, { page_name: 'artifact', }); }, [projectId, projectKind, file.name, file.kind, rendererMatch?.renderer.id, analytics.track]); if (rendererMatch?.renderer.id === 'html' || rendererMatch?.renderer.id === 'deck-html') { return ( ); } if (rendererMatch?.renderer.id === 'react-component') { return ( ); } if (rendererMatch?.renderer.id === 'markdown') { return ; } if (rendererMatch?.renderer.id === 'svg') { return ; } if (file.kind === 'image') { return ; } if (file.kind === 'video') { return ; } if (file.kind === 'audio') { return ; } if (file.kind === 'sketch') { if (isRenderableSketchJson(file)) { return ; } return ; } if (file.kind === 'text' || file.kind === 'code') { return ; } if ( file.kind === 'pdf' || file.kind === 'document' || file.kind === 'presentation' || file.kind === 'spreadsheet' ) { return ; } return ; } export function LiveArtifactViewer({ projectId, liveArtifact, liveArtifactEvents = [], onRefreshArtifacts, }: { projectId: string; liveArtifact: LiveArtifactWorkspaceEntry; liveArtifactEvents?: LiveArtifactEventItem[]; onRefreshArtifacts?: () => Promise | void; }) { const t = useT(); const tabs = useMemo(() => liveArtifactViewerTabs(t), [t]); const [mode, setMode] = useState('preview'); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [reloadKey, setReloadKey] = useState(0); const [zoom, setZoom] = useState(100); const liveArtifactViewportKey = `${projectId}:live-artifact:${liveArtifact.artifactId}`; const [previewViewport, setPreviewViewportState] = useState( () => htmlPreviewViewportState.get(liveArtifactViewportKey) ?? 'desktop', ); const setPreviewViewport = useCallback((viewport: PreviewViewportId) => { setPreviewViewportCached(liveArtifactViewportKey, viewport); setPreviewViewportState(viewport); }, [liveArtifactViewportKey]); const [previewBodyRef, previewBodySize] = usePreviewCanvasSize(); const iframeRef = useRef(null); const [refreshing, setRefreshing] = useState(false); const [refreshError, setRefreshError] = useState(null); const [refreshSuccess, setRefreshSuccess] = useState(null); const [refreshEvents, setRefreshEvents] = useState([]); const [refreshHistory, setRefreshHistory] = useState([]); const [presentMenuOpen, setPresentMenuOpen] = useState(false); const [zoomMenuOpen, setZoomMenuOpen] = useState(false); const zoomMenuRef = useRef(null); const [inTabPresent, setInTabPresent] = useState(false); const presentWrapRef = useRef(null); const [chromeActionsHost, setChromeActionsHost] = useState(null); useEffect(() => { if (typeof document === 'undefined') return; setChromeActionsHost(resolveChromeActionsHost()); }, []); useEffect(() => { if (!presentMenuOpen) return; const onPointer = (e: MouseEvent) => { const target = e.target as HTMLElement | null; if (!target) return; if (target.closest('.present-wrap')) return; setPresentMenuOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setPresentMenuOpen(false); }; document.addEventListener('mousedown', onPointer); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onPointer); document.removeEventListener('keydown', onKey); }; }, [presentMenuOpen]); useEffect(() => { setRefreshError(null); setRefreshSuccess(null); setRefreshEvents([]); }, [projectId, liveArtifact.artifactId]); useEffect(() => { setPreviewViewportState(htmlPreviewViewportState.get(liveArtifactViewportKey) ?? 'desktop'); }, [liveArtifactViewportKey]); useEffect(() => { if (!refreshSuccess) return; const timeout = window.setTimeout(() => setRefreshSuccess(null), 6000); return () => window.clearTimeout(timeout); }, [refreshSuccess]); const processedLiveArtifactEventIdRef = useRef(0); useEffect(() => { const pendingEvents = liveArtifactEvents.filter((item) => item.id > processedLiveArtifactEventIdRef.current); if (pendingEvents.length === 0) return; processedLiveArtifactEventIdRef.current = pendingEvents[pendingEvents.length - 1]?.id ?? processedLiveArtifactEventIdRef.current; for (const { event: liveArtifactEvent } of pendingEvents) { if ( (liveArtifactEvent.kind !== 'live_artifact' && liveArtifactEvent.kind !== 'live_artifact_refresh') || liveArtifactEvent.projectId !== projectId || liveArtifactEvent.artifactId !== liveArtifact.artifactId ) { continue; } if (liveArtifactEvent.kind === 'live_artifact') { setRefreshError(null); if (liveArtifactEvent.action === 'deleted') { setRefreshSuccess(`Live artifact deleted: ${liveArtifactEvent.title}`); continue; } setRefreshSuccess( liveArtifactEvent.action === 'created' ? `Live artifact created: ${liveArtifactEvent.title}` : `Live artifact updated: ${liveArtifactEvent.title}`, ); void fetchLiveArtifact(projectId, liveArtifact.artifactId).then((next) => { if (next) setDetail(next); }); void fetchLiveArtifactRefreshes(projectId, liveArtifact.artifactId).then(setRefreshHistory); setReloadKey((n) => n + 1); continue; } if (liveArtifactEvent.phase === 'started') { setRefreshing(true); setRefreshError(null); setRefreshSuccess(null); setRefreshEvents((prev) => appendRefreshEvent(prev, { phase: 'started' })); continue; } if (liveArtifactEvent.phase === 'failed') { setRefreshing(false); setRefreshError(liveArtifactEvent.error ?? t('liveArtifact.refresh.genericFailure')); setRefreshEvents((prev) => appendRefreshEvent(prev, { phase: 'failed', error: liveArtifactEvent.error ?? undefined, }), ); void fetchLiveArtifact(projectId, liveArtifact.artifactId).then((next) => { if (next) setDetail(next); }); void fetchLiveArtifactRefreshes(projectId, liveArtifact.artifactId).then(setRefreshHistory); continue; } setRefreshing(false); setRefreshError(null); setRefreshEvents((prev) => appendRefreshEvent(prev, { phase: 'succeeded', refreshedSourceCount: liveArtifactEvent.refreshedSourceCount ?? 0, }), ); if ((liveArtifactEvent.refreshedSourceCount ?? 0) > 0) { setRefreshSuccess(t('liveArtifact.refresh.successOne')); } else { setRefreshError(t('liveArtifact.refresh.noSourceTitle')); } void fetchLiveArtifact(projectId, liveArtifact.artifactId).then((next) => { if (next) setDetail(next); }); void fetchLiveArtifactRefreshes(projectId, liveArtifact.artifactId).then(setRefreshHistory); setReloadKey((n) => n + 1); } }, [liveArtifactEvents, liveArtifact.artifactId, projectId, t]); useEffect(() => { let cancelled = false; setLoading(true); setDetail(null); void fetchLiveArtifact(projectId, liveArtifact.artifactId).then((next) => { if (cancelled) return; setDetail(next); setLoading(false); }); void fetchLiveArtifactRefreshes(projectId, liveArtifact.artifactId).then((next) => { if (!cancelled) setRefreshHistory(next); }); return () => { cancelled = true; }; }, [projectId, liveArtifact.artifactId, liveArtifact.updatedAt]); const previewUrl = useMemo( () => `${liveArtifactPreviewUrl(projectId, liveArtifact.artifactId)}&v=${reloadKey}`, [projectId, liveArtifact.artifactId, reloadKey], ); const previewScale = zoom / 100; // Instrument the live-artifact iframe so failed loads — usually a // missing artifact file or a stuck `od://` resolver — surface in // PostHog. iframe load errors don't propagate to window.error, so // observability/install.ts cannot catch them globally. useEffect(() => { if (mode !== 'preview') return undefined; const node = iframeRef.current; if (!node) return undefined; return trackIframeLoad({ iframe: node, surface: 'live_artifact_preview', artifactId: liveArtifact.artifactId, projectId, }); }, [mode, previewUrl, liveArtifact.artifactId, projectId]); async function handleRefresh() { if (refreshing) return; setRefreshing(true); setRefreshError(null); setRefreshSuccess(null); setRefreshEvents((prev) => appendRefreshEvent(prev, { phase: 'started' })); try { const result = await refreshLiveArtifact(projectId, liveArtifact.artifactId); setDetail(result.artifact); void fetchLiveArtifactRefreshes(projectId, liveArtifact.artifactId).then(setRefreshHistory); setReloadKey((n) => n + 1); setRefreshEvents((prev) => appendRefreshEvent(prev, { phase: 'succeeded', refreshedSourceCount: result.refresh.refreshedSourceCount, }), ); if (result.refresh.refreshedSourceCount > 0) { setRefreshSuccess(t('liveArtifact.refresh.successOne')); } else { setRefreshError(t('liveArtifact.refresh.noSourceTitle')); } await onRefreshArtifacts?.(); } catch (error) { const message = refreshErrorMessage(error, t); setRefreshError(message); setRefreshEvents((prev) => appendRefreshEvent(prev, { phase: 'failed', error: message })); } finally { setRefreshing(false); } } const dataPayload = detail?.document?.dataJson ?? null; const currentRefreshStatus = detail?.refreshStatus ?? liveArtifact.refreshStatus; const isRunning = refreshing || currentRefreshStatus === 'running'; const presentInThisTab = () => { setPresentMenuOpen(false); setMode('preview'); setInTabPresent(true); }; const presentFullscreen = () => { setPresentMenuOpen(false); setMode('preview'); const target = previewBodyRef.current ?? iframeRef.current; if (target?.requestFullscreen) { void target.requestFullscreen().catch(() => {}); } }; const presentNewTab = () => { setPresentMenuOpen(false); if (typeof window === 'undefined') return; window.open(liveArtifactPreviewUrl(projectId, liveArtifact.artifactId), '_blank', 'noopener,noreferrer'); }; useEffect(() => { if (!inTabPresent) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setInTabPresent(false); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [inTabPresent]); useEffect(() => { if (!zoomMenuOpen) return; const onDocClick = (e: MouseEvent) => { if (!zoomMenuRef.current) return; if (!zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setZoomMenuOpen(false); }; document.addEventListener('mousedown', onDocClick); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onKey); }; }, [zoomMenuOpen]); return (
{((node: ReactNode) => ( chromeActionsHost ? createPortal(node, chromeActionsHost) : node ))(
{presentMenuOpen ? (
) : null}
)} {inTabPresent ? ( ) : null}
{tabs.map((tab) => ( ))}
{zoomMenuOpen && mode === 'preview' ? (
{[50, 75, 100, 125, 150, 200].map((level) => ( ))}
) : null}
{t('fileViewer.open')}
{refreshError ? ( ) : refreshSuccess ? ( setRefreshSuccess(null)} dismissLabel={t('common.close')} /> ) : isRunning ? ( ) : currentRefreshStatus === 'failed' ? ( ) : null}