mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
8771 lines
333 KiB
TypeScript
8771 lines
333 KiB
TypeScript
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<HTMLElement>(APP_CHROME_FILE_ACTIONS_SELECTOR)
|
||
?? document.getElementById(APP_CHROME_FILE_ACTIONS_ID);
|
||
}
|
||
|
||
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
||
type SlideState = { active: number; count: number };
|
||
type BoardTool = 'inspect' | 'pod';
|
||
type StrokePoint = { x: number; y: number };
|
||
export type ManualEditPendingStyleSave = {
|
||
id: string;
|
||
styles: Partial<ManualEditStyles>;
|
||
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<string, SlideState>();
|
||
const MAX_CACHED_PREVIEW_VIEWPORTS = 128;
|
||
const htmlPreviewViewportState = new Map<string, PreviewViewportId>();
|
||
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<ManualEditStyles>((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<boolean> {
|
||
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(/<pre\b([^>]*)>([\s\S]*?)<\/pre>/g, (_match, attrs: string, content: string) => {
|
||
const blockId = String(blockIndex++);
|
||
return `<div class="markdown-code-block" ${MARKDOWN_CODE_BLOCK_ATTR}="${blockId}"><pre${attrs}>${content}</pre></div>`;
|
||
});
|
||
}
|
||
|
||
function setMarkdownCodeBlockCopiedState(block: HTMLElement, copied: boolean, t: TranslateFn) {
|
||
const button = block.querySelector<HTMLButtonElement>(`.${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<HTMLDivElement | null>(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 (
|
||
<div className="viewer-viewport-switcher" ref={menuRef}>
|
||
<button
|
||
type="button"
|
||
className="viewer-action viewer-viewport-trigger"
|
||
aria-label={t('fileViewer.viewportAria')}
|
||
aria-haspopup="listbox"
|
||
aria-expanded={open}
|
||
aria-controls={open ? listboxId : undefined}
|
||
title={t(activePreset.titleKey)}
|
||
tabIndex={tabIndex}
|
||
onClick={() => setOpen((value) => !value)}
|
||
>
|
||
<span>{t(activePreset.labelKey)}</span>
|
||
<RemixIcon name="arrow-down-s-line" size={14} />
|
||
</button>
|
||
{open ? (
|
||
<div className="viewer-viewport-menu" id={listboxId} role="listbox" aria-label={t('fileViewer.viewportAria')}>
|
||
{PREVIEW_VIEWPORT_PRESETS.map((preset) => {
|
||
const selected = viewport === preset.id;
|
||
return (
|
||
<button
|
||
key={preset.id}
|
||
type="button"
|
||
className={`viewer-viewport-menu-item${selected ? ' active' : ''}`}
|
||
role="option"
|
||
aria-selected={selected}
|
||
title={t(preset.titleKey)}
|
||
onClick={() => {
|
||
onViewport(preset.id);
|
||
setOpen(false);
|
||
}}
|
||
>
|
||
<span>{t(preset.labelKey)}</span>
|
||
{selected ? <Icon name="check" size={13} /> : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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, 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<string, string | number> {
|
||
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<string, string | number> {
|
||
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<keyof ManualEditStyles>,
|
||
): 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<T extends HTMLElement>() {
|
||
const ref = useRef<T | null>(null);
|
||
const [size, setSize] = useState<PreviewCanvasSize | undefined>(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<HTMLElement>(`[${MARKDOWN_CODE_BLOCK_ATTR}]`)) {
|
||
let button = block.querySelector<HTMLButtonElement>(`.${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<void> {
|
||
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<ProjectFile, 'name' | 'path'>): 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<PreviewComment | null>;
|
||
onRemovePreviewComment?: (commentId: string) => Promise<void>;
|
||
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<boolean | void> | boolean | void;
|
||
onFileSaved?: () => Promise<void> | 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<string | null>(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 (
|
||
<HtmlViewer
|
||
projectId={projectId}
|
||
projectKind={projectKind}
|
||
file={file}
|
||
liveHtml={liveHtml}
|
||
filesRefreshKey={filesRefreshKey}
|
||
isDeck={rendererMatch.renderer.id === 'deck-html'}
|
||
onExportAsPptx={onExportAsPptx}
|
||
streaming={Boolean(streaming)}
|
||
commentSendDisabled={commentSendDisabled}
|
||
previewComments={previewComments}
|
||
onSavePreviewComment={onSavePreviewComment}
|
||
onRemovePreviewComment={onRemovePreviewComment}
|
||
onSendBoardCommentAttachments={onSendBoardCommentAttachments}
|
||
onFileSaved={onFileSaved}
|
||
commentPortalId={commentPortalId}
|
||
onCommentModeChange={onCommentModeChange}
|
||
/>
|
||
);
|
||
}
|
||
if (rendererMatch?.renderer.id === 'react-component') {
|
||
return (
|
||
<ReactComponentViewer
|
||
projectId={projectId}
|
||
file={file}
|
||
onOpenFileReplacing={onOpenFileReplacing}
|
||
/>
|
||
);
|
||
}
|
||
if (rendererMatch?.renderer.id === 'markdown') {
|
||
return <MarkdownViewer projectId={projectId} file={file} />;
|
||
}
|
||
if (rendererMatch?.renderer.id === 'svg') {
|
||
return <SvgViewer projectId={projectId} file={file} />;
|
||
}
|
||
if (file.kind === 'image') {
|
||
return <ImageViewer projectId={projectId} file={file} />;
|
||
}
|
||
if (file.kind === 'video') {
|
||
return <VideoViewer projectId={projectId} file={file} />;
|
||
}
|
||
if (file.kind === 'audio') {
|
||
return <AudioViewer projectId={projectId} file={file} />;
|
||
}
|
||
if (file.kind === 'sketch') {
|
||
if (isRenderableSketchJson(file)) {
|
||
return <SketchViewer projectId={projectId} file={file} />;
|
||
}
|
||
return <ImageViewer projectId={projectId} file={file} />;
|
||
}
|
||
if (file.kind === 'text' || file.kind === 'code') {
|
||
return <TextViewer projectId={projectId} file={file} />;
|
||
}
|
||
if (
|
||
file.kind === 'pdf' ||
|
||
file.kind === 'document' ||
|
||
file.kind === 'presentation' ||
|
||
file.kind === 'spreadsheet'
|
||
) {
|
||
return <DocumentPreviewViewer projectId={projectId} file={file} />;
|
||
}
|
||
return <BinaryViewer projectId={projectId} file={file} />;
|
||
}
|
||
|
||
export function LiveArtifactViewer({
|
||
projectId,
|
||
liveArtifact,
|
||
liveArtifactEvents = [],
|
||
onRefreshArtifacts,
|
||
}: {
|
||
projectId: string;
|
||
liveArtifact: LiveArtifactWorkspaceEntry;
|
||
liveArtifactEvents?: LiveArtifactEventItem[];
|
||
onRefreshArtifacts?: () => Promise<void> | void;
|
||
}) {
|
||
const t = useT();
|
||
const tabs = useMemo(() => liveArtifactViewerTabs(t), [t]);
|
||
const [mode, setMode] = useState<LiveArtifactViewerTab>('preview');
|
||
const [detail, setDetail] = useState<LiveArtifact | null>(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<PreviewViewportId>(
|
||
() => htmlPreviewViewportState.get(liveArtifactViewportKey) ?? 'desktop',
|
||
);
|
||
const setPreviewViewport = useCallback((viewport: PreviewViewportId) => {
|
||
setPreviewViewportCached(liveArtifactViewportKey, viewport);
|
||
setPreviewViewportState(viewport);
|
||
}, [liveArtifactViewportKey]);
|
||
const [previewBodyRef, previewBodySize] = usePreviewCanvasSize<HTMLDivElement>();
|
||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [refreshError, setRefreshError] = useState<string | null>(null);
|
||
const [refreshSuccess, setRefreshSuccess] = useState<string | null>(null);
|
||
const [refreshEvents, setRefreshEvents] = useState<LiveArtifactRefreshEvent[]>([]);
|
||
const [refreshHistory, setRefreshHistory] = useState<LiveArtifactRefreshLogEntry[]>([]);
|
||
const [presentMenuOpen, setPresentMenuOpen] = useState(false);
|
||
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
|
||
const zoomMenuRef = useRef<HTMLDivElement | null>(null);
|
||
const [inTabPresent, setInTabPresent] = useState(false);
|
||
const presentWrapRef = useRef<HTMLDivElement | null>(null);
|
||
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(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 (
|
||
<div className={`viewer html-viewer live-artifact-viewer${inTabPresent ? ' is-tab-present' : ''}`}>
|
||
{((node: ReactNode) => (
|
||
chromeActionsHost ? createPortal(node, chromeActionsHost) : node
|
||
))(
|
||
<div className="present-wrap chrome-present-wrap" ref={presentWrapRef}>
|
||
<button
|
||
className="chrome-action chrome-action-secondary chrome-action-icon present-trigger"
|
||
aria-haspopup="menu"
|
||
aria-expanded={presentMenuOpen}
|
||
aria-label={t('fileViewer.present')}
|
||
data-tooltip={t('fileViewer.present')}
|
||
title={t('fileViewer.present')}
|
||
onClick={() => setPresentMenuOpen((v) => !v)}
|
||
>
|
||
<RemixIcon name="slideshow-3-line" size={15} />
|
||
</button>
|
||
{presentMenuOpen ? (
|
||
<div className="present-menu" role="menu">
|
||
<button role="menuitem" onClick={presentInThisTab}>
|
||
<span className="present-icon"><RemixIcon name="eye-line" size={14} /></span>{' '}
|
||
{t('fileViewer.presentInTab')}
|
||
</button>
|
||
<button role="menuitem" onClick={presentFullscreen}>
|
||
<span className="present-icon"><RemixIcon name="play-line" size={14} /></span>{' '}
|
||
{t('fileViewer.presentFullscreen')}
|
||
</button>
|
||
<button role="menuitem" onClick={presentNewTab}>
|
||
<span className="present-icon"><RemixIcon name="share-forward-line" size={14} /></span>{' '}
|
||
{t('fileViewer.presentNewTab')}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
{inTabPresent ? (
|
||
<button
|
||
type="button"
|
||
className="present-exit-btn"
|
||
onClick={() => setInTabPresent(false)}
|
||
title={t('common.exitFullscreen')}
|
||
aria-label={t('common.exitFullscreen')}
|
||
>
|
||
<Icon name="close" size={14} />
|
||
</button>
|
||
) : null}
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left">
|
||
<button
|
||
type="button"
|
||
className="icon-only"
|
||
onClick={() => setReloadKey((n) => n + 1)}
|
||
title={`${t('fileViewer.reload')} ${t('fileViewer.preview')}`}
|
||
aria-label={`${t('fileViewer.reloadAria')} ${t('fileViewer.preview')}`}
|
||
>
|
||
<Icon name="reload" size={14} />
|
||
</button>
|
||
</div>
|
||
<div className="viewer-toolbar-actions">
|
||
<div className="viewer-tabs">
|
||
{tabs.map((tab) => (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
className={`viewer-tab ${mode === tab.id ? 'active' : ''}`}
|
||
onClick={() => setMode(tab.id)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div
|
||
className="viewer-preview-controls"
|
||
data-active={mode === 'preview' ? 'true' : 'false'}
|
||
aria-hidden={mode === 'preview' ? undefined : true}
|
||
>
|
||
<span className="viewer-divider" aria-hidden />
|
||
<PreviewViewportControls
|
||
viewport={previewViewport}
|
||
onViewport={setPreviewViewport}
|
||
t={t}
|
||
tabIndex={mode === 'preview' ? 0 : -1}
|
||
/>
|
||
<span className="viewer-divider" aria-hidden />
|
||
<div className="zoom-menu viewer-toolbar-zoom" ref={zoomMenuRef}>
|
||
<button
|
||
type="button"
|
||
className="viewer-action zoom-trigger"
|
||
aria-haspopup="menu"
|
||
aria-expanded={zoomMenuOpen}
|
||
title={t('fileViewer.resetZoom')}
|
||
tabIndex={mode === 'preview' ? 0 : -1}
|
||
onClick={() => setZoomMenuOpen((v) => !v)}
|
||
>
|
||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span>
|
||
</button>
|
||
{zoomMenuOpen && mode === 'preview' ? (
|
||
<div className="zoom-menu-popover" role="menu">
|
||
{[50, 75, 100, 125, 150, 200].map((level) => (
|
||
<button
|
||
key={level}
|
||
type="button"
|
||
className={`zoom-menu-item${zoom === level ? ' active' : ''}`}
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setZoom(level);
|
||
setZoomMenuOpen(false);
|
||
}}
|
||
>
|
||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{level}%</span>
|
||
{zoom === level ? (
|
||
<Icon name="check" size={13} />
|
||
) : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<span className="viewer-divider" aria-hidden />
|
||
<a
|
||
className="ghost-link"
|
||
href={liveArtifactPreviewUrl(projectId, liveArtifact.artifactId)}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
tabIndex={mode === 'preview' ? 0 : -1}
|
||
>
|
||
{t('fileViewer.open')}
|
||
</a>
|
||
</div>
|
||
<span className="viewer-divider" aria-hidden />
|
||
<button
|
||
type="button"
|
||
className="viewer-action primary"
|
||
data-running={isRunning ? 'true' : 'false'}
|
||
onClick={() => void handleRefresh()}
|
||
disabled={isRunning}
|
||
aria-busy={isRunning}
|
||
aria-label={isRunning ? t('liveArtifact.refresh.running') : t('liveArtifact.refresh.button')}
|
||
title={
|
||
isRunning
|
||
? t('liveArtifact.refresh.running')
|
||
: t('liveArtifact.refresh.buttonTitle')
|
||
}
|
||
>
|
||
<Icon name={isRunning ? 'spinner' : 'reload'} size={13} />
|
||
<span>{isRunning ? t('liveArtifact.refresh.running') : t('liveArtifact.refresh.button')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="viewer-body" ref={previewBodyRef}>
|
||
{refreshError ? (
|
||
<LiveArtifactRefreshNotice
|
||
tone="error"
|
||
message={refreshError}
|
||
action={t('liveArtifact.refresh.failureAction')}
|
||
/>
|
||
) : refreshSuccess ? (
|
||
<LiveArtifactRefreshNotice
|
||
tone="success"
|
||
message={refreshSuccess}
|
||
action={t('liveArtifact.refresh.successAction')}
|
||
onDismiss={() => setRefreshSuccess(null)}
|
||
dismissLabel={t('common.close')}
|
||
/>
|
||
) : isRunning ? (
|
||
<LiveArtifactRefreshNotice
|
||
tone="running"
|
||
message={t('liveArtifact.refresh.runningMessage')}
|
||
action={t('liveArtifact.refresh.runningAction')}
|
||
/>
|
||
) : currentRefreshStatus === 'failed' ? (
|
||
<LiveArtifactRefreshNotice
|
||
tone="error"
|
||
message={t('liveArtifact.refresh.previousFailure', { message: t('liveArtifact.refresh.genericFailure') })}
|
||
action={t('liveArtifact.refresh.failureAction')}
|
||
/>
|
||
) : null}
|
||
<div
|
||
className={`live-artifact-preview-layer preview-viewport preview-viewport-${previewViewport}`}
|
||
data-active={mode === 'preview' ? 'true' : 'false'}
|
||
aria-hidden={mode === 'preview' ? undefined : true}
|
||
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
|
||
>
|
||
<div className="preview-frame-clip">
|
||
<div style={previewScaleShellStyle(previewViewport, previewScale)}>
|
||
<PreviewDrawOverlay>
|
||
<iframe
|
||
ref={iframeRef}
|
||
data-testid="live-artifact-preview-frame"
|
||
title={liveArtifact.title}
|
||
sandbox="allow-scripts allow-popups allow-downloads"
|
||
src={previewUrl}
|
||
/>
|
||
</PreviewDrawOverlay>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{mode !== 'preview' && loading ? (
|
||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||
) : mode === 'code' ? (
|
||
<LiveArtifactCodePanel
|
||
projectId={projectId}
|
||
artifactId={liveArtifact.artifactId}
|
||
reloadKey={reloadKey}
|
||
/>
|
||
) : mode === 'data' ? (
|
||
<JsonPanel value={dataPayload} emptyLabel={t('liveArtifact.viewer.dataEmpty')} />
|
||
) : (
|
||
<LiveArtifactRefreshHistoryPanel
|
||
liveArtifact={detail}
|
||
fallbackRefreshStatus={liveArtifact.refreshStatus}
|
||
fallbackLastRefreshedAt={liveArtifact.lastRefreshedAt}
|
||
isRunning={isRunning}
|
||
sessionEvents={refreshEvents}
|
||
persistedEvents={refreshHistory}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LiveArtifactRefreshNotice({
|
||
tone,
|
||
message,
|
||
action,
|
||
onDismiss,
|
||
dismissLabel,
|
||
}: {
|
||
tone: 'running' | 'success' | 'error';
|
||
message: string;
|
||
action: string;
|
||
onDismiss?: () => void;
|
||
dismissLabel?: string;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={`live-artifact-refresh-notice ${tone}`}
|
||
role={tone === 'error' ? 'alert' : 'status'}
|
||
aria-label={`${message} ${action}`}
|
||
>
|
||
<span className="live-artifact-refresh-notice-copy">
|
||
<strong>{message}</strong>
|
||
<span>{action}</span>
|
||
</span>
|
||
{onDismiss ? (
|
||
<button type="button" className="icon-only" onClick={onDismiss} aria-label={dismissLabel}>
|
||
×
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function refreshErrorMessage(error: unknown, t: TranslateFn): string {
|
||
if (error instanceof LiveArtifactRefreshError && error.status === 0) {
|
||
return t('liveArtifact.refresh.networkFailure');
|
||
}
|
||
if (error instanceof LiveArtifactRefreshError && error.code === 'LIVE_ARTIFACT_REFRESH_UNAVAILABLE') {
|
||
return t('liveArtifact.refresh.noSourceTitle');
|
||
}
|
||
if (error instanceof Error && error.message.length > 0) return error.message;
|
||
return t('liveArtifact.refresh.genericFailure');
|
||
}
|
||
|
||
function liveArtifactViewerTabs(t: TranslateFn): Array<{ id: LiveArtifactViewerTab; label: string }> {
|
||
return [
|
||
{ id: 'preview', label: t('liveArtifact.viewer.tabPreview') },
|
||
{ id: 'code', label: t('liveArtifact.viewer.tabCode') },
|
||
{ id: 'data', label: t('liveArtifact.viewer.tabData') },
|
||
{ id: 'refresh-history', label: t('liveArtifact.viewer.tabRefreshHistory') },
|
||
];
|
||
}
|
||
|
||
type LiveArtifactCodeVariant = 'template' | 'rendered-source';
|
||
|
||
function LiveArtifactCodePanel({
|
||
projectId,
|
||
artifactId,
|
||
reloadKey,
|
||
}: {
|
||
projectId: string;
|
||
artifactId: string;
|
||
reloadKey: number;
|
||
}) {
|
||
const t = useT();
|
||
const [variant, setVariant] = useState<LiveArtifactCodeVariant>('template');
|
||
const [code, setCode] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [failed, setFailed] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
setFailed(false);
|
||
setCode(null);
|
||
void fetchLiveArtifactCode(projectId, artifactId, variant).then((next) => {
|
||
if (cancelled) return;
|
||
setCode(next);
|
||
setFailed(next == null);
|
||
setLoading(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [artifactId, projectId, reloadKey, variant]);
|
||
|
||
return (
|
||
<div className="live-artifact-code-panel">
|
||
<div className="live-artifact-code-header">
|
||
<div className="live-artifact-code-copy">
|
||
<strong>
|
||
{variant === 'template'
|
||
? t('liveArtifact.viewer.code.templateHeading')
|
||
: t('liveArtifact.viewer.code.renderedHeading')}
|
||
</strong>
|
||
<span>
|
||
{variant === 'template'
|
||
? t('liveArtifact.viewer.code.templateHelp')
|
||
: t('liveArtifact.viewer.code.renderedHelp')}
|
||
</span>
|
||
</div>
|
||
<div
|
||
className="viewer-tabs live-artifact-code-tabs"
|
||
aria-label={t('liveArtifact.viewer.code.variantAria')}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={`viewer-tab ${variant === 'template' ? 'active' : ''}`}
|
||
onClick={() => setVariant('template')}
|
||
>
|
||
{t('liveArtifact.viewer.code.variantTemplate')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`viewer-tab ${variant === 'rendered-source' ? 'active' : ''}`}
|
||
onClick={() => setVariant('rendered-source')}
|
||
>
|
||
{t('liveArtifact.viewer.code.variantRendered')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{loading ? (
|
||
<div className="viewer-empty">{t('liveArtifact.viewer.code.loading')}</div>
|
||
) : failed ? (
|
||
<div className="viewer-empty">{t('liveArtifact.viewer.code.unavailable')}</div>
|
||
) : code && code.trim().length > 0 ? (
|
||
<pre className="viewer-source">{code}</pre>
|
||
) : (
|
||
<div className="viewer-empty">{t('liveArtifact.viewer.code.empty')}</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function JsonPanel({ value, emptyLabel }: { value: unknown; emptyLabel: string }) {
|
||
if (value == null) return <div className="viewer-empty">{emptyLabel}</div>;
|
||
return <pre className="viewer-source">{JSON.stringify(value, null, 2)}</pre>;
|
||
}
|
||
|
||
function liveArtifactMetadataPayload(liveArtifact: LiveArtifact): unknown {
|
||
return {
|
||
artifact: {
|
||
id: liveArtifact.id,
|
||
title: liveArtifact.title,
|
||
slug: liveArtifact.slug,
|
||
status: liveArtifact.status,
|
||
pinned: liveArtifact.pinned,
|
||
preview: liveArtifact.preview,
|
||
refreshStatus: liveArtifact.refreshStatus,
|
||
createdAt: liveArtifact.createdAt,
|
||
updatedAt: liveArtifact.updatedAt,
|
||
lastRefreshedAt: liveArtifact.lastRefreshedAt,
|
||
},
|
||
document: liveArtifact.document
|
||
? {
|
||
format: liveArtifact.document.format,
|
||
templatePath: liveArtifact.document.templatePath,
|
||
generatedPreviewPath: liveArtifact.document.generatedPreviewPath,
|
||
dataPath: liveArtifact.document.dataPath,
|
||
dataSchemaJson: liveArtifact.document.dataSchemaJson,
|
||
sourceJson: liveArtifact.document.sourceJson,
|
||
}
|
||
: null,
|
||
};
|
||
}
|
||
|
||
function liveArtifactProvenancePayload(liveArtifact: LiveArtifact): unknown {
|
||
return {
|
||
documentSource: liveArtifact.document?.sourceJson ?? null,
|
||
};
|
||
}
|
||
|
||
function liveArtifactRefreshPayload(liveArtifact: LiveArtifact): unknown {
|
||
return {
|
||
refreshStatus: liveArtifact.refreshStatus,
|
||
lastRefreshedAt: liveArtifact.lastRefreshedAt ?? null,
|
||
};
|
||
}
|
||
|
||
type LiveArtifactRefreshStatus = LiveArtifact['refreshStatus'];
|
||
|
||
interface LiveArtifactRefreshEvent {
|
||
id: number;
|
||
phase: 'started' | 'succeeded' | 'failed';
|
||
at: number;
|
||
durationMs?: number;
|
||
refreshedSourceCount?: number;
|
||
error?: string;
|
||
}
|
||
|
||
let refreshEventSequence = 0;
|
||
|
||
function appendRefreshEvent(
|
||
prev: LiveArtifactRefreshEvent[],
|
||
next: Omit<LiveArtifactRefreshEvent, 'id' | 'at' | 'durationMs'>,
|
||
): LiveArtifactRefreshEvent[] {
|
||
const at = Date.now();
|
||
refreshEventSequence += 1;
|
||
const event: LiveArtifactRefreshEvent = { ...next, id: refreshEventSequence, at };
|
||
if (next.phase !== 'started') {
|
||
// Pair with the most recent 'started' to compute duration.
|
||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||
const candidate = prev[i];
|
||
if (candidate && candidate.phase === 'started') {
|
||
event.durationMs = Math.max(0, at - candidate.at);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// Cap at 25 entries to keep the panel lightweight.
|
||
const MAX = 25;
|
||
const combined = [...prev, event];
|
||
return combined.length > MAX ? combined.slice(combined.length - MAX) : combined;
|
||
}
|
||
|
||
function formatAbsoluteDateTime(iso: string | number | undefined): string | null {
|
||
if (iso === undefined || iso === null) return null;
|
||
const date = typeof iso === 'number' ? new Date(iso) : new Date(iso);
|
||
if (Number.isNaN(date.getTime())) return null;
|
||
try {
|
||
return date.toLocaleString(undefined, {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
});
|
||
} catch {
|
||
return date.toISOString();
|
||
}
|
||
}
|
||
|
||
function formatRelativeTime(
|
||
iso: string | number | undefined,
|
||
now = Date.now(),
|
||
locale: Locale = 'en',
|
||
t?: TranslateFn,
|
||
): string | null {
|
||
if (iso === undefined || iso === null) return null;
|
||
const ms = typeof iso === 'number' ? iso : new Date(iso).getTime();
|
||
if (Number.isNaN(ms)) return null;
|
||
const deltaSec = Math.round((ms - now) / 1000);
|
||
const abs = Math.abs(deltaSec);
|
||
if (abs < 5) {
|
||
// "just now" lives in the i18n dict because Intl.RelativeTimeFormat's
|
||
// "0 seconds ago" reads awkwardly in narrow style and we want a
|
||
// single canonical translation per locale. Fall back to the English
|
||
// literal only when called without t (background utilities, tests).
|
||
return t ? t('liveArtifact.refresh.justNow') : 'just now';
|
||
}
|
||
// Intl.RelativeTimeFormat handles tense (past / future), pluralisation,
|
||
// and word-order per locale so the panel matches the rest of the
|
||
// localised UI instead of mixing in English units like `5s ago`.
|
||
// `style: 'narrow'` keeps the English output close to the historical
|
||
// `5s ago` shape; `numeric: 'always'` forces numeric output so we
|
||
// don't get "yesterday" / "now" mixed in unexpectedly with the
|
||
// bucketing above.
|
||
let rtf: Intl.RelativeTimeFormat;
|
||
try {
|
||
rtf = new Intl.RelativeTimeFormat(locale, { style: 'narrow', numeric: 'always' });
|
||
} catch {
|
||
rtf = new Intl.RelativeTimeFormat('en', { style: 'narrow', numeric: 'always' });
|
||
}
|
||
const value = deltaSec; // negative = past, positive = future
|
||
if (abs < 60) return rtf.format(value, 'second');
|
||
if (abs < 3600) return rtf.format(Math.round(value / 60), 'minute');
|
||
if (abs < 86400) return rtf.format(Math.round(value / 3600), 'hour');
|
||
if (abs < 86400 * 30) return rtf.format(Math.round(value / 86400), 'day');
|
||
if (abs < 86400 * 365) return rtf.format(Math.round(value / (86400 * 30)), 'month');
|
||
return rtf.format(Math.round(value / (86400 * 365)), 'year');
|
||
}
|
||
|
||
function formatDurationMs(ms: number | undefined): string | null {
|
||
if (ms === undefined || ms === null || Number.isNaN(ms)) return null;
|
||
if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
|
||
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
|
||
const minutes = Math.floor(ms / 60_000);
|
||
const seconds = Math.round((ms % 60_000) / 1000);
|
||
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
||
}
|
||
|
||
function exportReadyNudgeKey(projectId: string, fileName: string): string {
|
||
return `${EXPORT_READY_NUDGE_STORAGE_PREFIX}${projectId}:${fileName}`;
|
||
}
|
||
|
||
function hasSeenExportReadyNudge(projectId: string, fileName: string): boolean {
|
||
try {
|
||
return window.sessionStorage.getItem(exportReadyNudgeKey(projectId, fileName)) === '1';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function markExportReadyNudgeSeen(projectId: string, fileName: string) {
|
||
try {
|
||
window.sessionStorage.setItem(exportReadyNudgeKey(projectId, fileName), '1');
|
||
} catch {
|
||
// Ignore storage-denied contexts; the in-memory state still prevents loops.
|
||
}
|
||
}
|
||
|
||
interface RefreshStatusDescriptor {
|
||
label: string;
|
||
tone: 'neutral' | 'running' | 'success' | 'warning' | 'error';
|
||
description: string;
|
||
}
|
||
|
||
function describeRefreshStatus(
|
||
status: LiveArtifactRefreshStatus,
|
||
t: TranslateFn,
|
||
): RefreshStatusDescriptor {
|
||
switch (status) {
|
||
case 'running':
|
||
return {
|
||
label: t('liveArtifact.refresh.statusRunning'),
|
||
tone: 'running',
|
||
description: t('liveArtifact.refresh.statusRunningDescription'),
|
||
};
|
||
case 'succeeded':
|
||
return {
|
||
label: t('liveArtifact.refresh.statusSucceeded'),
|
||
tone: 'success',
|
||
description: t('liveArtifact.refresh.statusSucceededDescription'),
|
||
};
|
||
case 'failed':
|
||
return {
|
||
label: t('liveArtifact.refresh.statusFailed'),
|
||
tone: 'error',
|
||
description: t('liveArtifact.refresh.statusFailedDescription'),
|
||
};
|
||
case 'idle':
|
||
return {
|
||
label: t('liveArtifact.refresh.statusReady'),
|
||
tone: 'neutral',
|
||
description: t('liveArtifact.refresh.statusReadyDescription'),
|
||
};
|
||
case 'never':
|
||
default:
|
||
return {
|
||
label: t('liveArtifact.refresh.statusNever'),
|
||
tone: 'warning',
|
||
description: t('liveArtifact.refresh.statusNeverDescription'),
|
||
};
|
||
}
|
||
}
|
||
|
||
function describeEventPhase(
|
||
event: LiveArtifactRefreshEvent,
|
||
t: TranslateFn,
|
||
): { label: string; tone: 'running' | 'success' | 'error' } {
|
||
if (event.phase === 'started')
|
||
return { label: t('liveArtifact.refresh.eventStarted'), tone: 'running' };
|
||
if (event.phase === 'succeeded')
|
||
return { label: t('liveArtifact.refresh.eventSucceeded'), tone: 'success' };
|
||
return { label: t('liveArtifact.refresh.eventFailed'), tone: 'error' };
|
||
}
|
||
|
||
function describePersistedStatus(
|
||
status: LiveArtifactRefreshLogEntry['status'],
|
||
t: TranslateFn,
|
||
): string {
|
||
switch (status) {
|
||
case 'succeeded':
|
||
return t('liveArtifact.refresh.persistedStatusSucceeded');
|
||
case 'running':
|
||
return t('liveArtifact.refresh.persistedStatusRunning');
|
||
case 'failed':
|
||
return t('liveArtifact.refresh.persistedStatusFailed');
|
||
case 'cancelled':
|
||
return t('liveArtifact.refresh.persistedStatusCancelled');
|
||
case 'skipped':
|
||
return t('liveArtifact.refresh.persistedStatusSkipped');
|
||
default: {
|
||
const exhaustive: never = status;
|
||
return exhaustive;
|
||
}
|
||
}
|
||
}
|
||
|
||
export function LiveArtifactRefreshHistoryPanel({
|
||
liveArtifact,
|
||
fallbackRefreshStatus,
|
||
fallbackLastRefreshedAt,
|
||
isRunning,
|
||
sessionEvents,
|
||
persistedEvents = [],
|
||
}: {
|
||
liveArtifact: LiveArtifact | null;
|
||
fallbackRefreshStatus: LiveArtifactRefreshStatus;
|
||
fallbackLastRefreshedAt?: string;
|
||
isRunning: boolean;
|
||
sessionEvents: LiveArtifactRefreshEvent[];
|
||
persistedEvents?: LiveArtifactRefreshLogEntry[];
|
||
}) {
|
||
const t = useT();
|
||
const { locale } = useI18n();
|
||
const [now, setNow] = useState(() => Date.now());
|
||
|
||
useEffect(() => {
|
||
// Keep relative timestamps fresh; 30s cadence is enough for "x minutes ago" feel.
|
||
const id = window.setInterval(() => setNow(Date.now()), 30_000);
|
||
return () => window.clearInterval(id);
|
||
}, []);
|
||
|
||
const status: LiveArtifactRefreshStatus = isRunning
|
||
? 'running'
|
||
: liveArtifact?.refreshStatus ?? fallbackRefreshStatus;
|
||
const descriptor = describeRefreshStatus(status, t);
|
||
const lastRefreshedAt = liveArtifact?.lastRefreshedAt ?? fallbackLastRefreshedAt;
|
||
const createdAt = liveArtifact?.createdAt;
|
||
const updatedAt = liveArtifact?.updatedAt;
|
||
const documentSource = liveArtifact?.document?.sourceJson ?? null;
|
||
const reversedEvents = [...sessionEvents].reverse();
|
||
const reversedPersistedEvents = [...persistedEvents].reverse().slice(0, 25);
|
||
const rawDebugPayload = liveArtifact
|
||
? {
|
||
refresh: liveArtifactRefreshPayload(liveArtifact),
|
||
metadata: liveArtifactMetadataPayload(liveArtifact),
|
||
provenance: liveArtifactProvenancePayload(liveArtifact),
|
||
}
|
||
: null;
|
||
|
||
return (
|
||
<div className="live-artifact-refresh-panel">
|
||
<section className="live-artifact-refresh-hero">
|
||
<div className="live-artifact-refresh-hero-main">
|
||
<span
|
||
className={`live-artifact-badge refresh-status tone-${descriptor.tone}`}
|
||
data-testid="live-artifact-refresh-status-badge"
|
||
>
|
||
{descriptor.label}
|
||
</span>
|
||
<p className="live-artifact-refresh-hero-desc">{descriptor.description}</p>
|
||
</div>
|
||
<div className="live-artifact-refresh-hero-meta">
|
||
<div className="live-artifact-refresh-hero-metric">
|
||
<span className="live-artifact-refresh-label">
|
||
{t('liveArtifact.refresh.heroLastRefreshedLabel')}
|
||
</span>
|
||
{lastRefreshedAt ? (
|
||
<>
|
||
<span className="live-artifact-refresh-value">
|
||
{formatRelativeTime(lastRefreshedAt, now, locale, t) ?? '—'}
|
||
</span>
|
||
<span
|
||
className="live-artifact-refresh-sub"
|
||
title={formatAbsoluteDateTime(lastRefreshedAt) ?? undefined}
|
||
>
|
||
{formatAbsoluteDateTime(lastRefreshedAt) ?? ''}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<span className="live-artifact-refresh-value muted">
|
||
{t('liveArtifact.refresh.heroLastRefreshedNever')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="live-artifact-refresh-facts">
|
||
<LiveArtifactRefreshFact
|
||
label={t('liveArtifact.refresh.factCreated')}
|
||
iso={createdAt}
|
||
emptyLabel={t('liveArtifact.refresh.factUnknown')}
|
||
now={now}
|
||
locale={locale}
|
||
t={t}
|
||
/>
|
||
<LiveArtifactRefreshFact
|
||
label={t('liveArtifact.refresh.factLastUpdated')}
|
||
iso={updatedAt}
|
||
emptyLabel={t('liveArtifact.refresh.factUnknown')}
|
||
now={now}
|
||
locale={locale}
|
||
t={t}
|
||
/>
|
||
</section>
|
||
|
||
<section className="live-artifact-refresh-section">
|
||
<header className="live-artifact-refresh-section-header">
|
||
<h4>{t('liveArtifact.refresh.persistedTitle')}</h4>
|
||
<span className="live-artifact-refresh-hint">
|
||
{t('liveArtifact.refresh.persistedHint')}
|
||
</span>
|
||
</header>
|
||
{reversedPersistedEvents.length === 0 ? (
|
||
<div className="live-artifact-refresh-empty">
|
||
{t('liveArtifact.refresh.persistedEmpty')}
|
||
</div>
|
||
) : (
|
||
<ol className="live-artifact-refresh-timeline">
|
||
{reversedPersistedEvents.map((event) => {
|
||
const tone = event.status === 'succeeded'
|
||
? 'success'
|
||
: event.status === 'running'
|
||
? 'running'
|
||
: event.status === 'failed' || event.status === 'cancelled'
|
||
? 'error'
|
||
: 'running';
|
||
const duration = formatDurationMs(event.durationMs);
|
||
return (
|
||
<li key={`${event.refreshId}:${event.sequence}`} className={`live-artifact-refresh-event tone-${tone}`}>
|
||
<span className="live-artifact-refresh-event-dot" aria-hidden />
|
||
<div className="live-artifact-refresh-event-body">
|
||
<div className="live-artifact-refresh-event-row">
|
||
<span className={`live-artifact-badge refresh-status tone-${tone}`}>
|
||
{describePersistedStatus(event.status, t)}
|
||
</span>
|
||
<strong>{event.step}</strong>
|
||
<span className="live-artifact-refresh-event-time">
|
||
{formatRelativeTime(event.startedAt, now, locale, t)
|
||
?? t('liveArtifact.refresh.justNow')}
|
||
</span>
|
||
</div>
|
||
<div className="live-artifact-refresh-event-meta">
|
||
<span>{event.refreshId}</span>
|
||
{duration ? <span>{duration}</span> : null}
|
||
{event.error?.message ? <span>{event.error.message}</span> : null}
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
)}
|
||
</section>
|
||
|
||
<section className="live-artifact-refresh-section">
|
||
<header className="live-artifact-refresh-section-header">
|
||
<h4>{t('liveArtifact.refresh.sessionTitle')}</h4>
|
||
<span className="live-artifact-refresh-hint">
|
||
{t('liveArtifact.refresh.sessionHint')}
|
||
</span>
|
||
</header>
|
||
{reversedEvents.length === 0 ? (
|
||
<div className="live-artifact-refresh-empty">
|
||
{t('liveArtifact.refresh.timelineEmpty')}
|
||
</div>
|
||
) : (
|
||
<ol className="live-artifact-refresh-timeline">
|
||
{reversedEvents.map((event) => {
|
||
const phase = describeEventPhase(event, t);
|
||
const duration = formatDurationMs(event.durationMs);
|
||
const refreshedCount = event.refreshedSourceCount ?? 0;
|
||
return (
|
||
<li key={event.id} className={`live-artifact-refresh-event tone-${phase.tone}`}>
|
||
<span className="live-artifact-refresh-event-dot" aria-hidden />
|
||
<div className="live-artifact-refresh-event-body">
|
||
<div className="live-artifact-refresh-event-row">
|
||
<span
|
||
className={`live-artifact-badge refresh-status tone-${phase.tone}`}
|
||
>
|
||
{phase.label}
|
||
</span>
|
||
<span
|
||
className="live-artifact-refresh-event-time"
|
||
title={formatAbsoluteDateTime(event.at) ?? undefined}
|
||
>
|
||
{formatRelativeTime(event.at, now, locale, t) ?? ''}
|
||
</span>
|
||
</div>
|
||
<div className="live-artifact-refresh-event-detail">
|
||
{event.phase === 'succeeded' ? (
|
||
<span>
|
||
{t(
|
||
refreshedCount === 1
|
||
? 'liveArtifact.refresh.sourcesUpdatedOne'
|
||
: 'liveArtifact.refresh.sourcesUpdatedMany',
|
||
{ n: refreshedCount },
|
||
)}
|
||
{duration ? ` · ${duration}` : ''}
|
||
</span>
|
||
) : event.phase === 'failed' ? (
|
||
<span>
|
||
{event.error ?? t('liveArtifact.refresh.genericFailure')}
|
||
{duration ? ` · ${duration}` : ''}
|
||
</span>
|
||
) : (
|
||
<span>{t('liveArtifact.refresh.eventStartedDetail')}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
)}
|
||
</section>
|
||
|
||
{documentSource ? (
|
||
<section className="live-artifact-refresh-section">
|
||
<header className="live-artifact-refresh-section-header">
|
||
<h4>{t('liveArtifact.refresh.docSourceTitle')}</h4>
|
||
<span className="live-artifact-refresh-hint">
|
||
{t('liveArtifact.refresh.docSourceHint')}
|
||
</span>
|
||
</header>
|
||
<dl className="live-artifact-refresh-kv">
|
||
<div>
|
||
<dt>{t('liveArtifact.refresh.docSourceType')}</dt>
|
||
<dd>{documentSource.type}</dd>
|
||
</div>
|
||
{documentSource.toolName ? (
|
||
<div>
|
||
<dt>{t('liveArtifact.refresh.docSourceTool')}</dt>
|
||
<dd>
|
||
<code>{documentSource.toolName}</code>
|
||
</dd>
|
||
</div>
|
||
) : null}
|
||
{documentSource.connector ? (
|
||
<div>
|
||
<dt>{t('liveArtifact.refresh.docSourceConnector')}</dt>
|
||
<dd>
|
||
{documentSource.connector.accountLabel ??
|
||
documentSource.connector.connectorId}
|
||
</dd>
|
||
</div>
|
||
) : null}
|
||
</dl>
|
||
</section>
|
||
) : null}
|
||
|
||
{rawDebugPayload != null ? (
|
||
<details className="live-artifact-refresh-raw">
|
||
<summary>{t('liveArtifact.refresh.debugSummary')}</summary>
|
||
<p className="live-artifact-refresh-raw-note">
|
||
{t('liveArtifact.refresh.debugNote')}
|
||
</p>
|
||
<pre className="viewer-source">{JSON.stringify(rawDebugPayload, null, 2)}</pre>
|
||
</details>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LiveArtifactRefreshFact({
|
||
label,
|
||
iso,
|
||
value,
|
||
helper,
|
||
emptyLabel,
|
||
now,
|
||
locale,
|
||
t,
|
||
}: {
|
||
label: string;
|
||
iso?: string;
|
||
value?: string;
|
||
helper?: string;
|
||
emptyLabel?: string;
|
||
now?: number;
|
||
locale?: Locale;
|
||
t?: TranslateFn;
|
||
}) {
|
||
const relative = iso !== undefined ? formatRelativeTime(iso, now, locale, t) : null;
|
||
const absolute = iso !== undefined ? formatAbsoluteDateTime(iso) : null;
|
||
const resolved = value ?? relative ?? emptyLabel ?? '—';
|
||
const sub = helper ?? (iso !== undefined ? absolute ?? '' : '');
|
||
return (
|
||
<div className="live-artifact-refresh-fact">
|
||
<span className="live-artifact-refresh-label">{label}</span>
|
||
<span className="live-artifact-refresh-value" title={absolute ?? undefined}>
|
||
{resolved}
|
||
</span>
|
||
{sub ? <span className="live-artifact-refresh-sub">{sub}</span> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FileActions({
|
||
projectId,
|
||
file,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
}) {
|
||
const t = useT();
|
||
return (
|
||
<div className="viewer-toolbar-actions">
|
||
<a
|
||
className="ghost-link"
|
||
href={projectFileUrl(projectId, file.name)}
|
||
download={file.name}
|
||
>
|
||
{t('fileViewer.download')}
|
||
</a>
|
||
<a
|
||
className="ghost-link"
|
||
href={projectFileUrl(projectId, file.name)}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
>
|
||
{t('fileViewer.open')}
|
||
</a>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatCommentTime(ts: number, t: TranslateFn): string {
|
||
const diff = Date.now() - ts;
|
||
if (diff < 60_000) return t('common.justNow');
|
||
const mins = Math.floor(diff / 60_000);
|
||
if (mins < 60) return t('common.minutesAgo', { n: mins });
|
||
const hours = Math.floor(mins / 60);
|
||
if (hours < 24) return t('common.hoursAgo', { n: hours });
|
||
const days = Math.floor(hours / 24);
|
||
if (days < 7) return t('common.daysAgo', { n: days });
|
||
const weeks = Math.floor(days / 7);
|
||
if (weeks < 5) return t('common.weeksAgo', { n: weeks });
|
||
return new Date(ts).toLocaleDateString();
|
||
}
|
||
|
||
function commentActivityAt(comment: PreviewComment): number {
|
||
return Math.max(
|
||
Number.isFinite(comment.updatedAt) ? comment.updatedAt : 0,
|
||
Number.isFinite(comment.createdAt) ? comment.createdAt : 0,
|
||
);
|
||
}
|
||
|
||
function commentTargetIntersectsPreview(
|
||
target: PreviewCommentSnapshot | null,
|
||
scale: number,
|
||
offset: { x: number; y: number },
|
||
bounds?: PreviewCanvasSize,
|
||
): boolean {
|
||
if (!target || !bounds?.width || !bounds.height) return true;
|
||
const rect = overlayBoundsFromSnapshot(target, scale, offset);
|
||
const margin = 8;
|
||
return (
|
||
rect.left + rect.width > margin &&
|
||
rect.top + rect.height > margin &&
|
||
rect.left < bounds.width - margin &&
|
||
rect.top < bounds.height - margin
|
||
);
|
||
}
|
||
|
||
function commentDisplayLabel(comment: PreviewComment, t: TranslateFn): string {
|
||
if (comment.elementId.startsWith('pin-')) return t('chat.comments.pin');
|
||
const label = String(comment.label || '').trim().toLowerCase();
|
||
const htmlHint = String(comment.htmlHint || '').trim().toLowerCase();
|
||
const elementId = String(comment.elementId || '').trim().toLowerCase();
|
||
const source = `${label} ${htmlHint} ${elementId}`;
|
||
if (/\b(?:img|picture|video|canvas|svg)\b/.test(source)) return t('chat.comments.targetImage');
|
||
if (/\b(?:button|input|textarea|select|label)\b/.test(source)) return t('chat.comments.targetControl');
|
||
if (/^<a\b/.test(htmlHint)) return t('chat.comments.targetLink');
|
||
if (/\b(?:h1|h2|h3|h4|h5|h6|p|span|strong|em|small|li|dt|dd)\b/.test(source)) return t('chat.comments.targetText');
|
||
if (/\b(?:section|main|header|footer|nav|article|aside)\b/.test(source)) return t('chat.comments.targetSection');
|
||
if (label.endsWith('.html') || elementId.startsWith('file-comment-')) return t('chat.comments.targetPage');
|
||
if (comment.text.trim()) return t('chat.comments.targetText');
|
||
return t('chat.comments.targetArea');
|
||
}
|
||
|
||
export function CommentSidePanel({
|
||
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;
|
||
}) {
|
||
const [newCommentDraft, setNewCommentDraft] = useState('');
|
||
const sorted = [...comments].sort((a, b) => commentActivityAt(b) - commentActivityAt(a));
|
||
const visibleSelectedIds = new Set(comments.filter((comment) => selectedIds.has(comment.id)).map((comment) => comment.id));
|
||
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={() => handleCollapsedChange(false, 'expanded')}
|
||
>
|
||
<RemixIcon name="message-3-line" size={15} />
|
||
<span>{commentsLabel}</span>
|
||
{comments.length > 0 ? <strong>{comments.length}</strong> : null}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<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} />
|
||
<span>{commentsLabel}</span>
|
||
</div>
|
||
<div className="comment-side-header-actions">
|
||
{comments.length > 0 ? (
|
||
<button
|
||
type="button"
|
||
className="comment-side-select-all"
|
||
disabled={allSelected}
|
||
onClick={onSelectAll}
|
||
>
|
||
{t('chat.comments.selectAll')}
|
||
</button>
|
||
) : null}
|
||
<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">
|
||
{sorted.length === 0 ? (
|
||
<div className="comment-side-empty">
|
||
{t('chat.comments.emptySaved')}
|
||
</div>
|
||
) : sorted.map((comment, index) => {
|
||
const selected = visibleSelectedIds.has(comment.id);
|
||
const active = comment.id === activeCommentId;
|
||
return (
|
||
<div
|
||
key={comment.id}
|
||
className={`comment-side-item${selected ? ' selected' : ''}${active ? ' active' : ''}`}
|
||
data-testid="comment-side-item"
|
||
data-comment-id={comment.id}
|
||
aria-current={active ? 'true' : undefined}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => onReply(comment)}
|
||
onKeyDown={(event) => {
|
||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||
event.preventDefault();
|
||
onReply(comment);
|
||
}}
|
||
>
|
||
<div className="comment-side-item-head">
|
||
<span className="comment-side-author">
|
||
<strong>{`${index + 1}. ${commentDisplayLabel(comment, t)}`}</strong>
|
||
</span>
|
||
<span className="comment-side-time">{formatCommentTime(commentActivityAt(comment), t)}</span>
|
||
<button
|
||
type="button"
|
||
className={`comment-side-check${selected ? ' checked' : ''}`}
|
||
aria-label={selected ? t('chat.comments.deselect') : t('chat.comments.select')}
|
||
aria-pressed={selected}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onToggleSelect(comment.id);
|
||
}}
|
||
>
|
||
{selected ? <Icon name="check" size={11} /> : null}
|
||
</button>
|
||
</div>
|
||
<div className="comment-side-body">{comment.note}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{selectedCount > 0 ? (
|
||
<div className="comment-side-selectbar" data-testid="comment-side-selectbar">
|
||
<span className="comment-side-selectcount">{t('chat.comments.nSelected', { n: selectedCount })}</span>
|
||
<button type="button" className="ghost" onClick={onClearSelection}>
|
||
{t('chat.comments.clear')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="primary"
|
||
data-testid="comment-side-send-claude"
|
||
disabled={sending}
|
||
onClick={() => void onSendSelected()}
|
||
>
|
||
{sending ? t('chat.comments.sending') : t('chat.comments.sendToChat')}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
{composer ? <div className="comment-side-composer">{composer}</div> : null}
|
||
{onCreateComment ? (
|
||
<form
|
||
className="comment-side-new-comment composer"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
void submitNewComment();
|
||
}}
|
||
>
|
||
<div className="composer-shell comment-side-new-comment-shell">
|
||
<div className="composer-input-wrap">
|
||
<div className="composer-textarea-layer">
|
||
<textarea
|
||
value={newCommentDraft}
|
||
placeholder={t('chat.comments.placeholder')}
|
||
aria-label={t('chat.comments.placeholder')}
|
||
onChange={(event) => setNewCommentDraft(event.target.value)}
|
||
onKeyDown={(event) => {
|
||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||
event.preventDefault();
|
||
void submitNewComment();
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="composer-row comment-side-new-comment-actions">
|
||
<button
|
||
type="button"
|
||
className="icon-btn"
|
||
title={t('chat.cliSettingsTitle')}
|
||
aria-label={t('chat.cliSettingsAria')}
|
||
disabled
|
||
>
|
||
<span className="composer-tools-at" aria-hidden>
|
||
@
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="icon-btn"
|
||
title={t('chat.attachTitle')}
|
||
aria-label={t('chat.attachAria')}
|
||
disabled
|
||
>
|
||
<Icon name="attach" size={15} />
|
||
</button>
|
||
<span className="composer-spacer" />
|
||
<button
|
||
type="submit"
|
||
className={`composer-send${sending ? ' is-sending' : ''}`}
|
||
disabled={!canCreateComment}
|
||
>
|
||
<Icon name="send" size={13} />
|
||
<span>{sending ? t('chat.comments.sending') : t('chat.send')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
) : null}
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
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
|
||
// units for slider binding and re-append on emit.
|
||
//
|
||
// Note: <input type=color> has no alpha channel, so an rgba() with alpha < 1
|
||
// is collapsed to its opaque RGB equivalent here. Most agent-generated HTML
|
||
// uses opaque colors, so this is a known cosmetic limitation — a
|
||
// semi-transparent source value will display in the panel as fully opaque.
|
||
function rgbToHex(value: string | undefined): string {
|
||
if (!value) return '#000000';
|
||
const v = value.trim();
|
||
if (v.startsWith('#') && (v.length === 7 || v.length === 4)) {
|
||
if (v.length === 4) {
|
||
return '#' + [1, 2, 3].map((i) => {
|
||
const c = v.charAt(i);
|
||
return c + c;
|
||
}).join('');
|
||
}
|
||
return v;
|
||
}
|
||
const m = v.match(/rgba?\(\s*([0-9.]+)[ ,]+([0-9.]+)[ ,]+([0-9.]+)/i);
|
||
if (!m) return '#000000';
|
||
const toHex = (n: string) => {
|
||
const x = Math.max(0, Math.min(255, Math.round(Number(n))));
|
||
return x.toString(16).padStart(2, '0');
|
||
};
|
||
return '#' + toHex(m[1] ?? '0') + toHex(m[2] ?? '0') + toHex(m[3] ?? '0');
|
||
}
|
||
|
||
// Parse a CSS length to a number. Inspect's current sliders all clamp to a
|
||
// non-negative range (padding, font-size, border-radius), so we reject
|
||
// negatives at parse time too — otherwise a `-12px` source value would be
|
||
// silently floored to 0 by the slider clamp without the regex agreeing.
|
||
// If a future control needs negative values (e.g. margin), thread an
|
||
// explicit `allowNegative` flag rather than reintroducing `-?` here.
|
||
function pxToNumber(value: string | undefined): number {
|
||
if (!value) return 0;
|
||
const m = value.trim().match(/^(\d+(?:\.\d+)?)/);
|
||
return m ? Number(m[1]) : 0;
|
||
}
|
||
|
||
function clamp(n: number, lo: number, hi: number): number {
|
||
return Math.max(lo, Math.min(hi, n));
|
||
}
|
||
|
||
function InspectPanel({
|
||
target,
|
||
onApply,
|
||
onResetElement,
|
||
onSaveToSource,
|
||
onClose,
|
||
saving,
|
||
savedAt,
|
||
error,
|
||
}: {
|
||
target: InspectTarget;
|
||
onApply: (prop: string, value: string) => void;
|
||
onResetElement: (elementId: string) => void;
|
||
onSaveToSource: () => void;
|
||
onClose: () => void;
|
||
saving: boolean;
|
||
savedAt: number | null;
|
||
error: string | null;
|
||
}) {
|
||
// Local "draft" mirror of the most recent value the user picked, so
|
||
// sliders/colors keep responding even before the iframe echoes back the
|
||
// computed result. Reset whenever the selected element changes.
|
||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||
useEffect(() => {
|
||
setDraft({});
|
||
}, [target.elementId]);
|
||
|
||
const value = (prop: string, fallback: string): string =>
|
||
draft[prop] ?? fallback;
|
||
|
||
function setVal(prop: string, raw: string) {
|
||
setDraft((d) => ({ ...d, [prop]: raw }));
|
||
onApply(prop, raw);
|
||
}
|
||
|
||
// Padding is exposed as a single shared slider that emits the `padding`
|
||
// shorthand; the browser fans the value out to all four sides internally.
|
||
// When per-side control becomes useful, switch to emitting explicit
|
||
// padding-top / padding-right / padding-bottom / padding-left props
|
||
// (the bridge already allow-lists those long-hand names).
|
||
const initialPadding = pxToNumber(target.style.paddingTop);
|
||
const initialFontSize = pxToNumber(target.style.fontSize);
|
||
const initialRadius = pxToNumber(target.style.borderRadius);
|
||
|
||
// Color / length controls all read through `draft` first so the input
|
||
// tracks the most recent user pick even before getComputedStyle catches
|
||
// up. Without this the picker would snap back to the initial computed
|
||
// snapshot on every change and feel non-editable.
|
||
const colorHex = value('color', rgbToHex(target.style.color));
|
||
const bgHex = value('background-color', rgbToHex(target.style.backgroundColor));
|
||
const padding = value('padding', String(initialPadding));
|
||
const fontSize = value('font-size', String(initialFontSize));
|
||
const radius = value('border-radius', String(initialRadius));
|
||
const textAlign = value('text-align', target.style.textAlign || 'left');
|
||
const fontWeight = value('font-weight', target.style.fontWeight || '400');
|
||
// Parse once: `pxToNumber(...) || initial...` would treat a legitimate
|
||
// `0px` draft as missing and snap the slider back to the original
|
||
// computed value, making it impossible to remove padding/radius from an
|
||
// element whose initial value is nonzero. `pxToNumber` already returns
|
||
// 0 for unparseable input, so its result is safe to consume directly
|
||
// and zero is preserved.
|
||
const paddingNum = pxToNumber(padding);
|
||
const fontSizeNum = pxToNumber(fontSize);
|
||
const radiusNum = pxToNumber(radius);
|
||
|
||
const justSaved = savedAt && Date.now() - savedAt < 4000;
|
||
|
||
return (
|
||
<aside className="inspect-panel" data-testid="inspect-panel">
|
||
<header className="inspect-panel-head">
|
||
<div className="inspect-panel-title">
|
||
<strong title={target.label || target.elementId}>{target.label || target.elementId}</strong>
|
||
<code title={target.selector}>{target.elementId}</code>
|
||
</div>
|
||
<button type="button" className="ghost" onClick={onClose} aria-label="Close inspect">
|
||
×
|
||
</button>
|
||
</header>
|
||
|
||
{target.clickedDescendant ? (
|
||
<div className="inspect-ancestor-notice" data-testid="inspect-ancestor-notice">
|
||
<div className="inspect-ancestor-notice-icon" aria-hidden>
|
||
i
|
||
</div>
|
||
<div className="inspect-ancestor-notice-text">
|
||
You clicked <strong>{target.clickedDescendant.label}</strong>
|
||
{target.clickedDescendant.text
|
||
? ` ("${target.clickedDescendant.text.slice(0, 40)}${target.clickedDescendant.text.length > 40 ? '...' : ''}")`
|
||
: ''}
|
||
, but it has no <code>data-od-id</code> annotation. Editing{' '}
|
||
<strong>{target.label || target.elementId}</strong> instead, the nearest annotated ancestor.
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<section className="inspect-section">
|
||
<div className="inspect-section-label">Colors</div>
|
||
<div className="inspect-row">
|
||
<label htmlFor="ip-color">Text</label>
|
||
<input
|
||
id="ip-color"
|
||
data-testid="inspect-color"
|
||
type="color"
|
||
value={colorHex}
|
||
onChange={(e) => setVal('color', e.target.value)}
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={colorHex}
|
||
onChange={(e) => setVal('color', e.target.value)}
|
||
spellCheck={false}
|
||
/>
|
||
</div>
|
||
<div className="inspect-row">
|
||
<label htmlFor="ip-bg">Background</label>
|
||
<input
|
||
id="ip-bg"
|
||
data-testid="inspect-bg"
|
||
type="color"
|
||
value={bgHex}
|
||
onChange={(e) => setVal('background-color', e.target.value)}
|
||
/>
|
||
<input
|
||
type="text"
|
||
value={bgHex}
|
||
onChange={(e) => setVal('background-color', e.target.value)}
|
||
spellCheck={false}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="inspect-section">
|
||
<div className="inspect-section-label">Typography</div>
|
||
<div className="inspect-row">
|
||
<label htmlFor="ip-fs">Size</label>
|
||
<input
|
||
id="ip-fs"
|
||
data-testid="inspect-font-size"
|
||
type="range"
|
||
min={8}
|
||
max={160}
|
||
step={1}
|
||
value={clamp(fontSizeNum, 8, 160)}
|
||
onChange={(e) => setVal('font-size', `${e.target.value}px`)}
|
||
/>
|
||
<span className="inspect-row-value">{Math.round(fontSizeNum)}px</span>
|
||
</div>
|
||
<div className="inspect-row">
|
||
<label htmlFor="ip-fw">Weight</label>
|
||
<select
|
||
id="ip-fw"
|
||
value={fontWeight}
|
||
onChange={(e) => setVal('font-weight', e.target.value)}
|
||
>
|
||
{['100', '300', '400', '500', '600', '700', '800', '900'].map((w) => (
|
||
<option key={w} value={w}>{w}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="inspect-row">
|
||
<label htmlFor="ip-ta">Align</label>
|
||
<select
|
||
id="ip-ta"
|
||
value={textAlign}
|
||
onChange={(e) => setVal('text-align', e.target.value)}
|
||
>
|
||
{['left', 'center', 'right', 'justify'].map((a) => (
|
||
<option key={a} value={a}>{a}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="inspect-section">
|
||
<div className="inspect-section-label">Spacing & Shape</div>
|
||
<div className="inspect-row">
|
||
<label htmlFor="ip-pad">Padding</label>
|
||
<input
|
||
id="ip-pad"
|
||
data-testid="inspect-padding"
|
||
type="range"
|
||
min={0}
|
||
max={120}
|
||
step={1}
|
||
value={clamp(paddingNum, 0, 120)}
|
||
onChange={(e) => setVal('padding', `${e.target.value}px`)}
|
||
/>
|
||
<span className="inspect-row-value">{Math.round(paddingNum)}px</span>
|
||
</div>
|
||
<div className="inspect-row">
|
||
<label htmlFor="ip-rad">Radius</label>
|
||
<input
|
||
id="ip-rad"
|
||
data-testid="inspect-radius"
|
||
type="range"
|
||
min={0}
|
||
max={120}
|
||
step={1}
|
||
value={clamp(radiusNum, 0, 120)}
|
||
onChange={(e) => setVal('border-radius', `${e.target.value}px`)}
|
||
/>
|
||
<span className="inspect-row-value">{Math.round(radiusNum)}px</span>
|
||
</div>
|
||
</section>
|
||
|
||
<footer className="inspect-panel-footer">
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
onClick={() => {
|
||
setDraft({});
|
||
onResetElement(target.elementId);
|
||
}}
|
||
>
|
||
Reset element
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="primary"
|
||
data-testid="inspect-save"
|
||
disabled={saving}
|
||
onClick={onSaveToSource}
|
||
>
|
||
{saving ? 'Saving…' : justSaved ? 'Saved ✓' : 'Save to source'}
|
||
</button>
|
||
</footer>
|
||
{error ? <div className="inspect-panel-error">{error}</div> : null}
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
// Inspect-mode override entry as held in the host's authoritative map and as
|
||
// it travels in od:inspect-overrides messages. The host's persisted map is
|
||
// owned and mutated only by host-driven onApply / reset actions plus the
|
||
// initial parse of the source's <style data-od-inspect-overrides> block;
|
||
// inbound iframe messages are treated as preview acknowledgements, never as
|
||
// save input. Artifact code rendered with scripts enabled can call
|
||
// window.parent.postMessage with a forged payload — ev.source still points
|
||
// at iframe.contentWindow — so any field arriving from the iframe is
|
||
// untrusted. Even the structured `overrides` field could be tampered with
|
||
// to flip allow-listed properties on elements the user never edited, which
|
||
// is why we no longer ingest it on save.
|
||
type InspectOverridePayload = {
|
||
selector?: unknown;
|
||
props?: unknown;
|
||
};
|
||
|
||
// Authoritative host-side override map: elementId → { selector, props }.
|
||
// Mirrors the in-iframe shape so serializeInspectOverrides can consume it.
|
||
export type InspectOverrideEntry = {
|
||
selector: string;
|
||
props: Record<string, string>;
|
||
};
|
||
export type InspectOverrideMap = Record<string, InspectOverrideEntry>;
|
||
|
||
// Allow-list of CSS properties the host will persist on Save. Mirrors the
|
||
// in-iframe ALLOWED_PROPS list so the host doesn't accept properties that
|
||
// the bridge itself would reject.
|
||
const HOST_ALLOWED_INSPECT_PROPS = new Set([
|
||
'color',
|
||
'background-color',
|
||
'font-size',
|
||
'font-weight',
|
||
'font-family',
|
||
'line-height',
|
||
'text-align',
|
||
'padding',
|
||
'padding-top',
|
||
'padding-right',
|
||
'padding-bottom',
|
||
'padding-left',
|
||
'border-radius',
|
||
]);
|
||
|
||
// Reject values that could break out of `prop: value` and into the
|
||
// surrounding <style> block — semicolons, braces, angle brackets, and
|
||
// newlines. Mirrors the bridge's UNSAFE_VALUE regex.
|
||
const HOST_UNSAFE_INSPECT_VALUE = /[;{}<>\n\r]/;
|
||
|
||
// Reject elementIds whose characters could break out of `[attr="..."]`
|
||
// inside a <style> block. Forbidden:
|
||
// - `"` and `\` would close the attribute string or smuggle CSS
|
||
// escapes the host didn't pre-process;
|
||
// - `<` and `>` would close the surrounding <style> tag;
|
||
// - C0/C1 controls (newline, etc.) end the CSS rule under string
|
||
// tokenization — kept in as defense-in-depth against parser quirks.
|
||
// Everything else — including ASCII whitespace and leading digits — is
|
||
// allowed, so deck labels like `01 Cover` survive instead of being
|
||
// dropped on the way to the persisted overrides block.
|
||
const HOST_UNSAFE_INSPECT_ID = /["\\<>\u0000-\u001f\u007f]/;
|
||
|
||
// Build the inspect overrides CSS body the host will persist, from the
|
||
// structured `overrides` field of an od:inspect-overrides message. The host
|
||
// MUST NOT trust the sibling `css` string — it is attacker-controlled when
|
||
// artifact JS forges the message. The selector is re-derived from each
|
||
// elementId; only allow-listed properties with safe values survive.
|
||
//
|
||
// Exported so unit tests can exercise the validator with hostile payloads.
|
||
export function serializeInspectOverrides(overrides: unknown): string {
|
||
if (!overrides || typeof overrides !== 'object') return '';
|
||
const map = overrides as Record<string, unknown>;
|
||
const lines: string[] = [];
|
||
for (const elementId of Object.keys(map)) {
|
||
if (!elementId || HOST_UNSAFE_INSPECT_ID.test(elementId)) continue;
|
||
const entry = map[elementId] as InspectOverridePayload | null | undefined;
|
||
if (!entry || typeof entry !== 'object') continue;
|
||
const props = entry.props;
|
||
if (!props || typeof props !== 'object') continue;
|
||
// Trust only the *kind* of selector the bridge built, not the value
|
||
// it carried. The bridge runs CSS.escape over the elementId, so a raw
|
||
// equality check against `[data-screen-label="${elementId}"]` would
|
||
// miss legitimate deck labels like `01 Cover` (whitespace, leading
|
||
// digit) and silently downgrade them to `[data-od-id="..."]`. The
|
||
// elementId itself was sanitized above, so embedding it verbatim into
|
||
// the re-derived selector is safe inside an attribute value string.
|
||
const inboundSelector = typeof entry.selector === 'string' ? entry.selector : '';
|
||
const attr = inboundSelector.startsWith('[data-screen-label="')
|
||
? 'data-screen-label'
|
||
: 'data-od-id';
|
||
const safeSelector = `[${attr}="${elementId}"]`;
|
||
const decls: string[] = [];
|
||
for (const [rawName, rawValue] of Object.entries(props as Record<string, unknown>)) {
|
||
if (typeof rawName !== 'string' || typeof rawValue !== 'string') continue;
|
||
const name = rawName.toLowerCase();
|
||
if (!HOST_ALLOWED_INSPECT_PROPS.has(name)) continue;
|
||
const value = rawValue.trim();
|
||
if (!value || HOST_UNSAFE_INSPECT_VALUE.test(value)) continue;
|
||
decls.push(`${name}: ${value} !important`);
|
||
}
|
||
if (!decls.length) continue;
|
||
lines.push(`${safeSelector} { ${decls.join('; ')} }`);
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// Apply a single host-driven prop change to the authoritative override map.
|
||
// Returns a new map (or the same reference if no-op so React skips renders).
|
||
// Empty value clears the prop; clearing the last prop drops the elementId.
|
||
// Mirrors the iframe bridge's applyOverride sanitization so the host map and
|
||
// the live preview stay in lock-step under the same rules.
|
||
export function updateInspectOverride(
|
||
map: InspectOverrideMap,
|
||
elementId: string,
|
||
selector: string,
|
||
prop: string,
|
||
value: string,
|
||
): InspectOverrideMap {
|
||
if (!elementId || HOST_UNSAFE_INSPECT_ID.test(elementId)) return map;
|
||
const propName = String(prop || '').toLowerCase();
|
||
if (!HOST_ALLOWED_INSPECT_PROPS.has(propName)) return map;
|
||
const trimmed = String(value ?? '').trim();
|
||
if (trimmed && HOST_UNSAFE_INSPECT_VALUE.test(trimmed)) return map;
|
||
const existing = map[elementId];
|
||
const nextProps: Record<string, string> = { ...(existing?.props ?? {}) };
|
||
if (!trimmed) {
|
||
if (!(propName in nextProps)) return map;
|
||
delete nextProps[propName];
|
||
} else if (nextProps[propName] === trimmed && existing?.selector === selector) {
|
||
return map;
|
||
} else {
|
||
nextProps[propName] = trimmed;
|
||
}
|
||
const nextMap: InspectOverrideMap = { ...map };
|
||
if (Object.keys(nextProps).length === 0) {
|
||
delete nextMap[elementId];
|
||
} else {
|
||
nextMap[elementId] = { selector: selector || existing?.selector || '', props: nextProps };
|
||
}
|
||
return nextMap;
|
||
}
|
||
|
||
// Parse any persisted <style data-od-inspect-overrides> blocks in the
|
||
// artifact source into the host's authoritative override map. The host owns
|
||
// this map and only mutates it from onApply / reset actions plus this
|
||
// initial hydration step — inbound iframe od:inspect-overrides messages are
|
||
// not ingested. Without this step, opening a file that already carries an
|
||
// override block would leave the host map empty, so a Save-to-source after
|
||
// any subsequent edit could splice a CSS body that drops every previously
|
||
// saved rule for elements the user did not touch in this session.
|
||
//
|
||
// Mirrors the iframe bridge's hydrateOverridesFromDom: same allow-list,
|
||
// same value sanitizer, same selector kinds, so what the iframe applies and
|
||
// what the host persists stay in lock-step. Pure string transform; no DOM.
|
||
//
|
||
// HTML-aware: enumerates `<style data-od-inspect-overrides>` elements via
|
||
// the same walker used by the splicer, so a `<style data-od-inspect-overrides>`
|
||
// literal living inside a `<script>`, `<style>` (e.g. CSS comment), `<textarea>`,
|
||
// `<title>`, or HTML comment is not mistaken for a real override block. Without
|
||
// that exclusion, useEffect would seed the host map from forged/quoted text and
|
||
// a later Save-to-source would persist phantom CSS the user never created.
|
||
export function parseInspectOverridesFromSource(source: string): InspectOverrideMap {
|
||
const map: InspectOverrideMap = {};
|
||
if (!source) return map;
|
||
for (const body of stripInspectOverridesAndIndex(source).bodies) {
|
||
const ruleRe = /(\[data-(?:od-id|screen-label)="([^"]*)"\])\s*\{\s*([^}]*)\}/g;
|
||
let ruleMatch: RegExpExecArray | null;
|
||
while ((ruleMatch = ruleRe.exec(body)) !== null) {
|
||
const selector = ruleMatch[1] ?? '';
|
||
const elementId = ruleMatch[2] ?? '';
|
||
const declBody = ruleMatch[3] ?? '';
|
||
if (!selector || !elementId || HOST_UNSAFE_INSPECT_ID.test(elementId)) continue;
|
||
const props: Record<string, string> = {};
|
||
for (const raw of declBody.split(';')) {
|
||
if (!raw) continue;
|
||
const colon = raw.indexOf(':');
|
||
if (colon <= 0) continue;
|
||
const name = raw.slice(0, colon).trim().toLowerCase();
|
||
if (!HOST_ALLOWED_INSPECT_PROPS.has(name)) continue;
|
||
const value = raw.slice(colon + 1).replace(/!important/gi, '').trim();
|
||
if (!value || HOST_UNSAFE_INSPECT_VALUE.test(value)) continue;
|
||
props[name] = value;
|
||
}
|
||
if (Object.keys(props).length) {
|
||
map[elementId] = { selector, props };
|
||
}
|
||
}
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// HTML5 raw-text and escapable-raw-text elements: the parser does not
|
||
// interpret markup inside their contents, so a literal `</head>` or
|
||
// `<style data-od-inspect-overrides>` written as text inside one of them
|
||
// must NOT be treated as a real tag. Without this exclusion, a regex-only
|
||
// splicer can match `</head>` inside an inline <script> string literal or
|
||
// a CSS comment and inject the override block into the middle of
|
||
// JavaScript/CSS instead of the actual document head, corrupting the
|
||
// artifact on Save to source.
|
||
const RAW_TEXT_INSPECT_ELEMENTS = new Set(['script', 'style', 'textarea', 'title']);
|
||
|
||
// Decide whether a `<style ...>` opening tag actually carries a real
|
||
// `data-od-inspect-overrides` attribute, as opposed to merely mentioning
|
||
// the marker text inside another attribute name or value. The naive
|
||
// `\bdata-od-inspect-overrides\b` test against the whole tag text is
|
||
// over-broad in two cases:
|
||
//
|
||
// 1. A longer attribute name that has the marker as a prefix, e.g.
|
||
// `<style data-od-inspect-overrides-note="docs">`. The `-` after
|
||
// `overrides` is a non-word character, so `\b` matches and the tag
|
||
// gets mis-stripped on save / mis-parsed on hydration.
|
||
// 2. The marker spelled inside an attribute value, e.g.
|
||
// `<style title="data-od-inspect-overrides">`. The whole tag text
|
||
// contains the literal, so the regex matches even though the actual
|
||
// attribute names are `title` only.
|
||
//
|
||
// Both shapes occur in real artifacts (notes, documentation, fixtures)
|
||
// and would either silently drop the user's CSS on save or seed phantom
|
||
// overrides into the host map even though the artifact has no real
|
||
// override block. So we walk attributes proper, lower-casing each name
|
||
// and skipping any quoted value, and report a hit only when one of those
|
||
// names is exactly `data-od-inspect-overrides` (boolean attribute or
|
||
// assigned value, both legal HTML for our marker).
|
||
function styleTagIsInspectOverrideBlock(tagText: string): boolean {
|
||
const start = /^<style/i.exec(tagText);
|
||
if (!start) return false;
|
||
let i = start[0].length;
|
||
const end = tagText.length;
|
||
while (i < end) {
|
||
const ch = tagText.charAt(i);
|
||
if (ch === '>') return false;
|
||
if (ch === '/' || /\s/.test(ch)) {
|
||
i++;
|
||
continue;
|
||
}
|
||
const nameStart = i;
|
||
while (i < end) {
|
||
const c = tagText.charAt(i);
|
||
if (c === '=' || c === '/' || c === '>' || /\s/.test(c)) break;
|
||
i++;
|
||
}
|
||
const name = tagText.slice(nameStart, i).toLowerCase();
|
||
while (i < end && /\s/.test(tagText.charAt(i))) i++;
|
||
if (i < end && tagText.charAt(i) === '=') {
|
||
i++;
|
||
while (i < end && /\s/.test(tagText.charAt(i))) i++;
|
||
const quote = tagText.charAt(i);
|
||
if (quote === '"' || quote === "'") {
|
||
i++;
|
||
const close = tagText.indexOf(quote, i);
|
||
i = close < 0 ? end : close + 1;
|
||
} else {
|
||
while (i < end) {
|
||
const c = tagText.charAt(i);
|
||
if (c === '>' || /\s/.test(c)) break;
|
||
i++;
|
||
}
|
||
}
|
||
}
|
||
if (name === 'data-od-inspect-overrides') return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Find the start (`<` position) of the matching close tag for a raw-text
|
||
// element, scanning case-insensitively. The close tag must be followed by
|
||
// a tag-name boundary (whitespace, `/`, or `>`) so a longer name like
|
||
// `</scripted>` doesn't accidentally close a `<script>`.
|
||
function findInspectRawTextEnd(source: string, start: number, name: string): number {
|
||
const lower = source.toLowerCase();
|
||
const needle = '</' + name.toLowerCase();
|
||
let p = start;
|
||
while (p < source.length) {
|
||
const idx = lower.indexOf(needle, p);
|
||
if (idx < 0) return -1;
|
||
const after = source.charAt(idx + needle.length);
|
||
if (after === '' || after === '>' || after === '/' || /\s/.test(after)) return idx;
|
||
p = idx + needle.length;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
type InspectSpliceScan = {
|
||
out: string;
|
||
// Position in `out` immediately after the first top-level `<head ...>`
|
||
// open tag, or -1 if no head was found outside raw-text content.
|
||
headOpenEnd: number;
|
||
// Position in `out` at the first top-level `</head>` close tag, or -1.
|
||
headCloseStart: number;
|
||
// Raw inner-text of every real `<style data-od-inspect-overrides>` element
|
||
// discovered during the walk, in source order. Excludes occurrences inside
|
||
// raw-text element contents and HTML comments. Hydration parses these
|
||
// bodies for the host map; the splicer ignores them.
|
||
bodies: string[];
|
||
};
|
||
|
||
// Walk `source` and produce a copy with every existing
|
||
// `<style data-od-inspect-overrides>...</style>` block removed, while
|
||
// remembering where the real (non-raw-text) `<head>` boundaries land in
|
||
// the output. The walker honours HTML comment, doctype/processing
|
||
// instruction, and raw-text element boundaries so the splicer can ignore
|
||
// tag-shaped literals inside scripts/styles/textareas/titles. Pure string
|
||
// transform — no DOM dependency, safe to run during SSR/tests.
|
||
function stripInspectOverridesAndIndex(source: string): InspectSpliceScan {
|
||
const parts: string[] = [];
|
||
const bodies: string[] = [];
|
||
let outLen = 0;
|
||
let headOpenEnd = -1;
|
||
let headCloseStart = -1;
|
||
let i = 0;
|
||
function emit(text: string): void {
|
||
if (!text) return;
|
||
parts.push(text);
|
||
outLen += text.length;
|
||
}
|
||
while (i < source.length) {
|
||
const lt = source.indexOf('<', i);
|
||
if (lt < 0) {
|
||
emit(source.slice(i));
|
||
break;
|
||
}
|
||
if (lt > i) emit(source.slice(i, lt));
|
||
i = lt;
|
||
if (source.startsWith('<!--', i)) {
|
||
const end = source.indexOf('-->', i + 4);
|
||
const stop = end < 0 ? source.length : end + 3;
|
||
emit(source.slice(i, stop));
|
||
i = stop;
|
||
continue;
|
||
}
|
||
if (source.startsWith('<!', i) || source.startsWith('<?', i)) {
|
||
const end = source.indexOf('>', i + 2);
|
||
const stop = end < 0 ? source.length : end + 1;
|
||
emit(source.slice(i, stop));
|
||
i = stop;
|
||
continue;
|
||
}
|
||
const tagEnd = source.indexOf('>', i + 1);
|
||
if (tagEnd < 0) {
|
||
emit(source.slice(i));
|
||
break;
|
||
}
|
||
const tagText = source.slice(i, tagEnd + 1);
|
||
const closeMatch = /^<\/([a-zA-Z][a-zA-Z0-9-]*)/.exec(tagText);
|
||
if (closeMatch) {
|
||
const name = closeMatch[1]!.toLowerCase();
|
||
if (name === 'head' && headCloseStart < 0) headCloseStart = outLen;
|
||
emit(tagText);
|
||
i = tagEnd + 1;
|
||
continue;
|
||
}
|
||
const openMatch = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(tagText);
|
||
if (!openMatch) {
|
||
emit(tagText);
|
||
i = tagEnd + 1;
|
||
continue;
|
||
}
|
||
const name = openMatch[1]!.toLowerCase();
|
||
const isSelfClose = /\/\s*>$/.test(tagText);
|
||
if (name === 'head' && headOpenEnd < 0) headOpenEnd = outLen + tagText.length;
|
||
if (name === 'style' && styleTagIsInspectOverrideBlock(tagText)) {
|
||
// Strip the entire override block. A self-closing <style /> is a
|
||
// degenerate authoring case; treat it as nothing to skip past.
|
||
if (isSelfClose) {
|
||
i = tagEnd + 1;
|
||
continue;
|
||
}
|
||
const closeStart = findInspectRawTextEnd(source, tagEnd + 1, 'style');
|
||
if (closeStart < 0) {
|
||
// Unterminated override block — drop the rest of the document
|
||
// rather than silently reflowing later content into a dangling
|
||
// <style>. Matches the "stop" behaviour of the previous regex.
|
||
i = source.length;
|
||
continue;
|
||
}
|
||
bodies.push(source.slice(tagEnd + 1, closeStart));
|
||
const closeEnd = source.indexOf('>', closeStart);
|
||
let stop = closeEnd < 0 ? source.length : closeEnd + 1;
|
||
while (stop < source.length && /\s/.test(source.charAt(stop))) stop++;
|
||
i = stop;
|
||
continue;
|
||
}
|
||
if (!isSelfClose && RAW_TEXT_INSPECT_ELEMENTS.has(name)) {
|
||
const closeStart = findInspectRawTextEnd(source, tagEnd + 1, name);
|
||
if (closeStart < 0) {
|
||
emit(source.slice(i));
|
||
i = source.length;
|
||
continue;
|
||
}
|
||
const closeEnd = source.indexOf('>', closeStart);
|
||
const stop = closeEnd < 0 ? source.length : closeEnd + 1;
|
||
// Copy the entire raw-text element (open tag, body, close tag) to
|
||
// the output verbatim so its contents pass through unmodified.
|
||
emit(source.slice(i, stop));
|
||
i = stop;
|
||
continue;
|
||
}
|
||
emit(tagText);
|
||
i = tagEnd + 1;
|
||
}
|
||
return { out: parts.join(''), headOpenEnd, headCloseStart, bodies };
|
||
}
|
||
|
||
// Splice (or remove) the inspect overrides <style> block in an HTML
|
||
// document. Idempotent: calling with the same css produces the same
|
||
// document. Empty css strips the block entirely.
|
||
//
|
||
// HTML-aware: the underlying scan ignores comments and raw-text element
|
||
// contents (script / style / textarea / title), so a literal `</head>` or
|
||
// `<style data-od-inspect-overrides>` written inside an inline script or
|
||
// style block does not trick the splicer into stripping user code or
|
||
// inserting the override block in the middle of JavaScript/CSS.
|
||
//
|
||
// Exported (via the module) so a unit test can drive it without a live
|
||
// browser. Pure string transform — no DOM, no parser dependency.
|
||
export function applyInspectOverridesToSource(source: string, css: string): string {
|
||
const trimmed = css.trim();
|
||
const { out, headOpenEnd, headCloseStart } = stripInspectOverridesAndIndex(source);
|
||
if (!trimmed) return out;
|
||
const block = `<style data-od-inspect-overrides>\n${trimmed}\n</style>\n`;
|
||
if (headCloseStart >= 0) {
|
||
return out.slice(0, headCloseStart) + block + out.slice(headCloseStart);
|
||
}
|
||
if (headOpenEnd >= 0) {
|
||
return out.slice(0, headOpenEnd) + block + out.slice(headOpenEnd);
|
||
}
|
||
return block + out;
|
||
}
|
||
|
||
function CommentPreviewOverlays({
|
||
comments,
|
||
liveTargets,
|
||
hoveredTarget,
|
||
hoveredPodMemberId,
|
||
activeTarget,
|
||
boardTool,
|
||
showActivePin = false,
|
||
scale,
|
||
offsetX,
|
||
offsetY,
|
||
strokePoints,
|
||
onOpenComment,
|
||
}: {
|
||
comments: PreviewComment[];
|
||
liveTargets: Map<string, PreviewCommentSnapshot>;
|
||
hoveredTarget: PreviewCommentSnapshot | null;
|
||
hoveredPodMemberId: string | null;
|
||
activeTarget: PreviewCommentSnapshot | null;
|
||
boardTool: BoardTool;
|
||
showActivePin?: boolean;
|
||
scale: number;
|
||
offsetX: number;
|
||
offsetY: number;
|
||
strokePoints: StrokePoint[];
|
||
onOpenComment: (comment: PreviewComment, snapshot: PreviewCommentSnapshot) => void;
|
||
}) {
|
||
const overlayOffset = { x: offsetX, y: offsetY };
|
||
const visibleComments = comments
|
||
.map((comment, index) => ({
|
||
comment,
|
||
index,
|
||
snapshot: liveSnapshotForComment(comment, liveTargets),
|
||
}))
|
||
.filter((item): item is { comment: PreviewComment; index: number; snapshot: PreviewCommentSnapshot } =>
|
||
Boolean(item.snapshot),
|
||
);
|
||
const activeSavedIndex = activeTarget
|
||
? visibleComments.findIndex(({ snapshot }) => snapshot.elementId === activeTarget.elementId)
|
||
: -1;
|
||
const activePinNumber = activeSavedIndex >= 0
|
||
? activeSavedIndex + 1
|
||
: visibleComments.length + 1;
|
||
const targetOverlay = activeTarget ?? hoveredTarget;
|
||
return (
|
||
<div className="comment-overlay-layer" aria-hidden={false}>
|
||
{visibleComments.map(({ comment, index, snapshot }) => {
|
||
const bounds = overlayBoundsFromSnapshot(snapshot, scale, overlayOffset);
|
||
const label = commentTargetDisplayName(comment);
|
||
return (
|
||
<div
|
||
key={comment.id}
|
||
className="comment-saved-marker"
|
||
style={{
|
||
left: bounds.left,
|
||
top: bounds.top,
|
||
width: bounds.width,
|
||
height: bounds.height,
|
||
}}
|
||
data-testid={`comment-saved-marker-${comment.elementId}`}
|
||
onClick={() => onOpenComment(comment, snapshot)}
|
||
>
|
||
<div className="comment-saved-outline" />
|
||
<button
|
||
type="button"
|
||
className="comment-saved-pin"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
onOpenComment(comment, snapshot);
|
||
}}
|
||
title={`${index + 1}. ${label}: ${comment.note}`}
|
||
aria-label={`Open comment for ${label}`}
|
||
>
|
||
{index + 1}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
{targetOverlay ? (
|
||
<CommentTargetOverlay
|
||
snapshot={targetOverlay}
|
||
scale={scale}
|
||
offset={overlayOffset}
|
||
selected={Boolean(activeTarget)}
|
||
hoveredMemberId={hoveredPodMemberId}
|
||
/>
|
||
) : null}
|
||
{showActivePin && activeTarget ? (
|
||
<div
|
||
className="comment-active-pin"
|
||
style={activeCommentPinStyle(activeTarget, scale, overlayOffset)}
|
||
data-testid="comment-active-pin"
|
||
aria-hidden="true"
|
||
>
|
||
{activePinNumber}
|
||
</div>
|
||
) : null}
|
||
{boardTool === 'pod' && strokePoints.length > 1 ? (
|
||
<svg className="board-pod-stroke">
|
||
<polyline
|
||
points={strokePoints.map((point) => `${offsetX + point.x * scale},${offsetY + point.y * scale}`).join(' ')}
|
||
/>
|
||
</svg>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function activeCommentPinStyle(
|
||
target: PreviewCommentSnapshot,
|
||
scale: number,
|
||
offset: { x: number; y: number } = { x: 0, y: 0 },
|
||
): CSSProperties {
|
||
const safeScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||
const anchor = target.hoverPoint ?? {
|
||
x: target.position.x,
|
||
y: target.position.y,
|
||
};
|
||
return {
|
||
left: Math.round(offset.x + anchor.x * safeScale),
|
||
top: Math.round(offset.y + anchor.y * safeScale),
|
||
};
|
||
}
|
||
|
||
export function CommentTargetOverlay({
|
||
snapshot,
|
||
scale,
|
||
offset,
|
||
selected,
|
||
hoveredMemberId,
|
||
}: {
|
||
snapshot: PreviewCommentSnapshot;
|
||
scale: number;
|
||
offset?: { x: number; y: number };
|
||
selected: boolean;
|
||
hoveredMemberId?: string | null;
|
||
}) {
|
||
const overlayOffset = offset ?? { x: 0, y: 0 };
|
||
const displayMembers = podDisplayMembers(snapshot);
|
||
if (displayMembers.length > 0) {
|
||
const overlayWeights = podOverlayWeights(displayMembers);
|
||
return (
|
||
<>
|
||
{displayMembers.map((member, index) => {
|
||
const bounds = overlayBoundsFromSnapshot(member, scale, overlayOffset);
|
||
const width = Math.round(member.position.width);
|
||
const height = Math.round(member.position.height);
|
||
const overlayWeight = overlayWeights[index] ?? {
|
||
backgroundOpacity: 0.24,
|
||
outlineOpacity: 0.72,
|
||
ringOpacity: 0.18,
|
||
};
|
||
const overlayStyle: CSSProperties & Record<string, string | number> = {
|
||
left: bounds.left,
|
||
top: bounds.top,
|
||
width: bounds.width,
|
||
height: bounds.height,
|
||
'--comment-overlay-bg': `rgba(22, 119, 255, ${overlayWeight.backgroundOpacity})`,
|
||
'--comment-overlay-ring': `rgba(22, 119, 255, ${overlayWeight.ringOpacity})`,
|
||
'--comment-overlay-border': `rgba(22, 119, 255, ${overlayWeight.outlineOpacity})`,
|
||
};
|
||
const isHoverFocused = hoveredMemberId === member.elementId;
|
||
return (
|
||
<div
|
||
key={`${member.elementId}-${index}`}
|
||
className={`comment-target-overlay comment-target-overlay--member${selected ? ' selected' : ''}${isHoverFocused ? ' is-hover-focused' : ''}`}
|
||
style={overlayStyle}
|
||
data-testid="comment-target-overlay"
|
||
>
|
||
<span className="comment-target-overlay-label">{snapshot.elementId}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
// Non-member fallback: single-element snapshots have no per-member chips,
|
||
// so the hover-focus channel never reaches this branch — no is-hover-focused
|
||
// class needed here.
|
||
const bounds = overlayBoundsFromSnapshot(snapshot, scale, overlayOffset);
|
||
return (
|
||
<div
|
||
className={`comment-target-overlay${selected ? ' selected' : ''}`}
|
||
style={{
|
||
left: bounds.left,
|
||
top: bounds.top,
|
||
width: bounds.width,
|
||
height: bounds.height,
|
||
}}
|
||
data-testid="comment-target-overlay"
|
||
>
|
||
<span className="comment-target-overlay-label">{snapshot.elementId}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function podDisplayMembers(snapshot: PreviewCommentSnapshot): PreviewCommentSnapshot[] {
|
||
if (snapshot.selectionKind !== 'pod' || !Array.isArray(snapshot.podMembers)) return [];
|
||
const memberSnapshots = snapshot.podMembers.map((member) => ({
|
||
filePath: snapshot.filePath,
|
||
elementId: member.elementId,
|
||
selector: member.selector,
|
||
label: member.label,
|
||
text: member.text,
|
||
position: member.position,
|
||
htmlHint: member.htmlHint,
|
||
selectionKind: 'element' as const,
|
||
}));
|
||
const refined = pruneContainerSelections(memberSnapshots);
|
||
return refined.length > 0 ? refined : memberSnapshots;
|
||
}
|
||
|
||
function podOverlayWeights(
|
||
members: PreviewCommentSnapshot[],
|
||
): Array<{ backgroundOpacity: number; outlineOpacity: number; ringOpacity: number }> {
|
||
const areas = members.map((member) =>
|
||
Math.max(1, member.position.width * member.position.height),
|
||
);
|
||
const maxArea = Math.max(...areas);
|
||
const minArea = Math.min(...areas);
|
||
return areas.map((area) => {
|
||
const normalized =
|
||
maxArea === minArea ? 1 : 1 - (area - minArea) / (maxArea - minArea);
|
||
const emphasis = Math.pow(normalized, 0.9);
|
||
return {
|
||
backgroundOpacity: roundOverlayOpacity(0.1 + emphasis * 0.6),
|
||
outlineOpacity: roundOverlayOpacity(0.34 + emphasis * 0.36),
|
||
ringOpacity: roundOverlayOpacity(0.08 + emphasis * 0.18),
|
||
};
|
||
});
|
||
}
|
||
|
||
function roundOverlayOpacity(value: number): number {
|
||
return Math.round(value * 100) / 100;
|
||
}
|
||
|
||
function buildPodSnapshot(input: {
|
||
filePath: string;
|
||
strokePoints: StrokePoint[];
|
||
liveTargets: Map<string, PreviewCommentSnapshot>;
|
||
}): PreviewCommentSnapshot | null {
|
||
if (input.strokePoints.length < 2) return null;
|
||
const closedLoop = isClosedLoop(input.strokePoints);
|
||
const intersected = Array.from(input.liveTargets.values()).filter((snapshot) =>
|
||
selectionHitsSnapshot({
|
||
points: input.strokePoints,
|
||
snapshot,
|
||
closedLoop,
|
||
}),
|
||
);
|
||
const refined = pruneContainerSelections(intersected);
|
||
const selected = refined.length > 0 ? refined : intersected;
|
||
if (selected.length === 0) return null;
|
||
const bounds = selected.reduce(
|
||
(acc, snapshot) => {
|
||
const rect = snapshot.position;
|
||
return {
|
||
left: Math.min(acc.left, rect.x),
|
||
top: Math.min(acc.top, rect.y),
|
||
right: Math.max(acc.right, rect.x + rect.width),
|
||
bottom: Math.max(acc.bottom, rect.y + rect.height),
|
||
};
|
||
},
|
||
{
|
||
left: Number.POSITIVE_INFINITY,
|
||
top: Number.POSITIVE_INFINITY,
|
||
right: Number.NEGATIVE_INFINITY,
|
||
bottom: Number.NEGATIVE_INFINITY,
|
||
},
|
||
);
|
||
const podMembers: PreviewCommentMember[] = selected.map((snapshot) => ({
|
||
elementId: snapshot.elementId,
|
||
selector: snapshot.selector,
|
||
label: snapshot.label,
|
||
text: snapshot.text,
|
||
position: snapshot.position,
|
||
htmlHint: snapshot.htmlHint,
|
||
style: snapshot.style,
|
||
}));
|
||
const summary = selected
|
||
.slice(0, 3)
|
||
.map((snapshot) => summarizeSnapshot(snapshot))
|
||
.join(' · ');
|
||
const htmlHint = selected
|
||
.slice(0, 4)
|
||
.map((snapshot) => snapshot.htmlHint)
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
const combinedSelector = selected
|
||
.slice(0, 8)
|
||
.map((snapshot) => snapshot.selector)
|
||
.filter(Boolean)
|
||
.join(', ');
|
||
return {
|
||
filePath: input.filePath,
|
||
elementId: `pod-${Date.now()}`,
|
||
selector: combinedSelector || 'body *',
|
||
label: summary || `Pod of ${intersected.length} items`,
|
||
text: intersected
|
||
.slice(0, 4)
|
||
.map((snapshot) => snapshot.text)
|
||
.filter(Boolean)
|
||
.join(' · '),
|
||
position: {
|
||
x: Math.round(bounds.left),
|
||
y: Math.round(bounds.top),
|
||
width: Math.max(1, Math.round(bounds.right - bounds.left)),
|
||
height: Math.max(1, Math.round(bounds.bottom - bounds.top)),
|
||
},
|
||
htmlHint: htmlHint.slice(0, 180),
|
||
selectionKind: 'pod',
|
||
memberCount: selected.length,
|
||
podMembers,
|
||
};
|
||
}
|
||
|
||
function pruneContainerSelections(
|
||
snapshots: PreviewCommentSnapshot[],
|
||
): PreviewCommentSnapshot[] {
|
||
if (snapshots.length < 2) return snapshots;
|
||
return snapshots.filter((candidate) => {
|
||
const candidateArea = Math.max(1, candidate.position.width * candidate.position.height);
|
||
const contained = snapshots.filter(
|
||
(other) =>
|
||
other.elementId !== candidate.elementId &&
|
||
rectContains(candidate.position, other.position),
|
||
);
|
||
if (contained.length === 0) return true;
|
||
const union = contained.reduce(
|
||
(acc, other) => ({
|
||
left: Math.min(acc.left, other.position.x),
|
||
top: Math.min(acc.top, other.position.y),
|
||
right: Math.max(acc.right, other.position.x + other.position.width),
|
||
bottom: Math.max(acc.bottom, other.position.y + other.position.height),
|
||
}),
|
||
{
|
||
left: Number.POSITIVE_INFINITY,
|
||
top: Number.POSITIVE_INFINITY,
|
||
right: Number.NEGATIVE_INFINITY,
|
||
bottom: Number.NEGATIVE_INFINITY,
|
||
},
|
||
);
|
||
const unionArea = Math.max(1, (union.right - union.left) * (union.bottom - union.top));
|
||
return !(contained.length >= 2 && candidateArea > unionArea * 2.4);
|
||
});
|
||
}
|
||
|
||
function summarizeSnapshot(snapshot: PreviewCommentSnapshot): string {
|
||
const text = snapshot.text.trim();
|
||
if (text) {
|
||
const trimmed = text.length > 28 ? `${text.slice(0, 25)}...` : text;
|
||
return `${snapshot.label || snapshot.elementId} · ${trimmed}`;
|
||
}
|
||
return snapshot.label || snapshot.elementId;
|
||
}
|
||
|
||
function selectionHitsSnapshot(input: {
|
||
points: StrokePoint[];
|
||
snapshot: PreviewCommentSnapshot;
|
||
closedLoop: boolean;
|
||
}): boolean {
|
||
const bounds = {
|
||
left: input.snapshot.position.x,
|
||
top: input.snapshot.position.y,
|
||
width: input.snapshot.position.width,
|
||
height: input.snapshot.position.height,
|
||
};
|
||
if (pathIntersectsRect(input.points, bounds)) return true;
|
||
if (!input.closedLoop) return false;
|
||
const center = {
|
||
x: bounds.left + bounds.width / 2,
|
||
y: bounds.top + bounds.height / 2,
|
||
};
|
||
if (pointInPolygon(center, input.points)) return true;
|
||
const corners = [
|
||
{ x: bounds.left, y: bounds.top },
|
||
{ x: bounds.left + bounds.width, y: bounds.top },
|
||
{ x: bounds.left + bounds.width, y: bounds.top + bounds.height },
|
||
{ x: bounds.left, y: bounds.top + bounds.height },
|
||
];
|
||
return corners.some((corner) => pointInPolygon(corner, input.points));
|
||
}
|
||
|
||
function isClosedLoop(points: StrokePoint[]): boolean {
|
||
if (points.length < 4) return false;
|
||
const first = points[0]!;
|
||
const last = points[points.length - 1]!;
|
||
return Math.hypot(first.x - last.x, first.y - last.y) <= 28;
|
||
}
|
||
|
||
function rectContains(
|
||
outer: { x: number; y: number; width: number; height: number },
|
||
inner: { x: number; y: number; width: number; height: number },
|
||
): boolean {
|
||
return (
|
||
outer.x <= inner.x &&
|
||
outer.y <= inner.y &&
|
||
outer.x + outer.width >= inner.x + inner.width &&
|
||
outer.y + outer.height >= inner.y + inner.height
|
||
);
|
||
}
|
||
|
||
function pathIntersectsRect(
|
||
points: StrokePoint[],
|
||
rect: { left: number; top: number; width: number; height: number },
|
||
): boolean {
|
||
if (points.length === 0) return false;
|
||
const x1 = rect.left;
|
||
const y1 = rect.top;
|
||
const x2 = rect.left + rect.width;
|
||
const y2 = rect.top + rect.height;
|
||
for (let index = 0; index < points.length; index += 1) {
|
||
const point = points[index]!;
|
||
if (point.x >= x1 && point.x <= x2 && point.y >= y1 && point.y <= y2) {
|
||
return true;
|
||
}
|
||
const next = points[index + 1];
|
||
if (!next) continue;
|
||
if (
|
||
lineIntersectsLine(point, next, { x: x1, y: y1 }, { x: x2, y: y1 }) ||
|
||
lineIntersectsLine(point, next, { x: x2, y: y1 }, { x: x2, y: y2 }) ||
|
||
lineIntersectsLine(point, next, { x: x2, y: y2 }, { x: x1, y: y2 }) ||
|
||
lineIntersectsLine(point, next, { x: x1, y: y2 }, { x: x1, y: y1 })
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function pointInPolygon(point: StrokePoint, polygon: StrokePoint[]): boolean {
|
||
let inside = false;
|
||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||
const pi = polygon[i]!;
|
||
const pj = polygon[j]!;
|
||
const intersects =
|
||
pi.y > point.y !== pj.y > point.y &&
|
||
point.x <
|
||
((pj.x - pi.x) * (point.y - pi.y)) / ((pj.y - pi.y) || Number.EPSILON) + pi.x;
|
||
if (intersects) inside = !inside;
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
function lineIntersectsLine(a1: StrokePoint, a2: StrokePoint, b1: StrokePoint, b2: StrokePoint): boolean {
|
||
const denominator =
|
||
(a2.x - a1.x) * (b2.y - b1.y) - (a2.y - a1.y) * (b2.x - b1.x);
|
||
if (denominator === 0) return false;
|
||
const ua =
|
||
((b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x)) / denominator;
|
||
const ub =
|
||
((a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x)) / denominator;
|
||
return ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1;
|
||
}
|
||
|
||
function finiteBridgeInteger(value: unknown): number | undefined {
|
||
if (!Number.isFinite(value)) return undefined;
|
||
return clampBridgeCoordinate(value);
|
||
}
|
||
|
||
function normalizeAnnotationStyle(input: unknown): PreviewCommentSnapshot['style'] {
|
||
if (!input || typeof input !== 'object') return undefined;
|
||
const raw = input as Record<string, unknown>;
|
||
const style: NonNullable<PreviewCommentSnapshot['style']> = {};
|
||
for (const key of ANNOTATION_STYLE_KEYS) {
|
||
const value = raw[key];
|
||
if (typeof value !== 'string') continue;
|
||
const trimmed = value.replace(/\s+/g, ' ').trim();
|
||
if (trimmed) style[key] = trimmed.slice(0, 120);
|
||
}
|
||
return Object.keys(style).length > 0 ? style : undefined;
|
||
}
|
||
|
||
const ANNOTATION_STYLE_KEYS = [
|
||
'color',
|
||
'backgroundColor',
|
||
'fontSize',
|
||
'fontWeight',
|
||
'lineHeight',
|
||
'textAlign',
|
||
'fontFamily',
|
||
'paddingTop',
|
||
'paddingRight',
|
||
'paddingBottom',
|
||
'paddingLeft',
|
||
'borderRadius',
|
||
] as const;
|
||
|
||
function clampBridgeCoordinate(value: unknown): number {
|
||
const numeric = Number(value);
|
||
if (!Number.isFinite(numeric)) return 0;
|
||
return Math.max(-MAX_BRIDGE_COORDINATE, Math.min(MAX_BRIDGE_COORDINATE, Math.round(numeric)));
|
||
}
|
||
|
||
// Shown instead of the React runtime when a .jsx/.tsx is a module loaded by a
|
||
// sibling HTML entry (issue #2744): such a file has no standalone component to
|
||
// render, so point the user at the page(s) that do. Clicking an entry opens
|
||
// (or focuses) that page and closes the now-useless module tab.
|
||
function ReactModulePointer({
|
||
entries,
|
||
onOpenEntry,
|
||
}: {
|
||
entries: string[];
|
||
onOpenEntry?: (name: string) => void;
|
||
}) {
|
||
const t = useT();
|
||
return (
|
||
<div className="viewer-module-pointer" role="note">
|
||
<Icon name="info" size={20} />
|
||
<h2 className="viewer-module-pointer__title">{t('fileViewer.jsxModuleTitle')}</h2>
|
||
<p className="viewer-module-pointer__body">{t('fileViewer.jsxModuleBody')}</p>
|
||
<p className="viewer-module-pointer__cta">{t('fileViewer.jsxModuleCta')}</p>
|
||
<ul className="viewer-module-pointer__entries">
|
||
{entries.map((name) => (
|
||
<li key={name}>
|
||
<button
|
||
type="button"
|
||
className="viewer-module-pointer__link"
|
||
onClick={() => onOpenEntry?.(name)}
|
||
disabled={!onOpenEntry}
|
||
>
|
||
<Icon name="external-link" size={14} />
|
||
<span>{name}</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReactComponentViewer({
|
||
projectId,
|
||
file,
|
||
onOpenFileReplacing,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
onOpenFileReplacing?: (openName: string, closeName: string) => void;
|
||
}) {
|
||
const t = useT();
|
||
const [mode, setMode] = useState<'preview' | 'source'>('preview');
|
||
const [source, setSource] = useState<string | null>(null);
|
||
const [srcDoc, setSrcDoc] = useState('');
|
||
const [reloadKey, setReloadKey] = useState(0);
|
||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||
const shareRef = useRef<HTMLDivElement | null>(null);
|
||
// HTML entries that load this file as a Babel module. `null` = still
|
||
// checking; `[]` = standalone artifact; non-empty = a module of a
|
||
// multi-file React prototype, which has no standalone preview. Issue #2744.
|
||
const [moduleEntries, setModuleEntries] = useState<string[] | null>(null);
|
||
const isModule = (moduleEntries?.length ?? 0) > 0;
|
||
|
||
useEffect(() => {
|
||
setSource(null);
|
||
let cancelled = false;
|
||
void fetchProjectFileText(projectId, file.name).then((text) => {
|
||
if (!cancelled) setSource(text ?? '');
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId, file.name, file.mtime, reloadKey]);
|
||
|
||
// Detect whether this .jsx/.tsx is a module loaded by a sibling HTML entry.
|
||
// Runs before any srcdoc is built so a module never flashes the raw
|
||
// "No React component export found" error from the React runtime.
|
||
useEffect(() => {
|
||
setModuleEntries(null);
|
||
let cancelled = false;
|
||
void (async () => {
|
||
try {
|
||
const files = await fetchProjectFiles(projectId);
|
||
const htmlNames = files
|
||
.filter((entry) => /\.html?$/i.test(entry.name))
|
||
.map((entry) => entry.name);
|
||
const htmlSources = new Map<string, string>();
|
||
await Promise.all(
|
||
htmlNames.map(async (name) => {
|
||
const text = await fetchProjectFileText(projectId, name).catch(() => null);
|
||
if (text != null) htmlSources.set(name, text);
|
||
}),
|
||
);
|
||
if (cancelled) return;
|
||
setModuleEntries(findHtmlEntriesReferencing(file.name, htmlSources));
|
||
} catch {
|
||
if (!cancelled) setModuleEntries([]);
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId, file.name, file.mtime, reloadKey]);
|
||
|
||
useEffect(() => {
|
||
if (!shareMenuOpen) return;
|
||
const onDocClick = (e: MouseEvent) => {
|
||
if (!shareRef.current) return;
|
||
if (!shareRef.current.contains(e.target as Node)) setShareMenuOpen(false);
|
||
};
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') setShareMenuOpen(false);
|
||
};
|
||
document.addEventListener('mousedown', onDocClick);
|
||
document.addEventListener('keydown', onKey);
|
||
return () => {
|
||
document.removeEventListener('mousedown', onDocClick);
|
||
document.removeEventListener('keydown', onKey);
|
||
};
|
||
}, [shareMenuOpen]);
|
||
|
||
const exportTitle = file.name.replace(/\.(jsx|tsx)$/i, '') || file.name;
|
||
const sourceExtension = file.name.toLowerCase().endsWith('.tsx') ? '.tsx' : '.jsx';
|
||
|
||
useEffect(() => {
|
||
if (source === null || moduleEntries === null || isModule) {
|
||
// No source yet, still checking module status, or this file is a module
|
||
// with no standalone preview — never build the React runtime srcdoc.
|
||
setSrcDoc('');
|
||
return;
|
||
}
|
||
|
||
let cancelled = false;
|
||
const buildSrcDoc = () => {
|
||
const nextSrcDoc = buildReactComponentSrcdoc(source, { title: exportTitle });
|
||
if (!cancelled) setSrcDoc(nextSrcDoc);
|
||
};
|
||
|
||
if (source.length > 100_000) {
|
||
setSrcDoc('');
|
||
const timeout = window.setTimeout(buildSrcDoc, 0);
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearTimeout(timeout);
|
||
};
|
||
}
|
||
|
||
buildSrcDoc();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [source, exportTitle, moduleEntries, isModule]);
|
||
|
||
return (
|
||
<div className="viewer react-component-viewer">
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left">
|
||
<button
|
||
type="button"
|
||
className="icon-only"
|
||
onClick={() => setReloadKey((n) => n + 1)}
|
||
title={`${t('fileViewer.reload')} ${t('fileViewer.preview')}`}
|
||
aria-label={`${t('fileViewer.reloadAria')} ${t('fileViewer.preview')}`}
|
||
>
|
||
<Icon name="reload" size={14} />
|
||
</button>
|
||
<span className="viewer-meta">
|
||
{t('fileViewer.reactMeta', { size: humanSize(file.size) })}
|
||
</span>
|
||
</div>
|
||
<div className="viewer-toolbar-actions">
|
||
<div className="viewer-tabs">
|
||
<button
|
||
type="button"
|
||
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
|
||
onClick={() => setMode('preview')}
|
||
>
|
||
{t('fileViewer.preview')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
|
||
onClick={() => setMode('source')}
|
||
>
|
||
{t('fileViewer.source')}
|
||
</button>
|
||
</div>
|
||
{source !== null ? (
|
||
<>
|
||
<span className="viewer-divider" aria-hidden />
|
||
<div className="share-menu" ref={shareRef}>
|
||
<button
|
||
type="button"
|
||
className="viewer-action primary viewer-action-export"
|
||
aria-haspopup="menu"
|
||
aria-expanded={shareMenuOpen}
|
||
onClick={() => setShareMenuOpen((v) => !v)}
|
||
>
|
||
<span className="export-action-spacer" aria-hidden />
|
||
<span>{t('fileViewer.shareLabel')}</span>
|
||
<RemixIcon name="arrow-down-s-line" size={14} />
|
||
</button>
|
||
{shareMenuOpen ? (
|
||
<div className="share-menu-popover" role="menu">
|
||
<div className="share-menu-section-label" role="presentation">
|
||
{t('common.share')}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
exportAsJsx(source, exportTitle, sourceExtension);
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="file-code-line" size={15} /></span>
|
||
<span>{t('fileViewer.exportJsx')}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
exportReactComponentAsHtml(source, exportTitle);
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="file-line" size={15} /></span>
|
||
<span>{t('fileViewer.exportReactHtml')}</span>
|
||
</button>
|
||
<div className="share-menu-divider" />
|
||
<button
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
exportReactComponentAsZip(source, exportTitle, sourceExtension);
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="file-zip-line" size={15} /></span>
|
||
<span>{t('fileViewer.exportZip')}</span>
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="viewer-body">
|
||
{isModule && mode === 'preview' ? (
|
||
// Module of a multi-file prototype: no standalone preview, so the
|
||
// Preview tab shows a pointer to the HTML entry. The Source tab still
|
||
// renders the raw code below. Issue #2744.
|
||
<ReactModulePointer
|
||
entries={moduleEntries ?? []}
|
||
onOpenEntry={(htmlName) => onOpenFileReplacing?.(htmlName, file.name)}
|
||
/>
|
||
) : source === null || (mode === 'preview' && !srcDoc) ? (
|
||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||
) : mode === 'preview' ? (
|
||
<PreviewDrawOverlay>
|
||
<iframe
|
||
data-testid="react-component-preview-frame"
|
||
title={file.name}
|
||
sandbox="allow-scripts allow-downloads"
|
||
srcDoc={srcDoc}
|
||
style={{ width: '100%', height: '100%', border: 0 }}
|
||
/>
|
||
</PreviewDrawOverlay>
|
||
) : (
|
||
<CodeWithLines text={source} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BinaryViewer({
|
||
projectId,
|
||
file,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
}) {
|
||
const t = useT();
|
||
return (
|
||
<div className="viewer binary-viewer">
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left">
|
||
<span className="viewer-meta">
|
||
{t('fileViewer.binaryMeta', { size: humanSize(file.size) })}
|
||
</span>
|
||
</div>
|
||
<FileActions projectId={projectId} file={file} />
|
||
</div>
|
||
<div className="viewer-body">
|
||
<div className="viewer-empty">
|
||
{t('fileViewer.binaryNote', { size: file.size })}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DocumentPreviewViewer({
|
||
projectId,
|
||
file,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
}) {
|
||
const t = useT();
|
||
const [preview, setPreview] = useState<ProjectFilePreview | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setLoading(true);
|
||
setPreview(null);
|
||
void fetchProjectFilePreview(projectId, file.name).then((next) => {
|
||
if (!cancelled) {
|
||
setPreview(next);
|
||
setLoading(false);
|
||
}
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId, file.name, file.mtime]);
|
||
|
||
return (
|
||
<div className="viewer document-viewer">
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left">
|
||
<span className="viewer-meta">
|
||
{documentMetaLabel(file, t)} · {humanSize(file.size)}
|
||
</span>
|
||
</div>
|
||
<FileActions projectId={projectId} file={file} />
|
||
</div>
|
||
<div className="viewer-body">
|
||
{loading ? (
|
||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||
) : preview ? (
|
||
<div className="document-preview">
|
||
<h2>{preview.title}</h2>
|
||
{preview.sections.map((section, idx) => (
|
||
<section key={`${section.title}-${idx}`}>
|
||
<h3>{section.title}</h3>
|
||
{section.lines.map((line, lineIdx) => (
|
||
<p key={`${lineIdx}-${line}`}>{line}</p>
|
||
))}
|
||
</section>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="viewer-empty">{t('fileViewer.previewUnavailable')}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function HtmlViewer({
|
||
projectId,
|
||
projectKind,
|
||
file,
|
||
liveHtml,
|
||
filesRefreshKey = 0,
|
||
isDeck,
|
||
onExportAsPptx,
|
||
streaming,
|
||
commentSendDisabled,
|
||
previewComments = [],
|
||
onSavePreviewComment,
|
||
onRemovePreviewComment,
|
||
onSendBoardCommentAttachments,
|
||
onFileSaved,
|
||
commentPortalId,
|
||
onCommentModeChange,
|
||
}: {
|
||
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<PreviewComment | null>;
|
||
onRemovePreviewComment?: (commentId: string) => Promise<void>;
|
||
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<boolean | void> | boolean | void;
|
||
onFileSaved?: () => Promise<void> | void;
|
||
commentPortalId?: string;
|
||
onCommentModeChange?: (active: boolean) => void;
|
||
}) {
|
||
const t = useT();
|
||
const analytics = useAnalytics();
|
||
// Shared helper for the share menu: emit studio_click share_option on
|
||
// entry and artifact_export_result on resolution. Sync exports report
|
||
// success immediately after the call returns; async exports get .then
|
||
// / .catch. The same request_id threads both events so PostHog can
|
||
// stitch click → result via $insert_id correlation.
|
||
const fireShareExport = (
|
||
format:
|
||
| 'pdf'
|
||
| 'pptx'
|
||
| 'zip'
|
||
| 'html'
|
||
| 'markdown'
|
||
| 'template'
|
||
| 'vercel'
|
||
| 'cloudflare_pages',
|
||
fn: () => Promise<unknown> | unknown,
|
||
) => {
|
||
const requestId = analytics.newRequestId();
|
||
const artifactId = anonymizeArtifactId({ projectId, fileName: file.name });
|
||
const artifactKind = artifactKindToTracking({ fileKind: file.kind ?? null });
|
||
trackShareOptionPopoverClick(
|
||
analytics.track,
|
||
{
|
||
page_name: 'artifact',
|
||
area: 'share_option_popover',
|
||
artifact_id: artifactId,
|
||
artifact_kind: artifactKind,
|
||
element: format,
|
||
project_id: projectId,
|
||
project_kind: projectKind,
|
||
},
|
||
{ requestId },
|
||
);
|
||
const started = performance.now();
|
||
const finish = (result: 'success' | 'failed' | 'cancelled', errorCode?: string) => {
|
||
trackArtifactExportResult(
|
||
analytics.track,
|
||
{
|
||
page_name: 'artifact',
|
||
area: 'share_option_popover',
|
||
artifact_id: artifactId,
|
||
artifact_kind: artifactKind,
|
||
project_id: projectId,
|
||
project_kind: projectKind,
|
||
export_format: format,
|
||
result,
|
||
...(errorCode ? { error_code: errorCode } : {}),
|
||
export_duration_ms: Math.round(performance.now() - started),
|
||
},
|
||
{ requestId },
|
||
);
|
||
};
|
||
const toastFormats = new Set(['pdf', 'pptx', 'zip', 'html', 'markdown']);
|
||
try {
|
||
const out = fn();
|
||
if (out && typeof (out as Promise<unknown>).then === 'function') {
|
||
(out as Promise<unknown>).then(
|
||
() => { finish('success'); if (toastFormats.has(format)) setExportToast(t('fileViewer.exportStarted')); },
|
||
(err) => finish('failed', err instanceof Error ? err.name : 'UNKNOWN'),
|
||
);
|
||
} else {
|
||
finish('success');
|
||
if (toastFormats.has(format)) setExportToast(t('fileViewer.exportStarted'));
|
||
}
|
||
} catch (err) {
|
||
finish('failed', err instanceof Error ? err.name : 'UNKNOWN');
|
||
}
|
||
};
|
||
// P0 helpers — keep the artifact_id + artifact_kind derivation in one place
|
||
// so each per-button onClick stays a one-liner. We compute lazily inside the
|
||
// closure because `file.kind` / `file.name` can change as the user navigates
|
||
// tabs without remounting HtmlViewer.
|
||
const fireArtifactToolbarClick = (
|
||
element:
|
||
| 'reload'
|
||
| 'preview'
|
||
| 'source'
|
||
| 'tweaks'
|
||
| 'draw'
|
||
| 'comment'
|
||
| 'pods'
|
||
| 'inspect'
|
||
| 'edit'
|
||
| 'zoom_out'
|
||
| 'zoom_level_dropdown'
|
||
| 'zoom_in',
|
||
) => {
|
||
trackArtifactToolbarClick(analytics.track, {
|
||
page_name: 'artifact',
|
||
area: 'artifact_toolbar',
|
||
element,
|
||
artifact_id: anonymizeArtifactId({ projectId, fileName: file.name }),
|
||
artifact_kind: artifactKindToTracking({ fileKind: file.kind ?? null }),
|
||
});
|
||
};
|
||
const fireArtifactHeaderClick = (
|
||
element: 'back' | 'edit' | 'present_dropdown' | 'share_dropdown' | 'settings',
|
||
) => {
|
||
trackArtifactHeaderClick(analytics.track, {
|
||
page_name: 'artifact',
|
||
area: 'artifact_header',
|
||
element,
|
||
artifact_id: anonymizeArtifactId({ projectId, fileName: file.name }),
|
||
artifact_kind: artifactKindToTracking({ fileKind: file.kind ?? null }),
|
||
});
|
||
};
|
||
const firePresentPopoverClick = (
|
||
element: 'in_this_tab' | 'fullscreen' | 'new_tab',
|
||
) => {
|
||
trackPresentPopoverClick(analytics.track, {
|
||
page_name: 'artifact',
|
||
area: 'present_popover',
|
||
element,
|
||
artifact_id: anonymizeArtifactId({ projectId, fileName: file.name }),
|
||
artifact_kind: artifactKindToTracking({ fileKind: file.kind ?? null }),
|
||
});
|
||
};
|
||
const fireCommentPopoverClick = (
|
||
element: 'save_comment' | 'send_to_chat' | 'add_note',
|
||
) => {
|
||
trackCommentPopoverClick(analytics.track, {
|
||
page_name: 'artifact',
|
||
area: 'comment_popover',
|
||
element,
|
||
artifact_id: anonymizeArtifactId({ projectId, fileName: file.name }),
|
||
artifact_kind: artifactKindToTracking({ fileKind: file.kind ?? null }),
|
||
});
|
||
};
|
||
const [mode, setMode] = useState<'preview' | 'source'>('preview');
|
||
const [source, setSource] = useState<string | null>(liveHtml ?? null);
|
||
const [inlinedSource, setInlinedSource] = useState<string | null>(null);
|
||
const [zoom, setZoom] = useState(100);
|
||
const fileViewportKey = previewViewportStateKey(projectId, file);
|
||
const [previewViewport, setPreviewViewportState] = useState<PreviewViewportId>(
|
||
() => htmlPreviewViewportState.get(fileViewportKey) ?? 'desktop',
|
||
);
|
||
const setPreviewViewport = useCallback((viewport: PreviewViewportId) => {
|
||
setPreviewViewportCached(fileViewportKey, viewport);
|
||
setPreviewViewportState(viewport);
|
||
}, [fileViewportKey]);
|
||
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
|
||
const zoomMenuRef = useRef<HTMLDivElement | null>(null);
|
||
const [presentMenuOpen, setPresentMenuOpen] = useState(false);
|
||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||
const [exportReadyNudge, setExportReadyNudge] = useState(false);
|
||
const exportReadyNudgeSeenRef = useRef<Set<string>>(new Set());
|
||
// Template save UX. We surface a transient "Saved" pill in the share
|
||
// menu so the user gets feedback without a noisy toast layer.
|
||
const [savingTemplate, setSavingTemplate] = useState(false);
|
||
const [templateNote, setTemplateNote] = useState<string | null>(null);
|
||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||
const [templateName, setTemplateName] = useState('');
|
||
|
||
useEffect(() => {
|
||
setPreviewViewportState(htmlPreviewViewportState.get(fileViewportKey) ?? 'desktop');
|
||
}, [fileViewportKey]);
|
||
const [templateDescription, setTemplateDescription] = useState('');
|
||
const [templateSaveError, setTemplateSaveError] = useState<string | null>(null);
|
||
const [deployment, setDeployment] = useState<WebDeploymentInfo | null>(null);
|
||
const [deploymentsByProvider, setDeploymentsByProvider] = useState<Partial<Record<WebDeployProviderId, WebDeploymentInfo>>>({});
|
||
const [deployModalOpen, setDeployModalOpen] = useState(false);
|
||
const [deployConfig, setDeployConfig] = useState<WebDeployConfigResponse | null>(null);
|
||
const [deploying, setDeploying] = useState(false);
|
||
const [deployPhase, setDeployPhase] = useState<'idle' | 'deploying' | 'preparing-link'>('idle');
|
||
const [savingDeployConfig, setSavingDeployConfig] = useState(false);
|
||
const [deployError, setDeployError] = useState<string | null>(null);
|
||
const [deployResult, setDeployResult] = useState<WebDeployProjectFileResponse | null>(null);
|
||
const [copiedDeployLink, setCopiedDeployLink] = useState<string | null>(null);
|
||
const [deployProviderId, setDeployProviderId] = useState<WebDeployProviderId>(DEFAULT_DEPLOY_PROVIDER_ID);
|
||
const [deployToken, setDeployToken] = useState('');
|
||
const [teamId, setTeamId] = useState('');
|
||
const [teamSlug, setTeamSlug] = useState('');
|
||
const [cloudflareAccountId, setCloudflareAccountId] = useState('');
|
||
const [cloudflareZones, setCloudflareZones] = useState<CloudflarePagesZoneOption[]>([]);
|
||
const [cloudflareZonesLoading, setCloudflareZonesLoading] = useState(false);
|
||
const [cloudflareZonesError, setCloudflareZonesError] = useState<string | null>(null);
|
||
const [cloudflareZoneId, setCloudflareZoneId] = useState('');
|
||
const [cloudflareDomainPrefix, setCloudflareDomainPrefix] = useState('');
|
||
const deployProviderLoadSeqRef = useRef(0);
|
||
const [inTabPresent, setInTabPresent] = useState(false);
|
||
const [reloadKey, setReloadKey] = useState(0);
|
||
const [boardMode, setBoardMode] = useState(false);
|
||
const [commentPanelOpen, setCommentPanelOpen] = useState(false);
|
||
const [commentCreateMode, setCommentCreateMode] = useState(false);
|
||
const [boardTool, setBoardTool] = useState<BoardTool>('inspect');
|
||
const [inspectMode, setInspectMode] = useState(false);
|
||
const [agentToolsOpen, setAgentToolsOpen] = useState(false);
|
||
const [drawOverlayOpen, setDrawOverlayOpen] = useState(false);
|
||
// for hint managing hint box state
|
||
const [openHintBox, setOpenHintBox] = useState(true);
|
||
const [manualEditMode, setManualEditModeRaw] = useState(false);
|
||
const [manualEditFrozenSource, setManualEditFrozenSource] = useState<string | null>(null);
|
||
const [manualEditViewportWidth, setManualEditViewportWidth] = useState<number | null>(null);
|
||
const [commentPortalHost, setCommentPortalHost] = useState<HTMLElement | null>(null);
|
||
const [previewBodyRef, previewBodySize] = usePreviewCanvasSize<HTMLDivElement>();
|
||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||
const urlPreviewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||
const srcDocPreviewIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||
const activatedSrcDocTransportHtmlRef = useRef<string | null>(null);
|
||
// Tracks the iframe DOM node whose dedupe ref was last reset by the
|
||
// srcDoc onLoad handler. We reset the dedupe exactly once per freshly
|
||
// mounted iframe (the first load is the shell HTML), and skip every
|
||
// subsequent load on the same node (those are our own
|
||
// document.open/write/close inside the shell). See onLoad below for
|
||
// the infinite-loop story (issue #2361).
|
||
const srcDocFrameDedupeResetForRef = useRef<HTMLIFrameElement | null>(null);
|
||
const isActivePreviewIframeSource = useCallback((source: MessageEventSource | null) => {
|
||
return !!source && source === iframeRef.current?.contentWindow;
|
||
}, []);
|
||
const isOurPreviewIframeSource = useCallback((source: MessageEventSource | null) => {
|
||
if (!source) return false;
|
||
return (
|
||
source === iframeRef.current?.contentWindow ||
|
||
source === urlPreviewIframeRef.current?.contentWindow ||
|
||
source === srcDocPreviewIframeRef.current?.contentWindow
|
||
);
|
||
}, []);
|
||
const previewScrollRestoreRef = useRef<{
|
||
hostLeft: number;
|
||
hostTop: number;
|
||
frameLeft: number;
|
||
frameTop: number;
|
||
canvasLeft: number;
|
||
canvasTop: number;
|
||
expiresAt: number;
|
||
} | null>(null);
|
||
const previewScrollPositionRef = useRef({
|
||
frameLeft: 0,
|
||
frameTop: 0,
|
||
canvasLeft: 0,
|
||
canvasTop: 0,
|
||
});
|
||
const previewScrollRequestAtRef = useRef(0);
|
||
const dcViewportRef = useRef({
|
||
x: 0,
|
||
y: 0,
|
||
scale: 1,
|
||
});
|
||
const dcViewportRestoreAtRef = useRef(0);
|
||
const setManualEditMode = useCallback((next: boolean | ((prev: boolean) => boolean)) => {
|
||
setManualEditModeRaw((prev) => {
|
||
const value = typeof next === 'function' ? (next as (p: boolean) => boolean)(prev) : next;
|
||
if (value !== prev && !value) {
|
||
setManualEditFrozenSource(null);
|
||
setManualEditViewportWidth(null);
|
||
}
|
||
return value;
|
||
});
|
||
}, []);
|
||
useEffect(() => {
|
||
onCommentModeChange?.(commentPanelOpen);
|
||
}, [commentPanelOpen, onCommentModeChange]);
|
||
useEffect(() => () => {
|
||
onCommentModeChange?.(false);
|
||
}, [onCommentModeChange]);
|
||
useEffect(() => {
|
||
if (!commentPanelOpen || !commentPortalId) {
|
||
setCommentPortalHost(null);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
let raf = 0;
|
||
const findHost = () => {
|
||
if (cancelled) return;
|
||
const host = document.getElementById(commentPortalId);
|
||
setCommentPortalHost(host);
|
||
if (!host) raf = window.requestAnimationFrame(findHost);
|
||
};
|
||
findHost();
|
||
return () => {
|
||
cancelled = true;
|
||
if (raf) window.cancelAnimationFrame(raf);
|
||
setCommentPortalHost(null);
|
||
};
|
||
}, [commentPanelOpen, commentPortalId]);
|
||
const capturePreviewScrollPosition = useCallback(() => {
|
||
const host = previewBodyRef.current;
|
||
let frameLeft = 0;
|
||
let frameTop = 0;
|
||
let canvasLeft = 0;
|
||
let canvasTop = 0;
|
||
try {
|
||
const frameDocument = iframeRef.current?.contentWindow?.document;
|
||
const frameScroll = frameDocument?.scrollingElement;
|
||
const canvasScroll = frameDocument?.querySelector<HTMLElement>('.design-canvas');
|
||
frameLeft = frameScroll?.scrollLeft ?? 0;
|
||
frameTop = frameScroll?.scrollTop ?? 0;
|
||
canvasLeft = canvasScroll?.scrollLeft ?? 0;
|
||
canvasTop = canvasScroll?.scrollTop ?? 0;
|
||
} catch {
|
||
frameLeft = 0;
|
||
frameTop = 0;
|
||
canvasLeft = 0;
|
||
canvasTop = 0;
|
||
}
|
||
previewScrollRestoreRef.current = {
|
||
hostLeft: host?.scrollLeft ?? 0,
|
||
hostTop: host?.scrollTop ?? 0,
|
||
frameLeft: frameLeft || previewScrollPositionRef.current.frameLeft,
|
||
frameTop: frameTop || previewScrollPositionRef.current.frameTop,
|
||
canvasLeft: canvasLeft || previewScrollPositionRef.current.canvasLeft,
|
||
canvasTop: canvasTop || previewScrollPositionRef.current.canvasTop,
|
||
expiresAt: Date.now() + 5000,
|
||
};
|
||
}, []);
|
||
const restorePreviewScrollPosition = useCallback(() => {
|
||
const snapshot = previewScrollRestoreRef.current;
|
||
if (!snapshot) return;
|
||
if (Date.now() > snapshot.expiresAt) {
|
||
previewScrollRestoreRef.current = null;
|
||
return;
|
||
}
|
||
const apply = () => {
|
||
const previewBody = previewBodyRef.current;
|
||
if (typeof previewBody?.scrollTo === 'function') {
|
||
previewBody.scrollTo(snapshot.hostLeft, snapshot.hostTop);
|
||
}
|
||
try {
|
||
const frameDocument = iframeRef.current?.contentWindow?.document;
|
||
frameDocument?.scrollingElement?.scrollTo(snapshot.frameLeft, snapshot.frameTop);
|
||
frameDocument?.querySelector<HTMLElement>('.design-canvas')?.scrollTo(snapshot.canvasLeft, snapshot.canvasTop);
|
||
iframeRef.current?.contentWindow?.postMessage({
|
||
type: 'od:preview-scroll-restore',
|
||
frameLeft: snapshot.frameLeft,
|
||
frameTop: snapshot.frameTop,
|
||
canvasLeft: snapshot.canvasLeft,
|
||
canvasTop: snapshot.canvasTop,
|
||
}, '*');
|
||
} catch {}
|
||
};
|
||
window.requestAnimationFrame(() => {
|
||
window.requestAnimationFrame(() => {
|
||
apply();
|
||
window.setTimeout(apply, 80);
|
||
window.setTimeout(() => {
|
||
if (previewScrollRestoreRef.current === snapshot) {
|
||
apply();
|
||
}
|
||
}, 260);
|
||
});
|
||
});
|
||
}, []);
|
||
const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([]);
|
||
const [selectedManualEditTarget, setSelectedManualEditTarget] = useState<ManualEditTarget | null>(null);
|
||
const [manualEditPanelPosition, setManualEditPanelPosition] = useState<{ left: number; top: number } | null>(null);
|
||
const selectedManualEditTargetIdRef = useRef<string | null>(null);
|
||
const [manualEditDraft, setManualEditDraft] = useState<ManualEditDraft>(() => emptyManualEditDraft());
|
||
const [manualEditHistory, setManualEditHistory] = useState<ManualEditHistoryEntry[]>([]);
|
||
const [manualEditUndone, setManualEditUndone] = useState<ManualEditHistoryEntry[]>([]);
|
||
const [manualEditError, setManualEditError] = useState<string | null>(null);
|
||
const [manualEditSaving, setManualEditSaving] = useState(false);
|
||
const manualEditSavingRef = useRef(false);
|
||
const manualEditPendingStyleRef = useRef<ManualEditPendingStyleSave | null>(null);
|
||
const manualEditStyleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const manualEditPreviewVersionRef = useRef(0);
|
||
const sourceRef = useRef<string | null>(source);
|
||
const sourceFileKeyRef = useRef<string | null>(null);
|
||
const templateNameId = useId();
|
||
const templateDescriptionId = useId();
|
||
const imageExportTitleId = useId();
|
||
// Opt back into the legacy inline-asset srcDoc path via `?forceInline=1`
|
||
// on the host page. Lets users escape-hatch around the URL-load default
|
||
// for non-deck HTML that depends on the in-iframe localStorage shim.
|
||
const forceInline = useMemo(
|
||
() => (typeof window === 'undefined' ? false : parseForceInline(window.location.search)),
|
||
[],
|
||
);
|
||
const [activeCommentTarget, setActiveCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
|
||
const [hoveredCommentTarget, setHoveredCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
|
||
const [hoveredPodMemberId, setHoveredPodMemberId] = useState<string | null>(null);
|
||
const [activePreviewCommentId, setActivePreviewCommentId] = useState<string | null>(null);
|
||
const [liveCommentTargets, setLiveCommentTargets] = useState<Map<string, PreviewCommentSnapshot>>(() => new Map());
|
||
const liveCommentTargetsRef = useRef(liveCommentTargets);
|
||
const [commentDraft, setCommentDraft] = useState('');
|
||
// Inspect mode shares the iframe selection bridge with comment mode but
|
||
// routes the picked element to a side panel that mutates per-element CSS
|
||
// overrides via postMessage. The host owns the authoritative override map:
|
||
// it is hydrated from the artifact's persisted <style> block on load and
|
||
// mutated only by host-driven onApply / reset actions. Save-to-source
|
||
// serializes that host map directly — iframe od:inspect-overrides messages
|
||
// are preview acknowledgements and never feed save input, so artifact JS
|
||
// forging a postMessage cannot tamper with what gets persisted.
|
||
const [activeInspectTarget, setActiveInspectTarget] = useState<InspectTarget | null>(null);
|
||
const [inspectOverrides, setInspectOverrides] = useState<InspectOverrideMap>(() =>
|
||
typeof source === 'string' ? parseInspectOverridesFromSource(source) : {},
|
||
);
|
||
// Track which `source` value the host map was last hydrated from so the
|
||
// setState-during-render hydration below only fires when the artifact
|
||
// text actually changes (file switch, save round-trip, live edits). The
|
||
// ref is initialised to `source` so the matching useState initialiser
|
||
// above counts as the first hydration.
|
||
const inspectHydratedSourceRef = useRef<string | null | undefined>(source);
|
||
const [savingInspect, setSavingInspect] = useState(false);
|
||
const [inspectSavedAt, setInspectSavedAt] = useState<number | null>(null);
|
||
const [inspectError, setInspectError] = useState<string | null>(null);
|
||
const [queuedBoardNotes, setQueuedBoardNotes] = useState<string[]>([]);
|
||
const [sendingBoardBatch, setSendingBoardBatch] = useState(false);
|
||
const [commentSavedToast, setCommentSavedToast] = useState<string | null>(null);
|
||
const [templateSavedToast, setTemplateSavedToast] = useState<string | null>(null);
|
||
const [deploySavedToast, setDeploySavedToast] = useState<{ message: string; details: string } | null>(null);
|
||
const [imageExportModalOpen, setImageExportModalOpen] = useState(false);
|
||
const [imageExportFormat, setImageExportFormat] = useState<ImageExportFormat>('png');
|
||
const [imageExportBusy, setImageExportBusy] = useState(false);
|
||
const [imageExportPreparing, setImageExportPreparing] = useState(false);
|
||
const [imageExportError, setImageExportError] = useState<string | null>(null);
|
||
const [imageExportSavedToast, setImageExportSavedToast] = useState<{ message: string; details: string } | null>(null);
|
||
const [imageExportPreparedBlob, setImageExportPreparedBlob] = useState<{ format: ImageExportFormat; blob: Blob } | null>(null);
|
||
const imageExportSnapshotDataUrlRef = useRef<string | null>(null);
|
||
const imageExportPrepareIdRef = useRef(0);
|
||
const [exportToast, setExportToast] = useState<string | null>(null);
|
||
const [selectedSideCommentIds, setSelectedSideCommentIds] = useState<Set<string>>(() => new Set());
|
||
const [commentSidePanelCollapsed, setCommentSidePanelCollapsed] = useState(false);
|
||
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>> = {};
|
||
for (const option of DEPLOY_PROVIDER_OPTIONS) {
|
||
const deploymentForProvider = items.find(
|
||
(item) => item.fileName === file.name && item.providerId === option.id && item.url?.trim(),
|
||
);
|
||
if (deploymentForProvider) next[option.id] = deploymentForProvider;
|
||
}
|
||
return next;
|
||
}
|
||
|
||
function syncDeployFormFromConfig(
|
||
providerId: WebDeployProviderId,
|
||
config: WebDeployConfigResponse | null,
|
||
) {
|
||
const matchingConfig = config?.providerId === providerId ? config : null;
|
||
setDeployProviderId(providerId);
|
||
setDeployConfig(matchingConfig);
|
||
setDeployToken(matchingConfig?.tokenMask || '');
|
||
setTeamId(matchingConfig?.teamId || '');
|
||
setTeamSlug(matchingConfig?.teamSlug || '');
|
||
setCloudflareAccountId(matchingConfig?.accountId || '');
|
||
setCloudflareZoneId(matchingConfig?.cloudflarePages?.lastZoneId || '');
|
||
setCloudflareDomainPrefix(matchingConfig?.cloudflarePages?.lastDomainPrefix || '');
|
||
}
|
||
|
||
function cloudflareConfigHintsFromForm() {
|
||
const zone = cloudflareZones.find((item) => item.id === cloudflareZoneId);
|
||
const hints = {
|
||
...(cloudflareZoneId.trim() ? { lastZoneId: cloudflareZoneId.trim() } : {}),
|
||
...((zone?.name || deployConfig?.cloudflarePages?.lastZoneName)
|
||
? { lastZoneName: zone?.name || deployConfig?.cloudflarePages?.lastZoneName }
|
||
: {}),
|
||
...(cloudflareDomainPrefix.trim()
|
||
? { lastDomainPrefix: normalizeCloudflareDomainPrefixInput(cloudflareDomainPrefix) }
|
||
: {}),
|
||
};
|
||
return Object.keys(hints).length > 0 ? hints : undefined;
|
||
}
|
||
|
||
function buildDeployConfigRequest(providerId: WebDeployProviderId): WebUpdateDeployConfigRequest {
|
||
const token = deployToken.trim();
|
||
if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID) {
|
||
return {
|
||
providerId,
|
||
token,
|
||
accountId: cloudflareAccountId.trim(),
|
||
cloudflarePages: cloudflareConfigHintsFromForm(),
|
||
};
|
||
}
|
||
return {
|
||
providerId,
|
||
token,
|
||
teamId: teamId.trim(),
|
||
teamSlug: teamSlug.trim(),
|
||
};
|
||
}
|
||
|
||
async function loadDeployProvider(
|
||
providerId: WebDeployProviderId,
|
||
options?: { fallbackToExisting?: boolean },
|
||
) {
|
||
const requestSeq = ++deployProviderLoadSeqRef.current;
|
||
setDeployProviderId(providerId);
|
||
const deployments = await fetchProjectDeployments(projectId);
|
||
const nextDeploymentsByProvider = deploymentMapForCurrentFile(deployments);
|
||
const exactDeployment = nextDeploymentsByProvider[providerId] ?? null;
|
||
const fallbackDeployment = options?.fallbackToExisting
|
||
? Object.values(nextDeploymentsByProvider)[0] ?? null
|
||
: null;
|
||
const currentDeployment = exactDeployment ?? fallbackDeployment;
|
||
// Use the explicit providerId for config/form so a fallback deployment from
|
||
// another provider only fills the existing-URL display, never the form/credentials.
|
||
const config = await fetchDeployConfig(providerId);
|
||
if (requestSeq !== deployProviderLoadSeqRef.current) {
|
||
return { config: null, currentDeployment: null };
|
||
}
|
||
syncDeployFormFromConfig(providerId, config);
|
||
setDeploymentsByProvider(nextDeploymentsByProvider);
|
||
setDeployment(currentDeployment ?? null);
|
||
setDeployResult(currentDeployment ?? null);
|
||
if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID && config?.configured) {
|
||
void loadCloudflareZones(config, { requestSeq });
|
||
}
|
||
return { config, currentDeployment };
|
||
}
|
||
|
||
async function loadCloudflareZones(
|
||
config: WebDeployConfigResponse | null = deployConfig,
|
||
options?: { requestSeq?: number },
|
||
) {
|
||
if (!config?.configured || config.providerId !== CLOUDFLARE_PAGES_PROVIDER_ID) return;
|
||
const requestSeq = options?.requestSeq ?? deployProviderLoadSeqRef.current;
|
||
setCloudflareZonesLoading(true);
|
||
setCloudflareZonesError(null);
|
||
try {
|
||
const response = await fetchCloudflarePagesZones();
|
||
if (requestSeq !== deployProviderLoadSeqRef.current) return;
|
||
const zones = response?.zones ?? [];
|
||
setCloudflareZones(zones);
|
||
const hintedZoneId = response?.cloudflarePages?.lastZoneId || config.cloudflarePages?.lastZoneId || '';
|
||
const nextZoneId = hintedZoneId && zones.some((zone) => zone.id === hintedZoneId)
|
||
? hintedZoneId
|
||
: zones[0]?.id || '';
|
||
setCloudflareZoneId(nextZoneId);
|
||
const hintedPrefix = response?.cloudflarePages?.lastDomainPrefix || config.cloudflarePages?.lastDomainPrefix || '';
|
||
if (hintedPrefix) setCloudflareDomainPrefix(hintedPrefix);
|
||
} catch (err) {
|
||
if (requestSeq !== deployProviderLoadSeqRef.current) return;
|
||
setCloudflareZones([]);
|
||
setCloudflareZonesError(err instanceof Error ? err.message : t('fileViewer.cloudflareZonesLoadFailed'));
|
||
} finally {
|
||
if (requestSeq === deployProviderLoadSeqRef.current) setCloudflareZonesLoading(false);
|
||
}
|
||
}
|
||
|
||
// Slide deck nav state: the iframe posts the active index + total count
|
||
// back to the host every time a slide settles. Host renders prev/next
|
||
// controls in the toolbar and reflects the count beside them.
|
||
const [slideState, setSlideState] = useState<SlideState | null>(
|
||
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
|
||
);
|
||
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(() => {
|
||
if (typeof document === 'undefined') return;
|
||
setChromeActionsHost(resolveChromeActionsHost());
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
liveCommentTargetsRef.current = liveCommentTargets;
|
||
}, [liveCommentTargets]);
|
||
|
||
useEffect(() => {
|
||
const sourceFileKey = `${projectId}\0${file.name}\0${liveHtml === undefined ? 'raw' : 'live'}`;
|
||
if (liveHtml !== undefined) {
|
||
sourceFileKeyRef.current = sourceFileKey;
|
||
setSource(liveHtml);
|
||
sourceRef.current = liveHtml;
|
||
return;
|
||
}
|
||
const fileChanged = sourceFileKeyRef.current !== sourceFileKey;
|
||
sourceFileKeyRef.current = sourceFileKey;
|
||
if (fileChanged) {
|
||
setSource(null);
|
||
sourceRef.current = null;
|
||
}
|
||
let cancelled = false;
|
||
// Cache-bust the fetch on every mtime / reload / files-refresh bump.
|
||
// Without this, an agent edit during Comment mode (srcDoc path) gets
|
||
// stale HTML from the browser HTTP cache — the source state ends up
|
||
// identical to the previous value, srcDoc is byte-equal to the last
|
||
// activated HTML, canActivateSrcDocTransport bails on the dedupe
|
||
// check, and the preview only refreshes when Comment closes and the
|
||
// url-load iframe takes over with its own ?v=mtime cache-bust.
|
||
void fetchProjectFileText(projectId, file.name, {
|
||
cacheBustKey: `${file.mtime}-${reloadKey}-${filesRefreshKey}`,
|
||
}).then((text) => {
|
||
if (cancelled) return;
|
||
// Chokidar emits agent rewrites as unlink+add+change bursts; a
|
||
// transient null mid-burst would blank source → srcDoc empty →
|
||
// shell stays on prior frame. Keep the last good text instead.
|
||
if (text == null) return;
|
||
setSource(text);
|
||
sourceRef.current = text;
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId, file.name, file.mtime, liveHtml, reloadKey, filesRefreshKey]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
setDeployResult(null);
|
||
setDeployError(null);
|
||
setCopiedDeployLink(null);
|
||
setDeployPhase('idle');
|
||
void fetchProjectDeployments(projectId).then((items) => {
|
||
if (cancelled) return;
|
||
const nextDeploymentsByProvider = deploymentMapForCurrentFile(items);
|
||
const current = nextDeploymentsByProvider[deployProviderId] ?? null;
|
||
setDeploymentsByProvider(nextDeploymentsByProvider);
|
||
setDeployment(current ?? null);
|
||
setDeployResult(current ?? null);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId, file.name, deployProviderId]);
|
||
|
||
// Detect deck-shaped HTML even when the project's skill didn't declare
|
||
// `mode: deck`. Freeform projects often produce a deck because the user
|
||
// asked for one in plain prose; without this, prev/next and Present
|
||
// never surface and the deck becomes a static, unnavigable preview.
|
||
const looksLikeDeck = useMemo(() => {
|
||
if (!source) return false;
|
||
return /class\s*=\s*['"][^'"]*\bslide\b/i.test(source);
|
||
}, [source]);
|
||
const effectiveDeck = isDeck || looksLikeDeck;
|
||
const livePreviewSource = inlinedSource ?? source;
|
||
// Freeze the iframe input on the snapshot taken at Edit-mode entry. Any
|
||
// source rewrite during edit (1.5s debounced set-style patches) stays
|
||
// invisible to the iframe — live updates flow through od-edit-preview-style
|
||
// postMessage instead, so the canvas never has to reload.
|
||
useEffect(() => {
|
||
if (manualEditMode && manualEditFrozenSource === null && livePreviewSource != null) {
|
||
setManualEditFrozenSource(livePreviewSource);
|
||
}
|
||
}, [manualEditMode, manualEditFrozenSource, livePreviewSource]);
|
||
const previewSource = (manualEditMode && manualEditFrozenSource !== null)
|
||
? manualEditFrozenSource
|
||
: livePreviewSource;
|
||
const manualEditPageStylesEnabled = typeof source === 'string' && isManualEditFullHtmlDocument(source);
|
||
const urlModeBridge = hasUrlModeBridge(source);
|
||
// When we URL-load the iframe directly, skip every in-host inlining /
|
||
// srcDoc-rebuilding step. The browser does the asset resolution itself,
|
||
// which is the whole point of the URL-load path.
|
||
// Auto-fall back to the srcDoc path when the artifact will crash under
|
||
// the URL-load iframe's bare `sandbox="allow-scripts"` — Babel-standalone
|
||
// React prototypes and any HTML that reads Web Storage at mount throw
|
||
// SecurityError without `allow-same-origin`. The srcDoc path runs
|
||
// `injectSandboxShim` before any user script, so those artifacts render.
|
||
// Memoized on `source` so HtmlViewer's frequent re-renders (board/inspect/
|
||
// edit mode toggles, slide nav) don't re-scan the HTML each time.
|
||
const needsSandboxShim = useMemo(
|
||
() => source != null && htmlNeedsSandboxShim(source),
|
||
[source],
|
||
);
|
||
const needsFocusGuard = useMemo(
|
||
() => source != null && htmlNeedsFocusGuard(source),
|
||
[source],
|
||
);
|
||
const useUrlLoadPreview = shouldUrlLoadHtmlPreview({
|
||
mode,
|
||
isDeck: effectiveDeck,
|
||
commentMode: boardMode,
|
||
editMode: manualEditMode,
|
||
urlModeBridge,
|
||
inspectMode,
|
||
drawMode: drawOverlayOpen,
|
||
forceInline: forceInline || needsSandboxShim,
|
||
needsFocusGuard,
|
||
});
|
||
const basePreviewSrcUrl = useMemo(
|
||
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odPreviewBridge=scroll`,
|
||
[projectId, file.name, file.mtime, reloadKey],
|
||
);
|
||
const [previewSrcUrl, setPreviewSrcUrl] = useState(basePreviewSrcUrl);
|
||
const activePreviewSrcUrl = (
|
||
previewSrcUrl === basePreviewSrcUrl ||
|
||
previewSrcUrl.startsWith(`${basePreviewSrcUrl}&`)
|
||
)
|
||
? previewSrcUrl
|
||
: basePreviewSrcUrl;
|
||
useEffect(() => {
|
||
setPreviewSrcUrl(basePreviewSrcUrl);
|
||
}, [basePreviewSrcUrl]);
|
||
useEffect(() => {
|
||
iframeRef.current = useUrlLoadPreview ? urlPreviewIframeRef.current : srcDocPreviewIframeRef.current;
|
||
}, [useUrlLoadPreview]);
|
||
|
||
useEffect(() => {
|
||
if (filesRefreshKey === 0) return;
|
||
const nextSrc = `${basePreviewSrcUrl}&fr=${filesRefreshKey}`;
|
||
const timeout = window.setTimeout(() => {
|
||
if (useUrlLoadPreview && urlPreviewIframeRef.current?.contentWindow) {
|
||
urlPreviewIframeRef.current.contentWindow.location.replace(nextSrc);
|
||
} else {
|
||
setPreviewSrcUrl(nextSrc);
|
||
}
|
||
}, 180);
|
||
return () => window.clearTimeout(timeout);
|
||
}, [basePreviewSrcUrl, filesRefreshKey, useUrlLoadPreview]);
|
||
|
||
useEffect(() => {
|
||
setInlinedSource(null);
|
||
if (useUrlLoadPreview) return;
|
||
if (!source || effectiveDeck || !hasRelativeAssetRefs(source)) return;
|
||
let cancelled = false;
|
||
void inlineRelativeAssets(source, projectId, file.name).then((next) => {
|
||
if (!cancelled) setInlinedSource(next);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [source, effectiveDeck, projectId, file.name, useUrlLoadPreview]);
|
||
|
||
const srcDoc = useMemo(
|
||
() => (previewSource ? buildSrcdoc(previewSource, {
|
||
deck: effectiveDeck,
|
||
baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
|
||
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
|
||
selectionBridge: true,
|
||
editBridge: manualEditMode,
|
||
paletteBridge: false,
|
||
previewFocusGuard: true,
|
||
}) : ''),
|
||
[previewSource, effectiveDeck, projectId, file.name, previewStateKey, manualEditMode],
|
||
);
|
||
const lazySrcDocTransport = useMemo(() => buildLazySrcdocTransport(), []);
|
||
const [srcDocTransportResetKey, setSrcDocTransportResetKey] = useState(0);
|
||
const [srcDocShellReady, setSrcDocShellReady] = useState(false);
|
||
const wasUrlLoadPreviewRef = useRef(useUrlLoadPreview);
|
||
const urlPreviewKeepAliveKey = previewIframeKeepAliveKey(projectId, file.name);
|
||
// Reset the shell-ready latch whenever the srcDoc iframe re-mounts. The
|
||
// next shell will post `od:srcdoc-transport-ready` (or fire onLoad) and
|
||
// flip this back to true. See #2253.
|
||
useEffect(() => {
|
||
setSrcDocShellReady(false);
|
||
}, [srcDocTransportResetKey]);
|
||
// Listen for the shell's ready handshake. Gating activation on this is
|
||
// what fixes the #2253 race: opening Tweaks right after a key-driven
|
||
// re-mount used to post `activate` before the shell's listener was
|
||
// installed, dropping the message and stranding the iframe on the empty
|
||
// 536-byte body.
|
||
useEffect(() => {
|
||
function onMessage(ev: MessageEvent) {
|
||
if (ev.source !== srcDocPreviewIframeRef.current?.contentWindow) return;
|
||
const data = ev.data as { type?: string } | null;
|
||
if (data?.type !== 'od:srcdoc-transport-ready') return;
|
||
setSrcDocShellReady(true);
|
||
}
|
||
window.addEventListener('message', onMessage);
|
||
return () => window.removeEventListener('message', onMessage);
|
||
}, []);
|
||
// Lazy transport preloads an empty shell only while URL-load is the active
|
||
// transport. Once srcdoc becomes active (sandbox shim, Draw, Screenshot,
|
||
// Tweaks, etc.), mount the real artifact HTML directly so we do not depend on
|
||
// a postMessage activation that can race (#2253) and strand the iframe blank
|
||
// (#2361, #2791).
|
||
const captureModeActive = drawOverlayOpen;
|
||
const useLazySrcDocTransport = !manualEditMode && !captureModeActive && useUrlLoadPreview;
|
||
const srcDocTransportContent = useLazySrcDocTransport ? lazySrcDocTransport : srcDoc;
|
||
const urlTransportSrc = useUrlLoadPreview ? activePreviewSrcUrl : 'about:blank';
|
||
const activateSrcDocTransport = useCallback((target: HTMLIFrameElement | null = srcDocPreviewIframeRef.current) => {
|
||
if (!canActivateSrcDocTransport({
|
||
srcDoc,
|
||
useUrlLoadPreview,
|
||
useLazySrcDocTransport,
|
||
shellReady: srcDocShellReady,
|
||
activatedHtml: activatedSrcDocTransportHtmlRef.current,
|
||
})) return false;
|
||
// A SECOND activation while Comment mode is on would document.open +
|
||
// write over the iframe's existing document. The window-level message
|
||
// listener survives, but iframe.onLoad does NOT refire for
|
||
// document.write, so host-side re-init (slide nav sync, scroll
|
||
// restore, bridge replay) is silently skipped — the visible page can
|
||
// drift out of sync with the host's tracked state (e.g. the page
|
||
// indicator shows 3 while the iframe rendered page 4 of the freshly
|
||
// edited deck). Force a fresh shell mount under Comment so onLoad
|
||
// fires and the full re-init pipeline runs against the new HTML.
|
||
//
|
||
// Skip the remount path in Manual Edit, where the postMessage
|
||
// activate carries the patched HTML and host-side scroll/slide
|
||
// state intentionally stays put across the patch.
|
||
if (boardMode && activatedSrcDocTransportHtmlRef.current !== null) {
|
||
activatedSrcDocTransportHtmlRef.current = null;
|
||
setSrcDocTransportResetKey((key) => key + 1);
|
||
return true;
|
||
}
|
||
const win = target?.contentWindow;
|
||
if (!win) return false;
|
||
win.postMessage({ type: 'od:srcdoc-transport-activate', html: srcDoc }, '*');
|
||
activatedSrcDocTransportHtmlRef.current = srcDoc;
|
||
return true;
|
||
}, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview, srcDocShellReady, boardMode]);
|
||
const activateLoadedSrcDocTransport = useCallback((target: HTMLIFrameElement | null = srcDocPreviewIframeRef.current) => {
|
||
if (!canActivateSrcDocTransport({
|
||
srcDoc,
|
||
useUrlLoadPreview,
|
||
useLazySrcDocTransport,
|
||
shellReady: true,
|
||
activatedHtml: activatedSrcDocTransportHtmlRef.current,
|
||
})) return false;
|
||
const win = target?.contentWindow;
|
||
if (!win) return false;
|
||
win.postMessage({ type: 'od:srcdoc-transport-activate', html: srcDoc }, '*');
|
||
activatedSrcDocTransportHtmlRef.current = srcDoc;
|
||
return true;
|
||
}, [srcDoc, useLazySrcDocTransport, useUrlLoadPreview]);
|
||
const activateSrcDocSnapshotTransport = useCallback((target: HTMLIFrameElement | null = srcDocPreviewIframeRef.current) => {
|
||
if (!srcDoc) return false;
|
||
const win = target?.contentWindow;
|
||
if (!win) return false;
|
||
win.postMessage({ type: 'od:srcdoc-transport-activate', html: srcDoc }, '*');
|
||
return true;
|
||
}, [srcDoc]);
|
||
useEffect(() => {
|
||
if (useUrlLoadPreview) {
|
||
activatedSrcDocTransportHtmlRef.current = null;
|
||
if (!wasUrlLoadPreviewRef.current) {
|
||
setSrcDocTransportResetKey((key) => key + 1);
|
||
}
|
||
wasUrlLoadPreviewRef.current = true;
|
||
return;
|
||
}
|
||
if (wasUrlLoadPreviewRef.current) {
|
||
setSrcDocTransportResetKey((key) => key + 1);
|
||
activatedSrcDocTransportHtmlRef.current = null;
|
||
}
|
||
wasUrlLoadPreviewRef.current = false;
|
||
activateSrcDocTransport();
|
||
}, [activateSrcDocTransport, useUrlLoadPreview]);
|
||
|
||
// Leaving Manual Edit swaps the iframe from a fully materialized srcDoc
|
||
// document back to the lazy transport shell. Remount the shell before
|
||
// activation; posting into the old edit document can mark the new HTML as
|
||
// activated, then React replaces the iframe with an empty shell and the
|
||
// dedupe check suppresses the real activation.
|
||
const prevManualEditModeRef = useRef(manualEditMode);
|
||
useEffect(() => {
|
||
const wasInEditMode = prevManualEditModeRef.current;
|
||
const isNowInEditMode = manualEditMode;
|
||
prevManualEditModeRef.current = isNowInEditMode;
|
||
|
||
if (wasInEditMode && !isNowInEditMode && !useUrlLoadPreview) {
|
||
activatedSrcDocTransportHtmlRef.current = null;
|
||
setSrcDocShellReady(false);
|
||
setSrcDocTransportResetKey((key) => key + 1);
|
||
}
|
||
}, [manualEditMode, useUrlLoadPreview]);
|
||
|
||
useEffect(() => {
|
||
restorePreviewScrollPosition();
|
||
}, [boardMode, drawOverlayOpen, manualEditMode, srcDoc, restorePreviewScrollPosition]);
|
||
|
||
useEffect(() => {
|
||
function onMessage(ev: MessageEvent) {
|
||
if (!isOurPreviewIframeSource(ev.source)) return;
|
||
if (!isActivePreviewIframeSource(ev.source)) return;
|
||
const data = ev.data as {
|
||
type?: string;
|
||
frameLeft?: number;
|
||
frameTop?: number;
|
||
canvasLeft?: number;
|
||
canvasTop?: number;
|
||
} | null;
|
||
if (!data || data.type !== 'od:preview-scroll') return;
|
||
if (previewScrollRestoreRef.current && Number(data.canvasLeft || 0) === 0 && Number(data.canvasTop || 0) === 0) return;
|
||
if (
|
||
previewScrollPositionRef.current.canvasLeft !== 0 ||
|
||
previewScrollPositionRef.current.canvasTop !== 0
|
||
) {
|
||
const isInitialZeroReport = Number(data.canvasLeft || 0) === 0 && Number(data.canvasTop || 0) === 0;
|
||
if (isInitialZeroReport && Date.now() - previewScrollRequestAtRef.current < 1200) return;
|
||
}
|
||
previewScrollPositionRef.current = {
|
||
frameLeft: Number(data.frameLeft || 0),
|
||
frameTop: Number(data.frameTop || 0),
|
||
canvasLeft: Number(data.canvasLeft || 0),
|
||
canvasTop: Number(data.canvasTop || 0),
|
||
};
|
||
}
|
||
function onRestoreRequest(ev: MessageEvent) {
|
||
if (!isOurPreviewIframeSource(ev.source)) return;
|
||
if (!isActivePreviewIframeSource(ev.source)) return;
|
||
const data = ev.data as { type?: string } | null;
|
||
if (!data || data.type !== 'od:preview-scroll-request') return;
|
||
previewScrollRequestAtRef.current = Date.now();
|
||
const snapshot = previewScrollRestoreRef.current;
|
||
const scroll = snapshot ?? {
|
||
frameLeft: previewScrollPositionRef.current.frameLeft,
|
||
frameTop: previewScrollPositionRef.current.frameTop,
|
||
canvasLeft: previewScrollPositionRef.current.canvasLeft,
|
||
canvasTop: previewScrollPositionRef.current.canvasTop,
|
||
};
|
||
iframeRef.current?.contentWindow?.postMessage({
|
||
type: 'od:preview-scroll-restore',
|
||
frameLeft: scroll.frameLeft,
|
||
frameTop: scroll.frameTop,
|
||
canvasLeft: scroll.canvasLeft,
|
||
canvasTop: scroll.canvasTop,
|
||
}, '*');
|
||
}
|
||
function onDcViewportMessage(ev: MessageEvent) {
|
||
if (!isOurPreviewIframeSource(ev.source)) return;
|
||
if (!isActivePreviewIframeSource(ev.source)) return;
|
||
const data = ev.data as {
|
||
type?: string;
|
||
x?: number;
|
||
y?: number;
|
||
scale?: number;
|
||
} | null;
|
||
if (!data || !data.type) return;
|
||
if (data.type === '__dc_viewport') {
|
||
const x = Number(data.x || 0);
|
||
const y = Number(data.y || 0);
|
||
const scale = Number(data.scale || 1);
|
||
const hasExistingPosition = dcViewportRef.current.x !== 0 || dcViewportRef.current.y !== 0;
|
||
const isInitialZeroReport = x === 0 && y === 0 && scale === 1;
|
||
if (hasExistingPosition && isInitialZeroReport && Date.now() - dcViewportRestoreAtRef.current < 1500) return;
|
||
dcViewportRef.current = {
|
||
x: Number.isFinite(x) ? x : 0,
|
||
y: Number.isFinite(y) ? y : 0,
|
||
scale: Number.isFinite(scale) && scale > 0 ? scale : 1,
|
||
};
|
||
return;
|
||
}
|
||
if (data.type === '__dc_viewport_request') {
|
||
dcViewportRestoreAtRef.current = Date.now();
|
||
iframeRef.current?.contentWindow?.postMessage({
|
||
type: '__dc_set_viewport',
|
||
...dcViewportRef.current,
|
||
}, '*');
|
||
}
|
||
}
|
||
window.addEventListener('message', onMessage);
|
||
window.addEventListener('message', onRestoreRequest);
|
||
window.addEventListener('message', onDcViewportMessage);
|
||
return () => {
|
||
window.removeEventListener('message', onMessage);
|
||
window.removeEventListener('message', onRestoreRequest);
|
||
window.removeEventListener('message', onDcViewportMessage);
|
||
};
|
||
}, [isActivePreviewIframeSource, isOurPreviewIframeSource]);
|
||
|
||
useEffect(() => {
|
||
if (!effectiveDeck) {
|
||
setSlideState(null);
|
||
return;
|
||
}
|
||
setSlideState(htmlPreviewSlideState.get(previewStateKey) ?? null);
|
||
function onMessage(ev: MessageEvent) {
|
||
if (!isOurPreviewIframeSource(ev.source)) return;
|
||
if (!isActivePreviewIframeSource(ev.source)) return;
|
||
const data = ev?.data as
|
||
| { type?: string; active?: number; count?: number }
|
||
| null;
|
||
if (!data || data.type !== 'od:slide-state') return;
|
||
if (typeof data.active !== 'number' || typeof data.count !== 'number') return;
|
||
const next = { active: data.active, count: data.count };
|
||
setSlideStateCached(previewStateKey, next);
|
||
setSlideState(next);
|
||
}
|
||
window.addEventListener('message', onMessage);
|
||
return () => window.removeEventListener('message', onMessage);
|
||
}, [effectiveDeck, isActivePreviewIframeSource, isOurPreviewIframeSource, previewStateKey]);
|
||
|
||
useEffect(() => {
|
||
const win = iframeRef.current?.contentWindow;
|
||
if (!win) return;
|
||
win.postMessage({
|
||
type: 'od:comment-mode',
|
||
enabled: boardMode,
|
||
mode: boardTool,
|
||
}, '*');
|
||
}, [boardMode, boardTool, srcDoc]);
|
||
|
||
useEffect(() => {
|
||
const win = iframeRef.current?.contentWindow;
|
||
if (!win) return;
|
||
win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*');
|
||
postSelectedManualEditTargetToIframe(manualEditMode ? selectedManualEditTarget?.id ?? null : null);
|
||
}, [manualEditMode, selectedManualEditTarget?.id, srcDoc]);
|
||
|
||
const previewStyleToIframe = useCallback((id: string, styles: Partial<ManualEditStyles>, version: number) => {
|
||
const win = iframeRef.current?.contentWindow;
|
||
if (!win) return false;
|
||
win.postMessage({ type: 'od-edit-preview-style', id, styles, version }, '*');
|
||
return true;
|
||
}, []);
|
||
|
||
function postSelectedManualEditTargetToIframe(id: string | null, target: HTMLIFrameElement | null = iframeRef.current) {
|
||
const win = target?.contentWindow;
|
||
if (!win) return;
|
||
win.postMessage({ type: 'od-edit-selected-target', id }, '*');
|
||
}
|
||
|
||
function syncBridgeModes(target: HTMLIFrameElement | null = iframeRef.current) {
|
||
const win = target?.contentWindow;
|
||
if (!win) return;
|
||
win.postMessage({
|
||
type: 'od:comment-mode',
|
||
enabled: boardMode,
|
||
mode: boardTool,
|
||
}, '*');
|
||
win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*');
|
||
postSelectedManualEditTargetToIframe(manualEditMode ? selectedManualEditTarget?.id ?? null : null, target);
|
||
win.postMessage({ type: 'od:inspect-mode', enabled: inspectMode }, '*');
|
||
}
|
||
|
||
useEffect(() => {
|
||
const win = iframeRef.current?.contentWindow;
|
||
if (!win) return;
|
||
win.postMessage({ type: 'od:inspect-mode', enabled: inspectMode }, '*');
|
||
}, [inspectMode, srcDoc]);
|
||
|
||
// Mirror the bridge's `od:comment-targets` broadcast into
|
||
// `liveCommentTargets` whenever EITHER Inspect or Comments mode is
|
||
// active. The boardMode-only useEffect below still handles its
|
||
// own comment-specific events (hover / click target / pod), but
|
||
// the targets list itself is mode-agnostic — it's just "which
|
||
// elements on the page carry data-od-id / data-screen-label".
|
||
// Without this listener Inspect mode never learns the artifact's
|
||
// annotation count, and the empty-state hint added for #890 would
|
||
// misfire (always firing in Inspect mode, even on annotated
|
||
// artifacts) because the comment-mode listener short-circuits on
|
||
// `!boardMode`. Issue #890.
|
||
useEffect(() => {
|
||
if (!inspectMode && !boardMode) {
|
||
setLiveCommentTargets((current) => (current.size > 0 ? new Map() : current));
|
||
return;
|
||
}
|
||
function onMessage(ev: MessageEvent) {
|
||
if (!isOurPreviewIframeSource(ev.source)) return;
|
||
const data = ev.data as
|
||
| {
|
||
type?: string;
|
||
targets?: Array<Partial<PreviewCommentSnapshot>>;
|
||
}
|
||
| null;
|
||
if (data?.type !== 'od:comment-targets' || !Array.isArray(data.targets)) return;
|
||
const next = new Map<string, PreviewCommentSnapshot>();
|
||
data.targets.forEach((item) => {
|
||
const elementId = String(item?.elementId || '');
|
||
if (!elementId) return;
|
||
next.set(elementId, {
|
||
filePath: file.name,
|
||
elementId,
|
||
selector: String(item?.selector || ''),
|
||
label: String(item?.label || ''),
|
||
text: String(item?.text || ''),
|
||
position: {
|
||
x: clampBridgeCoordinate(item?.position?.x),
|
||
y: clampBridgeCoordinate(item?.position?.y),
|
||
width: clampBridgeCoordinate(item?.position?.width),
|
||
height: clampBridgeCoordinate(item?.position?.height),
|
||
},
|
||
htmlHint: String(item?.htmlHint || ''),
|
||
style: normalizeAnnotationStyle(item?.style),
|
||
selectionKind: 'element',
|
||
memberCount: undefined,
|
||
});
|
||
});
|
||
setLiveCommentTargets(next);
|
||
}
|
||
window.addEventListener('message', onMessage);
|
||
return () => window.removeEventListener('message', onMessage);
|
||
}, [inspectMode, boardMode, file.name, isOurPreviewIframeSource]);
|
||
|
||
useEffect(() => {
|
||
setActiveCommentTarget(null);
|
||
setHoveredCommentTarget(null);
|
||
setLiveCommentTargets(new Map());
|
||
setCommentDraft('');
|
||
setActiveInspectTarget(null);
|
||
setInspectOverrides({});
|
||
setInspectSavedAt(null);
|
||
setInspectError(null);
|
||
setQueuedBoardNotes([]);
|
||
setStrokePoints([]);
|
||
setManualEditFrozenSource(null);
|
||
setManualEditViewportWidth(null);
|
||
setManualEditTargets([]);
|
||
setSelectedManualEditTarget(null);
|
||
setManualEditPanelPosition(null);
|
||
selectedManualEditTargetIdRef.current = null;
|
||
setManualEditDraft(emptyManualEditDraft());
|
||
setManualEditHistory([]);
|
||
setManualEditUndone([]);
|
||
setManualEditError(null);
|
||
manualEditPendingStyleRef.current = null;
|
||
clearManualEditStyleTimer();
|
||
}, [file.name]);
|
||
|
||
// Selecting a new file or turning inspect/comment-inspect off resets the panel target.
|
||
useEffect(() => {
|
||
if (!inspectMode && !(boardMode && boardTool === 'inspect')) {
|
||
setActiveInspectTarget(null);
|
||
setInspectError(null);
|
||
}
|
||
}, [inspectMode, boardMode, boardTool]);
|
||
|
||
// Hydrate the host-authoritative override map from the artifact source
|
||
// synchronously, *before* React commits a render that carries a new
|
||
// `srcDoc` to the iframe. A `useEffect([source])` would commit the new
|
||
// source first and only re-render with the parsed map afterwards — if
|
||
// the iframe finishes loading the new srcDoc in that window, its
|
||
// `onLoad` handler captures the previous file's empty/stale map in its
|
||
// closure and posts that map back over the bridge's freshly DOM-hydrated
|
||
// overrides, leaving the preview without saved inspect styles until the
|
||
// next reload or mode toggle. Setting state during render is React's
|
||
// documented escape hatch for "store a value derived from props"
|
||
// (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes):
|
||
// the in-flight render is discarded and React re-renders with the
|
||
// updated state before commit, so the new `srcDoc` and the new
|
||
// `inspectOverrides` always commit together. After hydration the map
|
||
// only mutates from host-driven onApply / reset callbacks below, so
|
||
// artifact JS forging an od:inspect-overrides message cannot tamper
|
||
// with what saveInspectToSource will persist.
|
||
if (inspectHydratedSourceRef.current !== source) {
|
||
inspectHydratedSourceRef.current = source;
|
||
setInspectOverrides(typeof source === 'string' ? parseInspectOverridesFromSource(source) : {});
|
||
}
|
||
|
||
useEffect(() => {
|
||
sourceRef.current = source;
|
||
if (source == null) return;
|
||
setManualEditDraft((current) => (
|
||
current.fullSource === source ? current : { ...current, fullSource: source }
|
||
));
|
||
}, [source]);
|
||
|
||
useEffect(() => {
|
||
selectedManualEditTargetIdRef.current = selectedManualEditTarget?.id ?? null;
|
||
}, [selectedManualEditTarget?.id]);
|
||
|
||
useEffect(() => {
|
||
if (!boardMode) {
|
||
setCommentCreateMode(false);
|
||
setActiveCommentTarget((current) => (current ? null : current));
|
||
setHoveredCommentTarget((current) => (current ? null : current));
|
||
setActivePreviewCommentId((current) => (current ? null : current));
|
||
setLiveCommentTargets((current) => (current.size > 0 ? new Map() : current));
|
||
setQueuedBoardNotes((current) => (current.length > 0 ? [] : current));
|
||
setStrokePoints((current) => (current.length > 0 ? [] : current));
|
||
return;
|
||
}
|
||
const snapshotFromData = (data: Partial<PreviewCommentSnapshot>): PreviewCommentSnapshot => ({
|
||
filePath: file.name,
|
||
elementId: String(data.elementId || ''),
|
||
selector: String(data.selector || ''),
|
||
label: String(data.label || ''),
|
||
text: String(data.text || ''),
|
||
position: {
|
||
x: clampBridgeCoordinate(data.position?.x),
|
||
y: clampBridgeCoordinate(data.position?.y),
|
||
width: clampBridgeCoordinate(data.position?.width),
|
||
height: clampBridgeCoordinate(data.position?.height),
|
||
},
|
||
hoverPoint: data.hoverPoint
|
||
? {
|
||
x: clampBridgeCoordinate(data.hoverPoint.x),
|
||
y: clampBridgeCoordinate(data.hoverPoint.y),
|
||
}
|
||
: undefined,
|
||
htmlHint: String(data.htmlHint || ''),
|
||
style: normalizeAnnotationStyle(data.style),
|
||
selectionKind: data.selectionKind === 'pod' ? 'pod' : 'element',
|
||
memberCount: finiteBridgeInteger(data.memberCount),
|
||
podMembers: Array.isArray(data.podMembers) ? data.podMembers : undefined,
|
||
});
|
||
function onMessage(ev: MessageEvent) {
|
||
if (!isOurPreviewIframeSource(ev.source)) return;
|
||
const data = ev.data as (Partial<PreviewCommentSnapshot> & {
|
||
type?: string;
|
||
targets?: Array<Partial<PreviewCommentSnapshot>>;
|
||
points?: StrokePoint[];
|
||
}) | null;
|
||
if (!data?.type) return;
|
||
if (data.type === 'od:comment-targets' && Array.isArray(data.targets)) {
|
||
const next = new Map<string, PreviewCommentSnapshot>();
|
||
data.targets.forEach((item) => {
|
||
const snapshot = snapshotFromData(item);
|
||
if (snapshot.elementId) next.set(snapshot.elementId, snapshot);
|
||
});
|
||
setLiveCommentTargets(next);
|
||
setActiveCommentTarget((current) => (
|
||
current
|
||
? current.selectionKind === 'pod'
|
||
? current
|
||
: next.get(current.elementId) ?? current
|
||
: null
|
||
));
|
||
setHoveredCommentTarget((current) => (
|
||
current
|
||
? current.selectionKind === 'pod'
|
||
? current
|
||
: next.get(current.elementId) ?? null
|
||
: null
|
||
));
|
||
return;
|
||
}
|
||
if (data.type === 'od:comment-active-target-update') {
|
||
const snapshot = snapshotFromData(data);
|
||
if (!snapshot.elementId) return;
|
||
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
|
||
setActiveCommentTarget((current) => (
|
||
current && current.elementId === snapshot.elementId ? snapshot : current
|
||
));
|
||
setHoveredCommentTarget((current) => (
|
||
current && current.elementId === snapshot.elementId ? snapshot : current
|
||
));
|
||
return;
|
||
}
|
||
if (data.type === 'od:comment-leave') {
|
||
setHoveredCommentTarget(null);
|
||
return;
|
||
}
|
||
if (data.type === 'od:comment-hover') {
|
||
const snapshot = snapshotFromData(data);
|
||
if (!snapshot.elementId) return;
|
||
setHoveredCommentTarget(snapshot);
|
||
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
|
||
return;
|
||
}
|
||
if (data.type === 'od:comment-target') {
|
||
const snapshot = snapshotFromData(data);
|
||
if (!snapshot.elementId) return;
|
||
const shouldOpenComposer = boardMode || commentCreateMode;
|
||
setActiveCommentTarget((current) => (shouldOpenComposer ? snapshot : current));
|
||
setHoveredCommentTarget(snapshot);
|
||
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
|
||
if (shouldOpenComposer) {
|
||
setActivePreviewCommentId(null);
|
||
setCommentDraft('');
|
||
setQueuedBoardNotes([]);
|
||
}
|
||
return;
|
||
}
|
||
if (data.type === 'od:pod-clear') {
|
||
setStrokePoints([]);
|
||
return;
|
||
}
|
||
if (data.type === 'od:pod-stroke' && Array.isArray(data.points)) {
|
||
setStrokePoints(
|
||
data.points.map((point) => ({
|
||
x: clampBridgeCoordinate(point.x),
|
||
y: clampBridgeCoordinate(point.y),
|
||
})),
|
||
);
|
||
return;
|
||
}
|
||
if (data.type === 'od:pod-select' && Array.isArray(data.points)) {
|
||
const points = data.points.map((point) => ({
|
||
x: clampBridgeCoordinate(point.x),
|
||
y: clampBridgeCoordinate(point.y),
|
||
}));
|
||
setStrokePoints(points);
|
||
const nextTarget = buildPodSnapshot({
|
||
filePath: file.name,
|
||
strokePoints: points,
|
||
liveTargets: liveCommentTargetsRef.current,
|
||
});
|
||
if (!nextTarget) {
|
||
setStrokePoints([]);
|
||
return;
|
||
}
|
||
setActiveCommentTarget(nextTarget);
|
||
setHoveredCommentTarget(nextTarget);
|
||
setActivePreviewCommentId(null);
|
||
setQueuedBoardNotes([]);
|
||
setCommentDraft('');
|
||
setStrokePoints([]);
|
||
}
|
||
}
|
||
window.addEventListener('message', onMessage);
|
||
return () => window.removeEventListener('message', onMessage);
|
||
}, [activeCommentTarget, boardMode, boardTool, commentPortalHost, file.name, isOurPreviewIframeSource, previewComments]);
|
||
|
||
useEffect(() => {
|
||
if (!boardMode || !activeCommentTarget || activeCommentTarget.selectionKind === 'pod') return;
|
||
iframeRef.current?.contentWindow?.postMessage({
|
||
type: 'od:comment-active-target',
|
||
elementId: activeCommentTarget.elementId,
|
||
selector: activeCommentTarget.selector,
|
||
}, '*');
|
||
}, [activeCommentTarget?.elementId, activeCommentTarget?.selector, activeCommentTarget?.selectionKind, boardMode]);
|
||
|
||
useEffect(() => {
|
||
if (!manualEditMode) {
|
||
setManualEditTargets([]);
|
||
setSelectedManualEditTarget(null);
|
||
setManualEditPanelPosition(null);
|
||
setManualEditError(null);
|
||
manualEditPendingStyleRef.current = null;
|
||
if (manualEditStyleTimerRef.current) {
|
||
clearTimeout(manualEditStyleTimerRef.current);
|
||
manualEditStyleTimerRef.current = null;
|
||
}
|
||
return;
|
||
}
|
||
function onMessage(ev: MessageEvent) {
|
||
if (!isOurPreviewIframeSource(ev.source)) return;
|
||
const data = ev.data as ManualEditBridgeMessage | null;
|
||
if (!data?.type) return;
|
||
if (data.type === 'od-edit-targets' && Array.isArray(data.targets)) {
|
||
setManualEditTargets(data.targets);
|
||
// Target broadcasts can be briefly empty while the iframe/save path is
|
||
// settling; keep the user's inspector selection unless a fresh copy is
|
||
// available to update its metadata.
|
||
setSelectedManualEditTarget((current) =>
|
||
current ? data.targets.find((target) => target.id === current.id) ?? current : current,
|
||
);
|
||
const selectedId = selectedManualEditTargetIdRef.current;
|
||
if (selectedId) setTimeout(() => postSelectedManualEditTargetToIframe(selectedId), 0);
|
||
return;
|
||
}
|
||
if (data.type === 'od-edit-select') {
|
||
void selectManualEditTarget(data.target);
|
||
return;
|
||
}
|
||
if (data.type === 'od-edit-hover') {
|
||
if (data.target.id !== selectedManualEditTargetIdRef.current) {
|
||
void selectManualEditTarget(data.target);
|
||
}
|
||
return;
|
||
}
|
||
if (data.type === 'od-edit-text-commit') {
|
||
void applyManualEdit({
|
||
id: String(data.id),
|
||
kind: 'set-text',
|
||
value: String(data.value),
|
||
}, 'Edit text');
|
||
return;
|
||
}
|
||
}
|
||
window.addEventListener('message', onMessage);
|
||
return () => window.removeEventListener('message', onMessage);
|
||
}, [isOurPreviewIframeSource, manualEditMode, source]);
|
||
|
||
function nextManualEditPreviewVersion(): number {
|
||
manualEditPreviewVersionRef.current += 1;
|
||
return manualEditPreviewVersionRef.current;
|
||
}
|
||
|
||
function inspectorManualEditStyles(target: ManualEditTarget, baseSource: string): ManualEditStyles {
|
||
const inlineStyles = readManualEditStyles(baseSource, target.id);
|
||
return mergeManualEditInspectorStyles(inlineStyles, target.styles);
|
||
}
|
||
|
||
function reconcileManualEditStyleSave(
|
||
id: string,
|
||
savedStyles: Partial<ManualEditStyles>,
|
||
savedSource: string,
|
||
) {
|
||
if (id !== '__body__' && !readManualEditOuterHtml(savedSource, id)) {
|
||
setManualEditError('The selected target no longer exists in the saved source. Refreshing the preview.');
|
||
setSelectedManualEditTarget(null);
|
||
setManualEditFrozenSource(null);
|
||
setReloadKey((key) => key + 1);
|
||
return;
|
||
}
|
||
const sourceStyles = readManualEditStyles(savedSource, id);
|
||
const supersededStyles = manualEditPendingStyleRef.current?.id === id
|
||
? manualEditPendingStyleRef.current.styles
|
||
: {};
|
||
const repairStyles: Partial<ManualEditStyles> = {};
|
||
for (const key of Object.keys(savedStyles) as Array<keyof ManualEditStyles>) {
|
||
if (Object.prototype.hasOwnProperty.call(supersededStyles, key)) continue;
|
||
const sourceValue = manualEditInspectorStyleValue(key, sourceStyles[key] ?? '');
|
||
const savedValue = savedStyles[key] ?? '';
|
||
if (manualEditPersistedValueMatchesSavedSnapshot(key, sourceValue, savedValue)) continue;
|
||
repairStyles[key] = sourceValue;
|
||
}
|
||
if (Object.keys(repairStyles).length === 0) return;
|
||
previewStyleToIframe(id, repairStyles, nextManualEditPreviewVersion());
|
||
setManualEditDraft((current) => ({
|
||
...current,
|
||
styles: { ...current.styles, ...repairStyles },
|
||
}));
|
||
setManualEditError('Saved styles differed from the active preview. Reconciled the selected target from source.');
|
||
}
|
||
|
||
function clearManualEditStyleTimer() {
|
||
if (!manualEditStyleTimerRef.current) return;
|
||
clearTimeout(manualEditStyleTimerRef.current);
|
||
manualEditStyleTimerRef.current = null;
|
||
}
|
||
|
||
function cancelManualEditPendingStyles(id: string, keys: Array<keyof ManualEditStyles>) {
|
||
const nextPending = cancelManualEditPendingStyleSnapshot(manualEditPendingStyleRef.current, id, keys);
|
||
if (!nextPending) {
|
||
manualEditPendingStyleRef.current = null;
|
||
clearManualEditStyleTimer();
|
||
return;
|
||
}
|
||
manualEditPendingStyleRef.current = nextPending;
|
||
}
|
||
|
||
async function handleManualEditStyleChange(id: string, styles: Partial<ManualEditStyles>, label: string) {
|
||
const version = nextManualEditPreviewVersion();
|
||
const currentPending = manualEditPendingStyleRef.current;
|
||
const pendingStyles = currentPending?.id === id
|
||
? { ...currentPending.styles, ...styles }
|
||
: styles;
|
||
const pending: ManualEditPendingStyleSave = { id, styles: pendingStyles, label, version };
|
||
manualEditPendingStyleRef.current = pending;
|
||
setManualEditError(null);
|
||
previewStyleToIframe(id, styles, version);
|
||
}
|
||
|
||
async function flushManualEditStyleSave(): Promise<boolean> {
|
||
const pending = manualEditPendingStyleRef.current;
|
||
if (!pending) return true;
|
||
if (manualEditSavingRef.current) return false;
|
||
manualEditPendingStyleRef.current = null;
|
||
return applyManualEdit({ id: pending.id, kind: 'set-style', styles: pending.styles }, pending.label);
|
||
}
|
||
|
||
function cancelManualEditStyleDraft() {
|
||
const pending = manualEditPendingStyleRef.current;
|
||
if (!pending) return;
|
||
clearManualEditStyleTimer();
|
||
manualEditPendingStyleRef.current = null;
|
||
const base = sourceRef.current ?? '';
|
||
const target = pending.id === '__body__'
|
||
? null
|
||
: selectedManualEditTarget?.id === pending.id
|
||
? selectedManualEditTarget
|
||
: manualEditTargets.find((item) => item.id === pending.id) ?? null;
|
||
const sourceStyles = target
|
||
? inspectorManualEditStyles(target, base)
|
||
: readManualEditStyles(base, pending.id);
|
||
const resetStyles = MANUAL_EDIT_STYLE_PROPS.reduce<Partial<ManualEditStyles>>((acc, key) => {
|
||
acc[key] = sourceStyles[key] ?? '';
|
||
return acc;
|
||
}, {});
|
||
previewStyleToIframe(pending.id, resetStyles, nextManualEditPreviewVersion());
|
||
if (!target || target.id === selectedManualEditTarget?.id) {
|
||
setManualEditDraft((current) => ({
|
||
...current,
|
||
styles: target ? sourceStyles : current.styles,
|
||
fullSource: base,
|
||
}));
|
||
}
|
||
setManualEditError(null);
|
||
}
|
||
|
||
function refreshSrcDocPreviewAfterManualEditExit() {
|
||
activatedSrcDocTransportHtmlRef.current = null;
|
||
setSrcDocShellReady(false);
|
||
setSrcDocTransportResetKey((key) => key + 1);
|
||
}
|
||
|
||
async function exitManualEditModeAfterFlush(): Promise<boolean> {
|
||
const ok = await flushManualEditStyleSave();
|
||
if (!ok) return false;
|
||
setManualEditPanelPosition(null);
|
||
setManualEditMode(false);
|
||
refreshSrcDocPreviewAfterManualEditExit();
|
||
return true;
|
||
}
|
||
|
||
function cancelManualEditModeAndExit() {
|
||
cancelManualEditStyleDraft();
|
||
selectedManualEditTargetIdRef.current = null;
|
||
setSelectedManualEditTarget(null);
|
||
setManualEditPanelPosition(null);
|
||
setManualEditDraft(emptyManualEditDraft(sourceRef.current ?? ''));
|
||
setManualEditError(null);
|
||
setManualEditMode(false);
|
||
refreshSrcDocPreviewAfterManualEditExit();
|
||
postSelectedManualEditTargetToIframe(null);
|
||
}
|
||
|
||
async function selectManualEditTarget(target: ManualEditTarget) {
|
||
if (manualEditPendingStyleRef.current?.id !== target.id) cancelManualEditStyleDraft();
|
||
const base = sourceRef.current ?? '';
|
||
const fields = readManualEditFields(base, target.id);
|
||
setSelectedManualEditTarget(target);
|
||
setManualEditDraft({
|
||
text: fields.text ?? target.fields.text ?? target.text,
|
||
href: fields.href ?? target.fields.href ?? '',
|
||
src: fields.src ?? target.fields.src ?? '',
|
||
alt: fields.alt ?? target.fields.alt ?? '',
|
||
styles: inspectorManualEditStyles(target, base),
|
||
attributesText: JSON.stringify(readManualEditAttributes(base, target.id), null, 2),
|
||
outerHtml: readManualEditOuterHtml(base, target.id) || target.outerHtml,
|
||
fullSource: base,
|
||
});
|
||
setManualEditError(null);
|
||
}
|
||
|
||
async function clearManualEditTargetSelection() {
|
||
cancelManualEditStyleDraft();
|
||
setSelectedManualEditTarget(null);
|
||
setManualEditPanelPosition(null);
|
||
setManualEditDraft(emptyManualEditDraft(sourceRef.current ?? ''));
|
||
setManualEditError(null);
|
||
}
|
||
|
||
async function applyManualEdit(patch: ManualEditPatch, label: string): Promise<boolean> {
|
||
if (manualEditSavingRef.current) return false;
|
||
if (sourceRef.current == null) return false;
|
||
manualEditSavingRef.current = true;
|
||
setManualEditSaving(true);
|
||
setManualEditError(null);
|
||
try {
|
||
const baseSource = sourceRef.current;
|
||
const result = applyManualEditPatch(baseSource, patch);
|
||
if (!result.ok) {
|
||
setManualEditError(result.error ?? 'Could not apply edit.');
|
||
return false;
|
||
}
|
||
if (!(await confirmManualEditHistorySource(
|
||
baseSource,
|
||
'The file changed outside manual edit mode. Refreshing before applying manual edits.',
|
||
))) return false;
|
||
const saved = await writeProjectTextFileDetailed(projectId, file.name, result.source, {
|
||
artifactManifest: file.artifactManifest,
|
||
});
|
||
if (!saved.ok) {
|
||
setManualEditError(
|
||
`Could not save the edited file${saved.status ? ` (${saved.status}${saved.code ? ` ${saved.code}` : ''})` : ''}: ${saved.message}`,
|
||
);
|
||
return false;
|
||
}
|
||
const entry: ManualEditHistoryEntry = {
|
||
id: `${Date.now()}-${manualEditHistory.length}`,
|
||
label,
|
||
patch,
|
||
beforeSource: baseSource,
|
||
afterSource: result.source,
|
||
createdAt: Date.now(),
|
||
};
|
||
setSource(result.source);
|
||
sourceRef.current = result.source;
|
||
setInlinedSource(null);
|
||
if (patch.kind !== 'set-style') {
|
||
setManualEditFrozenSource(result.source);
|
||
}
|
||
setManualEditHistory((current) => [entry, ...current]);
|
||
setManualEditUndone([]);
|
||
setManualEditDraft((current) => ({ ...current, fullSource: result.source }));
|
||
if (patch.kind === 'set-text') {
|
||
setSelectedManualEditTarget((current) => current?.id === patch.id
|
||
? { ...current, text: patch.value, fields: { ...current.fields, text: patch.value } }
|
||
: current);
|
||
} else if (patch.kind === 'remove-element') {
|
||
if (manualEditPendingStyleRef.current?.id === patch.id) {
|
||
manualEditPendingStyleRef.current = null;
|
||
clearManualEditStyleTimer();
|
||
}
|
||
selectedManualEditTargetIdRef.current = null;
|
||
setSelectedManualEditTarget(null);
|
||
setManualEditTargets((current) => current.filter((target) => target.id !== patch.id));
|
||
setManualEditDraft(emptyManualEditDraft(result.source));
|
||
postSelectedManualEditTargetToIframe(null);
|
||
} else {
|
||
setManualEditDraft((current) => ({ ...current, fullSource: result.source }));
|
||
}
|
||
if (patch.kind === 'set-style') {
|
||
reconcileManualEditStyleSave(patch.id, patch.styles, result.source);
|
||
}
|
||
setManualEditError(null);
|
||
await onFileSaved?.();
|
||
return true;
|
||
} finally {
|
||
manualEditSavingRef.current = false;
|
||
setManualEditSaving(false);
|
||
}
|
||
}
|
||
|
||
async function confirmManualEditHistorySource(expectedSource: string, message: string): Promise<boolean> {
|
||
const persisted = await fetchProjectFileText(projectId, file.name, {
|
||
cache: 'no-store',
|
||
cacheBustKey: Date.now(),
|
||
});
|
||
if (persisted == null || persisted === expectedSource) return true;
|
||
setSource(persisted);
|
||
sourceRef.current = persisted;
|
||
setInlinedSource(null);
|
||
setManualEditHistory([]);
|
||
setManualEditUndone([]);
|
||
manualEditPendingStyleRef.current = null;
|
||
setManualEditDraft((current) => ({ ...current, fullSource: persisted }));
|
||
setManualEditError(message);
|
||
return false;
|
||
}
|
||
|
||
async function undoManualEdit() {
|
||
if (manualEditSavingRef.current) return;
|
||
const [latest, ...rest] = manualEditHistory;
|
||
if (!latest) return;
|
||
manualEditSavingRef.current = true;
|
||
setManualEditSaving(true);
|
||
try {
|
||
if (!(await confirmManualEditHistorySource(
|
||
latest.afterSource,
|
||
'The file changed outside manual edit mode. History was cleared to avoid overwriting newer content.',
|
||
))) return;
|
||
const saved = await writeProjectTextFile(projectId, file.name, latest.beforeSource, {
|
||
artifactManifest: file.artifactManifest,
|
||
});
|
||
if (!saved) {
|
||
setManualEditError('Could not save the undo result.');
|
||
return;
|
||
}
|
||
setSource(latest.beforeSource);
|
||
sourceRef.current = latest.beforeSource;
|
||
setInlinedSource(null);
|
||
setManualEditFrozenSource(latest.beforeSource);
|
||
setManualEditHistory(rest);
|
||
setManualEditUndone((current) => [latest, ...current]);
|
||
setManualEditDraft((current) => ({ ...current, fullSource: latest.beforeSource }));
|
||
await onFileSaved?.();
|
||
} finally {
|
||
manualEditSavingRef.current = false;
|
||
setManualEditSaving(false);
|
||
}
|
||
}
|
||
|
||
async function redoManualEdit() {
|
||
if (manualEditSavingRef.current) return;
|
||
const [latest, ...rest] = manualEditUndone;
|
||
if (!latest) return;
|
||
manualEditSavingRef.current = true;
|
||
setManualEditSaving(true);
|
||
try {
|
||
if (!(await confirmManualEditHistorySource(
|
||
latest.beforeSource,
|
||
'The file changed outside manual edit mode. History was cleared to avoid overwriting newer content.',
|
||
))) return;
|
||
const saved = await writeProjectTextFile(projectId, file.name, latest.afterSource, {
|
||
artifactManifest: file.artifactManifest,
|
||
});
|
||
if (!saved) {
|
||
setManualEditError('Could not save the redo result.');
|
||
return;
|
||
}
|
||
setSource(latest.afterSource);
|
||
sourceRef.current = latest.afterSource;
|
||
setInlinedSource(null);
|
||
setManualEditFrozenSource(latest.afterSource);
|
||
setManualEditUndone(rest);
|
||
setManualEditHistory((current) => [latest, ...current]);
|
||
setManualEditDraft((current) => ({ ...current, fullSource: latest.afterSource }));
|
||
await onFileSaved?.();
|
||
} finally {
|
||
manualEditSavingRef.current = false;
|
||
setManualEditSaving(false);
|
||
}
|
||
}
|
||
|
||
// Inspect-mode picker: same `od:comment-target` payload, different sink.
|
||
// The bridge tags the message with a computed-style snapshot so the panel
|
||
// can show real starting values for color / typography / spacing / radius.
|
||
useEffect(() => {
|
||
if (!inspectMode) return;
|
||
function onMessage(ev: MessageEvent) {
|
||
if (!isOurPreviewIframeSource(ev.source)) return;
|
||
const data = ev.data as
|
||
| {
|
||
type?: string;
|
||
elementId?: string;
|
||
selector?: string;
|
||
label?: string;
|
||
text?: string;
|
||
style?: InspectStyleSnapshot;
|
||
clickedDescendant?: Partial<InspectClickedDescendant>;
|
||
}
|
||
| null;
|
||
if (!data || data.type !== 'od:comment-target') return;
|
||
if (!data.elementId || !data.selector) return;
|
||
const clickedDescendant =
|
||
data.clickedDescendant && typeof data.clickedDescendant === 'object'
|
||
? {
|
||
label: String(data.clickedDescendant.label || ''),
|
||
text: String(data.clickedDescendant.text || ''),
|
||
}
|
||
: null;
|
||
setActiveInspectTarget({
|
||
elementId: String(data.elementId),
|
||
selector: String(data.selector),
|
||
label: String(data.label || ''),
|
||
text: String(data.text || ''),
|
||
style: data.style && typeof data.style === 'object' ? data.style : {},
|
||
...(clickedDescendant ? { clickedDescendant } : {}),
|
||
});
|
||
setInspectError(null);
|
||
setInspectSavedAt(null);
|
||
}
|
||
window.addEventListener('message', onMessage);
|
||
return () => window.removeEventListener('message', onMessage);
|
||
}, [inspectMode, isOurPreviewIframeSource]);
|
||
|
||
function postSlide(action: 'next' | 'prev' | 'first' | 'last') {
|
||
const win = iframeRef.current?.contentWindow;
|
||
if (!win) return;
|
||
win.postMessage({ type: 'od:slide', action }, '*');
|
||
}
|
||
|
||
function postInspectSet(elementId: string, selector: string, prop: string, value: string) {
|
||
const win = iframeRef.current?.contentWindow;
|
||
if (!win) return;
|
||
win.postMessage(
|
||
{ type: 'od:inspect-set', elementId, selector, prop, value },
|
||
'*',
|
||
);
|
||
}
|
||
|
||
function postInspectReset(elementId?: string) {
|
||
const win = iframeRef.current?.contentWindow;
|
||
if (!win) return;
|
||
win.postMessage({ type: 'od:inspect-reset', elementId }, '*');
|
||
}
|
||
|
||
// Replay the host's authoritative override map into the freshly loaded
|
||
// iframe. The bridge inside the iframe only sees rules persisted in the
|
||
// artifact source via its own hydrateOverridesFromDom() — any unsaved
|
||
// edit lives on the host side until Save-to-source. Without this replay,
|
||
// toggling Inspect off/on, switching to Comment mode, or any other
|
||
// srcdoc rebuild reloads the iframe from previewSource without the
|
||
// unsaved style block, so the preview drops the live edits while
|
||
// saveInspectToSource() can still persist them later from the stale
|
||
// host map. The bridge re-validates each entry under its own allow-list,
|
||
// so a parent that posted a hostile replay can only land overrides the
|
||
// bridge would also have accepted via od:inspect-set.
|
||
//
|
||
// The render-time hydration above keeps `inspectOverrides` aligned with
|
||
// the current `source` whenever React commits, but the iframe `onLoad`
|
||
// callback fires from a separate event-loop turn after the new srcDoc
|
||
// is parsed; if it ever races a stale closure (e.g. an interleaved
|
||
// remount), reading React state would post the previous file's map over
|
||
// the bridge's DOM-hydrated one and silently strip the persisted styles
|
||
// from preview. Re-derive synchronously from `source` whenever the
|
||
// hydration ref disagrees so onLoad never sends a stale snapshot.
|
||
function replayInspectOverridesToIframe(target: HTMLIFrameElement | null = iframeRef.current) {
|
||
const win = target?.contentWindow;
|
||
if (!win) return;
|
||
const overrides = inspectHydratedSourceRef.current === source
|
||
? inspectOverrides
|
||
: (typeof source === 'string' ? parseInspectOverridesFromSource(source) : {});
|
||
win.postMessage({ type: 'od:inspect-replay', overrides }, '*');
|
||
}
|
||
|
||
// Persist accumulated inspect overrides into the artifact source: replace
|
||
// (or insert) a single <style data-od-inspect-overrides> block in <head>.
|
||
// The CSS body is serialized from the host's own override map, hydrated
|
||
// from source on load and updated only by host-driven onApply / reset
|
||
// callbacks. We deliberately do NOT round-trip through the iframe at save
|
||
// time: artifact JS rendered inside the preview shares the same
|
||
// contentWindow as the bridge and could forge an od:inspect-overrides
|
||
// reply that flips allow-listed properties on elements the user never
|
||
// touched. POSTing to /api/projects/:id/files upserts the file via
|
||
// writeProjectFile (multipart-or-JSON; we use JSON).
|
||
async function saveInspectToSource() {
|
||
if (!source) return;
|
||
setSavingInspect(true);
|
||
setInspectError(null);
|
||
try {
|
||
const css = serializeInspectOverrides(inspectOverrides).trim();
|
||
const next = applyInspectOverridesToSource(source, css);
|
||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/files`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: file.name, content: next }),
|
||
});
|
||
if (!resp.ok) {
|
||
const payload = await resp.json().catch(() => null) as { error?: string; message?: string } | null;
|
||
throw new Error(payload?.error || payload?.message || `Save failed (${resp.status})`);
|
||
}
|
||
setSource(next);
|
||
setInspectSavedAt(Date.now());
|
||
setReloadKey((k) => k + 1);
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'Save failed';
|
||
setInspectError(msg);
|
||
// The error banner inside the inspect panel is easy to miss when the
|
||
// user is focused on the iframe preview — surface failures in the
|
||
// console as well so quota/network errors aren't silently lost.
|
||
console.error('[inspect] saveToSource failed:', err);
|
||
} finally {
|
||
setSavingInspect(false);
|
||
}
|
||
}
|
||
|
||
// Keyboard nav on the host, so the user can press ←/→ even when focus
|
||
// is on the chat composer or any other host control.
|
||
useEffect(() => {
|
||
if (!effectiveDeck || mode !== 'preview') return;
|
||
function onKey(e: KeyboardEvent) {
|
||
const target = e.target as HTMLElement | null;
|
||
if (target) {
|
||
const tag = target.tagName;
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return;
|
||
}
|
||
if (e.key === 'ArrowRight' || e.key === 'PageDown') {
|
||
e.preventDefault();
|
||
postSlide('next');
|
||
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
|
||
e.preventDefault();
|
||
postSlide('prev');
|
||
} else if (e.key === 'Home') {
|
||
e.preventDefault();
|
||
postSlide('first');
|
||
} else if (e.key === 'End') {
|
||
e.preventDefault();
|
||
postSlide('last');
|
||
}
|
||
}
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
}, [effectiveDeck, mode]);
|
||
|
||
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(() => {
|
||
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]);
|
||
|
||
useEffect(() => {
|
||
if (!agentToolsOpen) return;
|
||
const onDocClick = (e: MouseEvent) => {
|
||
const target = e.target as HTMLElement | null;
|
||
if (target?.closest('.artifact-tool-menu-anchor')) return;
|
||
closeArtifactToolMenus();
|
||
};
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') closeArtifactToolMenus();
|
||
};
|
||
document.addEventListener('mousedown', onDocClick);
|
||
document.addEventListener('keydown', onKey);
|
||
return () => {
|
||
document.removeEventListener('mousedown', onDocClick);
|
||
document.removeEventListener('keydown', onKey);
|
||
};
|
||
}, [agentToolsOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!shareMenuOpen) return;
|
||
const onDocClick = (e: MouseEvent) => {
|
||
if (!shareRef.current) return;
|
||
if (!shareRef.current.contains(e.target as Node)) setShareMenuOpen(false);
|
||
};
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') setShareMenuOpen(false);
|
||
};
|
||
document.addEventListener('mousedown', onDocClick);
|
||
document.addEventListener('keydown', onKey);
|
||
return () => {
|
||
document.removeEventListener('mousedown', onDocClick);
|
||
document.removeEventListener('keydown', onKey);
|
||
};
|
||
}, [shareMenuOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!inTabPresent) return;
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') setInTabPresent(false);
|
||
};
|
||
document.addEventListener('keydown', onKey);
|
||
return () => document.removeEventListener('keydown', onKey);
|
||
}, [inTabPresent]);
|
||
|
||
function openInNewTab() {
|
||
if (!source) return;
|
||
openSandboxedPreviewInNewTab(source, exportTitle, {
|
||
deck: effectiveDeck,
|
||
baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
|
||
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
|
||
});
|
||
}
|
||
|
||
// Snapshot this project as a reusable template. The daemon snapshots
|
||
// EVERY html/text/code file in the project (not just the file open in
|
||
// the viewer), so the template captures the whole design, not a single
|
||
// page. Surfaced here in the Share menu because that's where the user's
|
||
// share / export mental model already lives.
|
||
function openSaveAsTemplateModal() {
|
||
setShareMenuOpen(false);
|
||
const defaultName =
|
||
file.name.replace(/\.html?$/i, '') || t('fileViewer.templateNameDefault');
|
||
setTemplateName(defaultName);
|
||
setTemplateDescription('');
|
||
setTemplateSaveError(null);
|
||
setTemplateModalOpen(true);
|
||
}
|
||
|
||
async function handleSaveAsTemplate() {
|
||
const name = templateName.trim();
|
||
if (!name) return;
|
||
setSavingTemplate(true);
|
||
setTemplateNote(null);
|
||
setTemplateSaveError(null);
|
||
let savedName: string | null = null;
|
||
try {
|
||
const tpl = await saveTemplate({
|
||
name,
|
||
description: templateDescription.trim() || undefined,
|
||
sourceProjectId: projectId,
|
||
});
|
||
if (!tpl) {
|
||
setTemplateSaveError(t('fileViewer.savedTemplateFail'));
|
||
return;
|
||
}
|
||
savedName = tpl.name;
|
||
setTemplateModalOpen(false);
|
||
setTemplateName('');
|
||
setTemplateDescription('');
|
||
setTemplateNote(t('fileViewer.savedTemplate', { name: tpl.name }));
|
||
// Show success toast
|
||
setTemplateSavedToast(t('fileViewer.savedTemplate', { name: tpl.name }));
|
||
} finally {
|
||
setSavingTemplate(false);
|
||
if (savedName) {
|
||
// Auto-clear the note so the menu doesn't keep stale state next open.
|
||
setTimeout(() => setTemplateNote(null), 4000);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function openDeployModal(nextProviderId: WebDeployProviderId = deployProviderId) {
|
||
setShareMenuOpen(false);
|
||
setDeployModalOpen(true);
|
||
setDeployError(null);
|
||
setCopiedDeployLink(null);
|
||
setDeployPhase('idle');
|
||
await loadDeployProvider(nextProviderId, { fallbackToExisting: true });
|
||
}
|
||
|
||
async function changeDeployProvider(nextProviderId: WebDeployProviderId) {
|
||
if (nextProviderId === deployProviderId) return;
|
||
setDeployError(null);
|
||
setDeployPhase('idle');
|
||
await loadDeployProvider(nextProviderId);
|
||
}
|
||
|
||
async function saveDeployConfig() {
|
||
setSavingDeployConfig(true);
|
||
setDeployError(null);
|
||
try {
|
||
if (deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID) {
|
||
if (!deployToken.trim()) {
|
||
throw new Error(t('fileViewer.cloudflareApiTokenRequired'));
|
||
}
|
||
if (!cloudflareAccountId.trim()) {
|
||
throw new Error(t('fileViewer.cloudflareAccountIdRequired'));
|
||
}
|
||
}
|
||
const config = await updateDeployConfig(buildDeployConfigRequest(deployProviderId));
|
||
if (!config || config.providerId !== deployProviderId) {
|
||
throw new Error(t('fileViewer.deployProviderConfigSaveFailed', { provider: deployProviderLabel }));
|
||
}
|
||
syncDeployFormFromConfig(deployProviderId, config);
|
||
if (deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID) {
|
||
await loadCloudflareZones(config);
|
||
}
|
||
return config;
|
||
} catch (err) {
|
||
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployProviderConfigSaveFailed', { provider: deployProviderLabel }));
|
||
return null;
|
||
} finally {
|
||
setSavingDeployConfig(false);
|
||
}
|
||
}
|
||
|
||
function buildCloudflarePagesDeploySelection(): WebCloudflarePagesDeploySelection | undefined {
|
||
if (deployProviderId !== CLOUDFLARE_PAGES_PROVIDER_ID) return undefined;
|
||
const prefix = normalizeCloudflareDomainPrefixInput(cloudflareDomainPrefix);
|
||
if (!prefix) return undefined;
|
||
if (!isValidCloudflareDomainPrefixInput(prefix)) {
|
||
throw new Error(t('fileViewer.cloudflareDomainPrefixInvalid'));
|
||
}
|
||
const zone = cloudflareZones.find((item) => item.id === cloudflareZoneId);
|
||
if (!zone) {
|
||
throw new Error(t('fileViewer.cloudflareZoneRequired'));
|
||
}
|
||
return {
|
||
zoneId: zone.id,
|
||
zoneName: zone.name,
|
||
domainPrefix: prefix,
|
||
};
|
||
}
|
||
|
||
async function deployToSelectedProvider() {
|
||
setDeploying(true);
|
||
setDeployPhase('deploying');
|
||
setDeployError(null);
|
||
setCopiedDeployLink(null);
|
||
try {
|
||
const cloudflarePagesSelection = buildCloudflarePagesDeploySelection();
|
||
const typedToken = deployToken.trim();
|
||
const hasNewToken = typedToken && typedToken !== deployConfig?.tokenMask;
|
||
const cloudflareHints = cloudflareConfigHintsFromForm();
|
||
const cloudflareHintsChanged = deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID && Boolean(
|
||
cloudflareHints?.lastZoneId !== deployConfig?.cloudflarePages?.lastZoneId ||
|
||
cloudflareHints?.lastZoneName !== deployConfig?.cloudflarePages?.lastZoneName ||
|
||
cloudflareHints?.lastDomainPrefix !== deployConfig?.cloudflarePages?.lastDomainPrefix,
|
||
);
|
||
const needsConfigSave =
|
||
hasNewToken ||
|
||
teamId.trim() !== (deployConfig?.teamId || '') ||
|
||
teamSlug.trim() !== (deployConfig?.teamSlug || '') ||
|
||
cloudflareAccountId.trim() !== (deployConfig?.accountId || '') ||
|
||
cloudflareHintsChanged ||
|
||
!deployConfig?.configured;
|
||
if (needsConfigSave) {
|
||
const nextConfig = await saveDeployConfig();
|
||
if (!nextConfig) return;
|
||
if (!nextConfig?.configured) {
|
||
const option = getDeployProviderOption(deployProviderId);
|
||
throw new Error(t(option.tokenRequiredKey, { provider: t(option.labelKey) }));
|
||
}
|
||
}
|
||
setDeployPhase('preparing-link');
|
||
const next = await deployProjectFile(projectId, file.name, deployProviderId, cloudflarePagesSelection);
|
||
setDeploymentsByProvider((current) => ({
|
||
...current,
|
||
[next.providerId]: next,
|
||
}));
|
||
setDeployment(next);
|
||
setDeployResult(next);
|
||
if (deployResultState(next.status) !== 'failed') {
|
||
setDeploySavedToast({
|
||
message: t('fileViewer.deploySuccessToast'),
|
||
details: t('fileViewer.deploySuccessToastDetails', {
|
||
provider: deployProviderLabel,
|
||
url: next.url,
|
||
}),
|
||
});
|
||
}
|
||
} catch (err) {
|
||
const option = getDeployProviderOption(deployProviderId);
|
||
setDeployError(
|
||
err instanceof Error ? err.message : t('fileViewer.deployProviderFailed', { provider: t(option.labelKey) }),
|
||
);
|
||
} finally {
|
||
setDeploying(false);
|
||
setDeployPhase('idle');
|
||
}
|
||
}
|
||
|
||
async function retryDeploymentLink() {
|
||
const current = deployResult || deployment;
|
||
if (!current?.id) return;
|
||
setDeployError(null);
|
||
setDeployPhase('preparing-link');
|
||
try {
|
||
const next = await checkDeploymentLink(projectId, current.id);
|
||
setDeploymentsByProvider((items) => ({
|
||
...items,
|
||
[next.providerId]: next,
|
||
}));
|
||
setDeployment(next);
|
||
setDeployResult(next);
|
||
} catch (err) {
|
||
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployFailed'));
|
||
} finally {
|
||
setDeployPhase('idle');
|
||
}
|
||
}
|
||
|
||
async function copyDeployLink(url: string) {
|
||
const safeUrl = url.trim();
|
||
if (!safeUrl) return;
|
||
try {
|
||
await navigator.clipboard.writeText(safeUrl);
|
||
} catch {
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = safeUrl;
|
||
textarea.setAttribute('readonly', 'true');
|
||
textarea.style.position = 'fixed';
|
||
textarea.style.top = '-1000px';
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textarea);
|
||
}
|
||
setCopiedDeployLink(safeUrl);
|
||
window.setTimeout(() => {
|
||
setCopiedDeployLink((current) => (current === safeUrl ? null : current));
|
||
}, 1800);
|
||
}
|
||
|
||
function presentInThisTab() {
|
||
setPresentMenuOpen(false);
|
||
setInTabPresent(true);
|
||
}
|
||
|
||
function presentFullscreen() {
|
||
setPresentMenuOpen(false);
|
||
const el = previewBodyRef.current;
|
||
if (el && typeof el.requestFullscreen === 'function') {
|
||
el.requestFullscreen().catch(() => setInTabPresent(true));
|
||
} else {
|
||
setInTabPresent(true);
|
||
}
|
||
}
|
||
|
||
function presentNewTab() {
|
||
setPresentMenuOpen(false);
|
||
openInNewTab();
|
||
}
|
||
|
||
function selectMode(nextMode: 'preview' | 'source') {
|
||
if (nextMode === 'source') setDrawOverlayOpen(false);
|
||
setMode(nextMode);
|
||
}
|
||
|
||
function activateBoard(nextTool?: BoardTool) {
|
||
setMode('preview');
|
||
setBoardMode(true);
|
||
if (nextTool) setBoardTool(nextTool);
|
||
}
|
||
|
||
function activateBoardPicker(nextTool: BoardTool) {
|
||
clearBoardComposer();
|
||
fireArtifactToolbarClick(nextTool === 'pod' ? 'pods' : 'comment');
|
||
setCommentPanelOpen(false);
|
||
setCommentCreateMode(false);
|
||
activateBoard(nextTool);
|
||
setAgentToolsOpen(false);
|
||
}
|
||
|
||
function clearBoardComposer() {
|
||
setActiveCommentTarget(null);
|
||
setHoveredCommentTarget(null);
|
||
setHoveredPodMemberId(null);
|
||
setActivePreviewCommentId(null);
|
||
setCommentDraft('');
|
||
setQueuedBoardNotes([]);
|
||
setStrokePoints([]);
|
||
}
|
||
|
||
function closeArtifactToolMenus() {
|
||
setAgentToolsOpen(false);
|
||
}
|
||
|
||
function activateDrawTool() {
|
||
fireArtifactToolbarClick('draw');
|
||
const next = !drawOverlayOpen;
|
||
if (!next) {
|
||
setDrawOverlayOpen(false);
|
||
setAgentToolsOpen(false);
|
||
return;
|
||
}
|
||
capturePreviewScrollPosition();
|
||
const activateDraw = () => {
|
||
setCommentPanelOpen(false);
|
||
setCommentCreateMode(false);
|
||
setBoardMode(false);
|
||
clearBoardComposer();
|
||
setInspectMode(false);
|
||
setMode('preview');
|
||
setDrawOverlayOpen(true);
|
||
closeArtifactToolMenus();
|
||
};
|
||
if (manualEditMode) {
|
||
void exitManualEditModeAfterFlush().then((ok) => {
|
||
if (ok) activateDraw();
|
||
});
|
||
return;
|
||
}
|
||
activateDraw();
|
||
}
|
||
|
||
function activateCommentTool() {
|
||
fireArtifactToolbarClick('comment');
|
||
capturePreviewScrollPosition();
|
||
if (boardMode && !commentCreateMode && boardTool === 'inspect') {
|
||
setBoardMode(false);
|
||
setCommentCreateMode(false);
|
||
clearBoardComposer();
|
||
setAgentToolsOpen(false);
|
||
return;
|
||
}
|
||
const activateComment = () => {
|
||
setCommentPanelOpen(false);
|
||
setCommentCreateMode(false);
|
||
clearBoardComposer();
|
||
setInspectMode(false);
|
||
setDrawOverlayOpen(false);
|
||
setMode('preview');
|
||
activateBoard('inspect');
|
||
closeArtifactToolMenus();
|
||
};
|
||
if (manualEditMode) {
|
||
void exitManualEditModeAfterFlush().then((ok) => {
|
||
if (ok) activateComment();
|
||
});
|
||
return;
|
||
}
|
||
activateComment();
|
||
}
|
||
|
||
function activateCommentCreateTool() {
|
||
fireArtifactToolbarClick('comment');
|
||
capturePreviewScrollPosition();
|
||
if (boardMode && commentCreateMode) {
|
||
setBoardMode(false);
|
||
setCommentCreateMode(false);
|
||
setCommentPanelOpen(false);
|
||
clearBoardComposer();
|
||
closeArtifactToolMenus();
|
||
return;
|
||
}
|
||
const activateCommentCreate = () => {
|
||
setCommentPanelOpen(true);
|
||
setCommentSidePanelCollapsed(false);
|
||
setCommentCreateMode(true);
|
||
if (!activeCommentTarget) clearBoardComposer();
|
||
setInspectMode(false);
|
||
setDrawOverlayOpen(false);
|
||
setMode('preview');
|
||
activateBoard('inspect');
|
||
closeArtifactToolMenus();
|
||
};
|
||
if (manualEditMode) {
|
||
void exitManualEditModeAfterFlush().then((ok) => {
|
||
if (ok) activateCommentCreate();
|
||
});
|
||
return;
|
||
}
|
||
activateCommentCreate();
|
||
}
|
||
|
||
function activateManualEditTool() {
|
||
fireArtifactToolbarClick('edit');
|
||
capturePreviewScrollPosition();
|
||
if (!manualEditMode) {
|
||
setCommentPanelOpen(false);
|
||
setCommentCreateMode(false);
|
||
setBoardMode(false);
|
||
clearBoardComposer();
|
||
setInspectMode(false);
|
||
setDrawOverlayOpen(false);
|
||
setMode('preview');
|
||
setManualEditViewportWidth(previewBodyRef.current?.clientWidth ?? null);
|
||
setManualEditMode(true);
|
||
closeArtifactToolMenus();
|
||
return;
|
||
}
|
||
closeArtifactToolMenus();
|
||
void exitManualEditModeAfterFlush();
|
||
}
|
||
|
||
function queueCurrentDraft() {
|
||
const note = commentDraft.trim();
|
||
if (!note) return;
|
||
setQueuedBoardNotes((current) => [...current, note]);
|
||
setCommentDraft('');
|
||
}
|
||
|
||
async function sendBoardBatch() {
|
||
if (!activeCommentTarget || !onSendBoardCommentAttachments) return;
|
||
const nextNotes = [...queuedBoardNotes];
|
||
if (commentDraft.trim()) nextNotes.push(commentDraft.trim());
|
||
if (nextNotes.length === 0) return;
|
||
setSendingBoardBatch(true);
|
||
try {
|
||
const accepted = await onSendBoardCommentAttachments(
|
||
buildBoardCommentAttachments({
|
||
target: targetFromSnapshot(activeCommentTarget),
|
||
notes: nextNotes,
|
||
}),
|
||
);
|
||
if (accepted === false) return;
|
||
clearBoardComposer();
|
||
} finally {
|
||
setSendingBoardBatch(false);
|
||
}
|
||
}
|
||
|
||
async function savePersistentComment() {
|
||
if (!activeCommentTarget || !commentDraft.trim() || !onSavePreviewComment) return;
|
||
const isFreePin = activeCommentTarget.elementId.startsWith('pin-');
|
||
setSendingBoardBatch(true);
|
||
try {
|
||
const saved = await onSavePreviewComment(
|
||
targetFromSnapshot(activeCommentTarget),
|
||
commentDraft.trim(),
|
||
false,
|
||
);
|
||
if (saved) {
|
||
clearBoardComposer();
|
||
setBoardMode(true);
|
||
setCommentCreateMode(true);
|
||
setCommentPanelOpen(true);
|
||
setCommentSidePanelCollapsed(false);
|
||
setActivePreviewCommentId(saved.id);
|
||
setCommentSavedToast(isFreePin ? t('chat.comments.pinSavedToast') : t('chat.comments.savedToast'));
|
||
}
|
||
} finally {
|
||
setSendingBoardBatch(false);
|
||
}
|
||
}
|
||
|
||
async function savePanelComment(note: string) {
|
||
if (!onSavePreviewComment) return false;
|
||
const cleanNote = note.trim();
|
||
if (!cleanNote) return false;
|
||
const idSeed = Date.now().toString(36);
|
||
const target: PreviewCommentTarget = activeCommentTarget
|
||
? targetFromSnapshot(activeCommentTarget)
|
||
: {
|
||
filePath: file.name,
|
||
elementId: `file-comment-${idSeed}-${Math.floor(Math.random() * 1e6).toString(36)}`,
|
||
selector: 'html',
|
||
label: file.name,
|
||
text: '',
|
||
position: { x: 0, y: 0, width: 0, height: 0 },
|
||
htmlHint: '',
|
||
selectionKind: 'element',
|
||
};
|
||
setSendingBoardBatch(true);
|
||
try {
|
||
const saved = await onSavePreviewComment(target, cleanNote, false);
|
||
if (saved) {
|
||
setCommentSavedToast(t('chat.comments.savedToast'));
|
||
if (activeCommentTarget) clearBoardComposer();
|
||
}
|
||
return Boolean(saved);
|
||
} finally {
|
||
setSendingBoardBatch(false);
|
||
}
|
||
}
|
||
|
||
const showPresent = source !== null;
|
||
const canShare = source !== null;
|
||
const exportTitle = file.name.replace(/\.html?$/i, '') || file.name;
|
||
const canPptx = canShare && Boolean(onExportAsPptx) && !streaming;
|
||
useEffect(() => {
|
||
const nudgeKey = `${projectId}\n${file.name}`;
|
||
if (!canShare || exportReadyNudgeSeenRef.current.has(nudgeKey)) return;
|
||
exportReadyNudgeSeenRef.current.add(nudgeKey);
|
||
if (hasSeenExportReadyNudge(projectId, file.name)) return;
|
||
markExportReadyNudgeSeen(projectId, file.name);
|
||
setExportReadyNudge(true);
|
||
const timeout = window.setTimeout(() => setExportReadyNudge(false), 1800);
|
||
return () => window.clearTimeout(timeout);
|
||
}, [canShare, file.name, projectId]);
|
||
|
||
const openExportMenu = () => {
|
||
fireArtifactHeaderClick('share_dropdown');
|
||
setExportReadyNudge(false);
|
||
markExportReadyNudgeSeen(projectId, file.name);
|
||
setShareMenuOpen((v) => !v);
|
||
};
|
||
const captureExportImageSnapshot = useCallback(async () => {
|
||
if (!useUrlLoadPreview) {
|
||
const activeIframe = iframeRef.current;
|
||
return activeIframe ? requestPreviewSnapshot(activeIframe) : null;
|
||
}
|
||
|
||
const srcDocIframe = srcDocPreviewIframeRef.current;
|
||
if (!srcDocIframe) {
|
||
const activeIframe = iframeRef.current;
|
||
return activeIframe ? requestPreviewSnapshot(activeIframe) : null;
|
||
}
|
||
|
||
if (!srcDocShellReady) {
|
||
await waitForIframeLoadOrTimeout(srcDocIframe, 500);
|
||
}
|
||
const activated = activateSrcDocSnapshotTransport(srcDocIframe);
|
||
if (activated) {
|
||
await waitForIframeLoadOrTimeout(srcDocIframe);
|
||
}
|
||
return requestPreviewSnapshot(srcDocIframe);
|
||
}, [
|
||
activateSrcDocSnapshotTransport,
|
||
srcDocShellReady,
|
||
useUrlLoadPreview,
|
||
]);
|
||
|
||
const prepareImageExportBlob = useCallback(async (format: ImageExportFormat) => {
|
||
const prepareId = imageExportPrepareIdRef.current + 1;
|
||
imageExportPrepareIdRef.current = prepareId;
|
||
setImageExportPreparing(true);
|
||
setImageExportError(null);
|
||
setImageExportPreparedBlob(null);
|
||
try {
|
||
let dataUrl = imageExportSnapshotDataUrlRef.current;
|
||
if (!dataUrl) {
|
||
const snap = await captureExportImageSnapshot();
|
||
if (!snap) throw new Error('Snapshot capture returned null');
|
||
dataUrl = snap.dataUrl;
|
||
imageExportSnapshotDataUrlRef.current = dataUrl;
|
||
}
|
||
const blob = await imageDataUrlToBlob(dataUrl, format);
|
||
if (blob.size <= 0) throw new Error('Snapshot capture produced an empty image');
|
||
if (imageExportPrepareIdRef.current === prepareId) {
|
||
setImageExportPreparedBlob({ format, blob });
|
||
}
|
||
} catch (err) {
|
||
console.warn('[exportAsImage] failed to prepare snapshot:', err);
|
||
if (imageExportPrepareIdRef.current === prepareId) {
|
||
setImageExportError(t('fileViewer.exportImageFailed'));
|
||
}
|
||
} finally {
|
||
if (imageExportPrepareIdRef.current === prepareId) {
|
||
setImageExportPreparing(false);
|
||
}
|
||
}
|
||
}, [captureExportImageSnapshot, t]);
|
||
|
||
const openImageExportModal = () => {
|
||
setShareMenuOpen(false);
|
||
setImageExportError(null);
|
||
setImageExportPreparedBlob(null);
|
||
imageExportSnapshotDataUrlRef.current = null;
|
||
setImageExportModalOpen(true);
|
||
void prepareImageExportBlob(imageExportFormat);
|
||
};
|
||
|
||
const changeImageExportFormat = (format: ImageExportFormat) => {
|
||
setImageExportFormat(format);
|
||
void prepareImageExportBlob(format);
|
||
};
|
||
|
||
async function handleImageExportSave() {
|
||
const prepared = imageExportPreparedBlob;
|
||
if (!prepared || prepared.format !== imageExportFormat) {
|
||
setImageExportError(t('fileViewer.exportImageFailed'));
|
||
return;
|
||
}
|
||
setImageExportBusy(true);
|
||
setImageExportError(null);
|
||
try {
|
||
const target = await prepareImageExportTarget(exportTitle, imageExportFormat, { useNativePicker: false });
|
||
if (!target) return;
|
||
const preparedDataUrl = imageExportSnapshotDataUrlRef.current;
|
||
if (target.method === 'download' && imageExportFormat === 'png' && preparedDataUrl) {
|
||
downloadImageDataUrl(preparedDataUrl, target.filename);
|
||
} else {
|
||
await target.save(prepared.blob);
|
||
}
|
||
setImageExportModalOpen(false);
|
||
setImageExportSavedToast({
|
||
message: target.method === 'picker'
|
||
? t('fileViewer.exportImageSaved')
|
||
: t('fileViewer.exportImageDownloadStarted'),
|
||
details: target.method === 'picker'
|
||
? target.filename
|
||
: t('fileViewer.exportImageDownloadDetails', { filename: target.filename }),
|
||
});
|
||
} catch (err) {
|
||
console.warn('[exportAsImage] failed to save snapshot:', err);
|
||
setImageExportError(t('fileViewer.exportImageFailed'));
|
||
} finally {
|
||
setImageExportBusy(false);
|
||
}
|
||
}
|
||
const visibleSideComments = useMemo(
|
||
() => previewComments
|
||
.filter((comment) => comment.filePath === file.name && comment.status === 'open')
|
||
.sort((a, b) => commentActivityAt(b) - commentActivityAt(a)),
|
||
[file.name, previewComments],
|
||
);
|
||
const activeSideCommentId = activePreviewCommentId;
|
||
const activeCommentTargetVisible = commentTargetIntersectsPreview(
|
||
activeCommentTarget,
|
||
overlayPreviewScale,
|
||
{ x: overlayPreviewTransform.offsetX, y: overlayPreviewTransform.offsetY },
|
||
previewBodySize,
|
||
);
|
||
useEffect(() => {
|
||
if (!boardMode || !activePreviewCommentId) return;
|
||
const stillOpen = visibleSideComments.some((comment) => comment.id === activePreviewCommentId);
|
||
if (!stillOpen) clearBoardComposer();
|
||
}, [activePreviewCommentId, boardMode, visibleSideComments]);
|
||
const activeDeployment = deployResult || deployment;
|
||
const activeDeployedUrl = activeDeployment?.url?.trim() || '';
|
||
const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed';
|
||
const activeDeploymentProtected = activeDeployment?.status === 'protected';
|
||
const activeCloudflarePages = activeDeployment?.providerId === CLOUDFLARE_PAGES_PROVIDER_ID
|
||
? activeDeployment.cloudflarePages
|
||
: undefined;
|
||
const activeCloudflareCustomDomain = activeCloudflarePages?.customDomain;
|
||
const deployProvider = getDeployProviderOption(deployProviderId);
|
||
const deployProviderLabel = t(deployProvider.labelKey);
|
||
const selectedCloudflareZone = cloudflareZones.find((zone) => zone.id === cloudflareZoneId) ?? null;
|
||
const normalizedCloudflarePrefix = normalizeCloudflareDomainPrefixInput(cloudflareDomainPrefix);
|
||
const cloudflareHostnamePreview =
|
||
selectedCloudflareZone && normalizedCloudflarePrefix
|
||
? `${normalizedCloudflarePrefix}.${selectedCloudflareZone.name}`
|
||
: '';
|
||
const deployResultCards: DeployResultCard[] = activeCloudflarePages
|
||
? (() => {
|
||
const cards: DeployResultCard[] = [];
|
||
const pagesDevUrl = activeCloudflarePages.pagesDev?.url || activeDeployedUrl;
|
||
if (pagesDevUrl) {
|
||
cards.push({
|
||
id: 'pages-dev',
|
||
label: t('fileViewer.cloudflarePagesDevLinkLabel'),
|
||
url: pagesDevUrl,
|
||
status: activeCloudflarePages.pagesDev?.status || activeDeployment?.status || 'link-delayed',
|
||
message: activeCloudflarePages.pagesDev?.statusMessage,
|
||
});
|
||
}
|
||
if (activeCloudflareCustomDomain?.url) {
|
||
cards.push({
|
||
id: 'custom-domain',
|
||
label: t('fileViewer.cloudflareCustomDomainLinkLabel'),
|
||
url: activeCloudflareCustomDomain.url,
|
||
status: activeCloudflareCustomDomain.status,
|
||
message:
|
||
activeCloudflareCustomDomain.errorMessage ||
|
||
activeCloudflareCustomDomain.statusMessage,
|
||
});
|
||
}
|
||
return cards;
|
||
})()
|
||
: activeDeployedUrl
|
||
? [{
|
||
id: 'default',
|
||
label: activeDeploymentProtected
|
||
? t('fileViewer.deployLinkProtectedLabel')
|
||
: activeDeploymentDelayed
|
||
? t('fileViewer.deployLinkPreparingLabel')
|
||
: t('fileViewer.deployResultLabel'),
|
||
url: activeDeployedUrl,
|
||
status: activeDeployment?.status || 'ready',
|
||
message: activeDeploymentProtected
|
||
? t('fileViewer.deployLinkProtected')
|
||
: activeDeploymentDelayed
|
||
? t('fileViewer.deployLinkDelayed')
|
||
: activeDeployment?.statusMessage,
|
||
}]
|
||
: [];
|
||
const deployActionLabelFor = (providerId: WebDeployProviderId) => {
|
||
const option = getDeployProviderOption(providerId);
|
||
const label = t(option.labelKey);
|
||
const hasActiveDeploymentForProvider = Boolean(deploymentsByProvider[providerId]?.url?.trim());
|
||
return hasActiveDeploymentForProvider
|
||
? t('fileViewer.redeployToProvider', { provider: label })
|
||
: t('fileViewer.deployToProvider', { provider: label });
|
||
};
|
||
const deployCopyLinks = DEPLOY_PROVIDER_OPTIONS.map((option) => ({
|
||
providerId: option.id,
|
||
providerLabel: t(option.labelKey),
|
||
url: deploymentsByProvider[option.id]?.url?.trim() || '',
|
||
})).filter((item) => item.url);
|
||
const deployButtonLabel =
|
||
deployPhase === 'deploying'
|
||
? t('fileViewer.deployingToProvider', { provider: deployProviderLabel })
|
||
: deployPhase === 'preparing-link'
|
||
? t('fileViewer.preparingPublicLink')
|
||
: deployActionLabelFor(deployProviderId);
|
||
const copyDeployLabel = (url: string) =>
|
||
copiedDeployLink === url.trim()
|
||
? t('fileViewer.copied')
|
||
: t('fileViewer.copyDeployLink');
|
||
const copyDeployMenuLabel = (providerLabel: string, url: string) =>
|
||
copiedDeployLink === url.trim()
|
||
? t('fileViewer.copied')
|
||
: providerLabel.toLowerCase().includes('cloudflare')
|
||
? t('fileViewer.copyCloudflareLink')
|
||
: t('fileViewer.copyProviderLink', { provider: providerLabel });
|
||
const statusLabelFor = (state: ReturnType<typeof deployResultState>) => {
|
||
if (state === 'ready') return t('fileViewer.deployLinkReady');
|
||
if (state === 'protected') return t('fileViewer.deployLinkProtectedLabel');
|
||
if (state === 'failed') return t('fileViewer.deployLinkFailed');
|
||
return t('fileViewer.deployLinkPreparingLabel');
|
||
};
|
||
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}
|
||
selectedTarget={selectedManualEditTarget}
|
||
draft={manualEditDraft}
|
||
history={manualEditHistory}
|
||
error={manualEditError}
|
||
canUndo={manualEditHistory.length > 0}
|
||
canRedo={manualEditUndone.length > 0}
|
||
busy={manualEditSaving}
|
||
pageStylesEnabled={manualEditPageStylesEnabled}
|
||
onSelectTarget={selectManualEditTarget}
|
||
onDraftChange={setManualEditDraft}
|
||
onStyleChange={(id, styles, label) => {
|
||
void handleManualEditStyleChange(id, styles, label);
|
||
}}
|
||
onInvalidStyle={cancelManualEditPendingStyles}
|
||
onApplyPatch={(patch, label) => {
|
||
void applyManualEdit(patch, label);
|
||
}}
|
||
onError={setManualEditError}
|
||
onClearSelection={() => {
|
||
void clearManualEditTargetSelection();
|
||
}}
|
||
onExit={() => {
|
||
void exitManualEditModeAfterFlush();
|
||
}}
|
||
onCancelDraft={() => {
|
||
cancelManualEditModeAndExit();
|
||
}}
|
||
onSaveDraft={() => {
|
||
void exitManualEditModeAfterFlush();
|
||
}}
|
||
onUndo={() => {
|
||
void undoManualEdit();
|
||
}}
|
||
onRedo={() => {
|
||
void redoManualEdit();
|
||
}}
|
||
floatingStyle={selectedManualEditTarget
|
||
? {
|
||
...manualEditFloatingPanelStyle(
|
||
selectedManualEditTarget,
|
||
overlayPreviewScale,
|
||
previewBodySize,
|
||
),
|
||
...(manualEditPanelPosition ?? {}),
|
||
}
|
||
: undefined}
|
||
onFloatingPositionChange={setManualEditPanelPosition}
|
||
onPickImage={async (pickedFile) => {
|
||
const result = await uploadProjectFiles(projectId, [pickedFile]);
|
||
const uploaded = result.uploaded[0];
|
||
if (!uploaded?.path) {
|
||
setManualEditError(result.error ?? t('manualEdit.uploadImageFailed'));
|
||
return null;
|
||
}
|
||
setManualEditError(null);
|
||
return toOwnerRelativePath(file.name, uploaded.path);
|
||
}}
|
||
/>
|
||
) : null;
|
||
const activeComposerComment = activePreviewCommentId
|
||
? visibleSideComments.find((comment) => comment.id === activePreviewCommentId) ?? null
|
||
: null;
|
||
const commentComposer = boardMode && activeCommentTarget && activeCommentTargetVisible ? (
|
||
<BoardComposerPopover
|
||
target={activeCommentTarget}
|
||
existing={activeComposerComment}
|
||
draft={commentDraft}
|
||
notes={queuedBoardNotes}
|
||
onDraft={setCommentDraft}
|
||
onAddDraft={queueCurrentDraft}
|
||
onRemoveQueuedNote={(index) =>
|
||
setQueuedBoardNotes((current) => current.filter((_, currentIndex) => currentIndex !== index))
|
||
}
|
||
onClose={clearBoardComposer}
|
||
onSaveComment={() => { fireCommentPopoverClick('save_comment'); return savePersistentComment(); }}
|
||
onSendBatch={() => { fireCommentPopoverClick('send_to_chat'); return sendBoardBatch(); }}
|
||
onRemoveMember={(elementId) => {
|
||
setActiveCommentTarget((current) => {
|
||
const { next, shouldClose } = applyPodMemberRemoval(current, elementId);
|
||
if (shouldClose) clearBoardComposer();
|
||
return next;
|
||
});
|
||
setHoveredPodMemberId((current) => (current === elementId ? null : current));
|
||
}}
|
||
onHoverMember={setHoveredPodMemberId}
|
||
onDeleteComment={onRemovePreviewComment ? async (commentId) => {
|
||
await onRemovePreviewComment(commentId);
|
||
clearBoardComposer();
|
||
setSelectedSideCommentIds((current) => {
|
||
if (!current.has(commentId)) return current;
|
||
const next = new Set(current);
|
||
next.delete(commentId);
|
||
return next;
|
||
});
|
||
setActivePreviewCommentId((current) => (current === commentId ? null : current));
|
||
} : undefined}
|
||
sending={sendingBoardBatch || commentSendDisabled}
|
||
|
||
t={t}
|
||
scale={overlayPreviewScale}
|
||
offset={{ x: overlayPreviewTransform.offsetX, y: overlayPreviewTransform.offsetY }}
|
||
bounds={previewBodySize}
|
||
docked={false}
|
||
commenting
|
||
/>
|
||
) : null;
|
||
const commentSidePanel = commentPanelOpen ? (
|
||
<CommentSideDock
|
||
comments={visibleSideComments}
|
||
selectedIds={selectedSideCommentIds}
|
||
activeCommentId={activeSideCommentId}
|
||
collapsed={commentPortalHost ? false : commentSidePanelCollapsed}
|
||
onCollapsedChange={setCommentSidePanelCollapsed}
|
||
onToggleSelect={(commentId) => {
|
||
setSelectedSideCommentIds((current) => {
|
||
const next = new Set(current);
|
||
if (next.has(commentId)) next.delete(commentId);
|
||
else next.add(commentId);
|
||
return next;
|
||
});
|
||
}}
|
||
onSelectAll={() => setSelectedSideCommentIds(new Set(visibleSideComments.map((comment) => comment.id)))}
|
||
onClearSelection={() => setSelectedSideCommentIds(new Set())}
|
||
onReply={(comment) => {
|
||
// Reply == edit on a flat-thread model: prefill the
|
||
// popover with the existing note so the user sees and
|
||
// mutates the current text. Save runs through the
|
||
// same upsert path; matching project/conv/file/element
|
||
// updates note in place rather than creating a new row.
|
||
const snapshot = liveSnapshotForComment(comment, liveCommentTargets) ?? {
|
||
filePath: comment.filePath,
|
||
elementId: comment.elementId,
|
||
selector: comment.selector,
|
||
label: comment.label,
|
||
text: comment.text,
|
||
position: comment.position,
|
||
htmlHint: comment.htmlHint,
|
||
style: comment.style,
|
||
selectionKind: comment.selectionKind ?? 'element',
|
||
memberCount: comment.memberCount,
|
||
podMembers: comment.podMembers,
|
||
};
|
||
setActiveCommentTarget(snapshot);
|
||
setHoveredCommentTarget(snapshot);
|
||
setActivePreviewCommentId(comment.id);
|
||
setCommentDraft(comment.note);
|
||
setQueuedBoardNotes([]);
|
||
setBoardMode(true);
|
||
setCommentCreateMode(true);
|
||
setCommentPanelOpen(true);
|
||
setCommentSidePanelCollapsed(false);
|
||
}}
|
||
onSendSelected={async () => {
|
||
if (!onSendBoardCommentAttachments) return;
|
||
const selected = visibleSideComments.filter(
|
||
(comment) => selectedSideCommentIds.has(comment.id),
|
||
);
|
||
if (selected.length === 0) return;
|
||
fireCommentPopoverClick('send_to_chat');
|
||
setSendingBoardBatch(true);
|
||
try {
|
||
const accepted = await onSendBoardCommentAttachments(commentsToAttachments(selected));
|
||
if (accepted !== false) setSelectedSideCommentIds(new Set());
|
||
} finally {
|
||
setSendingBoardBatch(false);
|
||
}
|
||
}}
|
||
onCreateComment={savePanelComment}
|
||
sending={sendingBoardBatch || commentSendDisabled}
|
||
t={t}
|
||
composer={null}
|
||
/>
|
||
) : null;
|
||
|
||
return (
|
||
<div className="viewer html-viewer">
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left">
|
||
<div className="viewer-tabs" role="tablist" aria-label="View mode">
|
||
{([
|
||
['preview', t('fileViewer.preview')],
|
||
['source', t('fileViewer.source')],
|
||
] as const).map(([id, label]) => (
|
||
<button
|
||
key={id}
|
||
type="button"
|
||
role="tab"
|
||
className={`viewer-tab ${mode === id ? 'active' : ''}`}
|
||
aria-selected={mode === id}
|
||
onClick={() => {
|
||
fireArtifactToolbarClick(id);
|
||
selectMode(id);
|
||
}}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{showPreviewToolbarControls ? (
|
||
<>
|
||
<span className="viewer-divider" aria-hidden />
|
||
<PreviewViewportControls
|
||
viewport={previewViewport}
|
||
onViewport={setPreviewViewport}
|
||
t={t}
|
||
/>
|
||
</>
|
||
) : null}
|
||
{showPreviewToolbarControls && effectiveDeck ? (
|
||
<span
|
||
className="deck-nav"
|
||
role="group"
|
||
aria-label={t('fileViewer.slideNavAria')}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="icon-only"
|
||
onClick={() => postSlide('prev')}
|
||
title={t('fileViewer.previousSlide')}
|
||
aria-label={t('fileViewer.previousSlide')}
|
||
disabled={slideState !== null && slideState.active <= 0}
|
||
>
|
||
<Icon name="chevron-right" size={14} style={{ transform: 'rotate(180deg)' }} />
|
||
</button>
|
||
<span className="deck-nav-counter">
|
||
{slideState
|
||
? `${slideState.active + 1} / ${slideState.count}`
|
||
: '— / —'}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className="icon-only"
|
||
onClick={() => postSlide('next')}
|
||
title={t('fileViewer.nextSlide')}
|
||
aria-label={t('fileViewer.nextSlide')}
|
||
disabled={
|
||
slideState !== null &&
|
||
slideState.active >= slideState.count - 1
|
||
}
|
||
>
|
||
<Icon name="chevron-right" size={14} />
|
||
</button>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
<div className="viewer-toolbar-actions">
|
||
{showPreviewToolbarControls ? (
|
||
<>
|
||
<div className="artifact-tool-menu-anchor">
|
||
<button
|
||
type="button"
|
||
className={`viewer-action viewer-action-icon viewer-comment-toggle${boardMode && !commentCreateMode && boardTool === 'inspect' ? ' active' : ''}`}
|
||
data-testid="board-mode-toggle"
|
||
data-tooltip={t('fileViewer.comment')}
|
||
title={t('fileViewer.comment')}
|
||
aria-label={t('fileViewer.comment')}
|
||
aria-pressed={boardMode && !commentCreateMode && boardTool === 'inspect'}
|
||
onClick={activateCommentTool}
|
||
>
|
||
<RemixIcon name="chat-new-line" size={15} />
|
||
</button>
|
||
</div>
|
||
<button
|
||
className={`viewer-action viewer-action-icon${drawOverlayOpen ? ' active' : ''}`}
|
||
type="button"
|
||
data-testid="draw-overlay-toggle"
|
||
data-tooltip={t('fileViewer.mark')}
|
||
title={t('fileViewer.mark')}
|
||
aria-label={t('fileViewer.mark')}
|
||
aria-pressed={drawOverlayOpen}
|
||
onClick={activateDrawTool}
|
||
>
|
||
<RemixIcon name="mark-pen-line" size={15} />
|
||
</button>
|
||
<span className="viewer-toolbar-tool-divider" aria-hidden />
|
||
<button
|
||
className={`viewer-action viewer-action-icon${manualEditMode ? ' active' : ''}`}
|
||
type="button"
|
||
data-testid="manual-edit-mode-toggle"
|
||
data-tooltip={t('fileViewer.edit')}
|
||
title={t('fileViewer.edit')}
|
||
aria-label={t('fileViewer.edit')}
|
||
aria-pressed={manualEditMode}
|
||
onClick={activateManualEditTool}
|
||
>
|
||
<RemixIcon name="edit-line" size={15} />
|
||
</button>
|
||
<span className="viewer-toolbar-tool-divider" aria-hidden />
|
||
<button
|
||
type="button"
|
||
className={`viewer-action viewer-comment-count-trigger viewer-comment-toggle${boardMode && commentCreateMode ? ' active' : ''}`}
|
||
data-testid="comment-panel-toggle"
|
||
data-tooltip={t('chat.tabComments')}
|
||
title={t('chat.tabComments')}
|
||
aria-label={`${t('chat.tabComments')} (${visibleSideComments.length})`}
|
||
aria-pressed={boardMode && commentCreateMode}
|
||
onClick={activateCommentCreateTool}
|
||
>
|
||
<RemixIcon name="message-3-line" size={15} />
|
||
<span className="viewer-comment-count" aria-hidden>{visibleSideComments.length}</span>
|
||
</button>
|
||
{source !== null && mode === 'preview' ? (
|
||
<div className="zoom-menu viewer-toolbar-zoom" ref={zoomMenuRef}>
|
||
<button
|
||
type="button"
|
||
className="viewer-action zoom-trigger"
|
||
aria-haspopup="menu"
|
||
aria-expanded={zoomMenuOpen}
|
||
onClick={() => {
|
||
fireArtifactToolbarClick('zoom_level_dropdown');
|
||
setZoomMenuOpen((v) => !v);
|
||
}}
|
||
>
|
||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span>
|
||
</button>
|
||
{zoomMenuOpen ? (
|
||
<div className="zoom-menu-popover" role="menu">
|
||
{[50, 75, 100, 125, 150, 200].map((level) => (
|
||
<button
|
||
key={level}
|
||
type="button"
|
||
className={`zoom-menu-item${zoom === level ? ' active' : ''}`}
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setZoom(level);
|
||
setZoomMenuOpen(false);
|
||
}}
|
||
>
|
||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{level}%</span>
|
||
{zoom === level ? (
|
||
<Icon name="check" size={13} />
|
||
) : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
{!showPreviewToolbarControls ? (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className={`viewer-action viewer-comment-toggle${boardMode && !commentCreateMode && boardTool === 'inspect' ? ' active' : ''}`}
|
||
data-testid="board-mode-toggle"
|
||
title={t('fileViewer.comment')}
|
||
aria-label={t('fileViewer.comment')}
|
||
aria-pressed={boardMode && !commentCreateMode && boardTool === 'inspect'}
|
||
onClick={activateCommentTool}
|
||
>
|
||
<RemixIcon name="chat-new-line" size={14} />
|
||
<span>{t('fileViewer.comment')}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`viewer-action viewer-comment-toggle${boardMode && commentCreateMode ? ' active' : ''}`}
|
||
data-testid="comment-panel-toggle"
|
||
title={t('chat.tabComments')}
|
||
aria-pressed={boardMode && commentCreateMode}
|
||
onClick={activateCommentCreateTool}
|
||
>
|
||
<RemixIcon name="message-3-line" size={14} />
|
||
<span>{t('chat.tabComments')}</span>
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{((filePrimaryActions: ReactNode) => (
|
||
chromeActionsHost ? createPortal(filePrimaryActions, chromeActionsHost) : filePrimaryActions
|
||
))(<>
|
||
{showPresent ? (
|
||
<div className="present-wrap chrome-present-wrap">
|
||
<button
|
||
className="chrome-action chrome-action-secondary chrome-action-icon present-trigger"
|
||
aria-haspopup="menu"
|
||
aria-expanded={presentMenuOpen}
|
||
aria-label={t('fileViewer.present')}
|
||
data-tooltip={t('fileViewer.present')}
|
||
title={t('fileViewer.present')}
|
||
onClick={() => {
|
||
fireArtifactHeaderClick('present_dropdown');
|
||
setPresentMenuOpen((v) => !v);
|
||
}}
|
||
>
|
||
<RemixIcon name="slideshow-3-line" size={15} />
|
||
</button>
|
||
{presentMenuOpen ? (
|
||
<div className="present-menu" role="menu">
|
||
<button role="menuitem" onClick={() => { firePresentPopoverClick('in_this_tab'); presentInThisTab(); }}>
|
||
<span className="present-icon"><RemixIcon name="eye-line" size={14} /></span>{' '}
|
||
{t('fileViewer.presentInTab')}
|
||
</button>
|
||
<button role="menuitem" onClick={() => { firePresentPopoverClick('fullscreen'); presentFullscreen(); }}>
|
||
<span className="present-icon"><RemixIcon name="play-line" size={14} /></span>{' '}
|
||
{t('fileViewer.presentFullscreen')}
|
||
</button>
|
||
<button role="menuitem" onClick={() => { firePresentPopoverClick('new_tab'); presentNewTab(); }}>
|
||
<span className="present-icon"><RemixIcon name="share-forward-line" size={14} /></span>{' '}
|
||
{t('fileViewer.presentNewTab')}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{canShare ? (
|
||
<div className="share-menu chrome-share-menu" ref={shareRef}>
|
||
<button
|
||
type="button"
|
||
className={
|
||
'chrome-action chrome-action-primary chrome-action-export' +
|
||
(exportReadyNudge ? ' export-ready-nudge' : '')
|
||
}
|
||
aria-haspopup="menu"
|
||
aria-expanded={shareMenuOpen}
|
||
onClick={openExportMenu}
|
||
>
|
||
<Icon name="download" size={13} />
|
||
<span>{t('fileViewer.shareLabel')}</span>
|
||
<Icon name="chevron-down" size={11} />
|
||
</button>
|
||
{shareMenuOpen ? (
|
||
<div className="share-menu-popover" role="menu">
|
||
{deployCopyLinks.length > 0 ? (
|
||
<>
|
||
<div className="share-menu-section-label" role="presentation">
|
||
{t('fileViewer.shareMenuShareLink')}
|
||
</div>
|
||
{deployCopyLinks.map((item) => (
|
||
<button
|
||
key={`copy-${item.providerId}`}
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
void copyDeployLink(item.url);
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="file-copy-line" size={15} /></span>
|
||
<span>{copyDeployMenuLabel(item.providerLabel, item.url)}</span>
|
||
</button>
|
||
))}
|
||
<div className="share-menu-divider" />
|
||
</>
|
||
) : null}
|
||
<div className="share-menu-section-label" role="presentation">
|
||
{t('fileViewer.shareMenuPublishOnline')}
|
||
</div>
|
||
{DEPLOY_PROVIDER_OPTIONS.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
const format =
|
||
option.id === 'cloudflare-pages'
|
||
? 'cloudflare_pages'
|
||
: option.id === 'vercel-self'
|
||
? 'vercel'
|
||
: 'vercel';
|
||
fireShareExport(format, () => openDeployModal(option.id));
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="upload-cloud-line" size={15} /></span>
|
||
<span>{deployActionLabelFor(option.id)}</span>
|
||
</button>
|
||
))}
|
||
<div className="share-menu-divider" />
|
||
<div className="share-menu-section-label" role="presentation">
|
||
{t('fileViewer.shareMenuDownload')}
|
||
</div>
|
||
<div className="share-menu-subsection-label" role="presentation">
|
||
{t('fileViewer.shareMenuPresentation')}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="share-menu-item share-menu-subitem"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
fireShareExport('pdf', () => exportProjectAsPdf({
|
||
deck: effectiveDeck,
|
||
fallbackPdf: () => exportAsPdf(source ?? '', exportTitle, { deck: effectiveDeck }),
|
||
filePath: file.name,
|
||
projectId,
|
||
title: exportTitle,
|
||
}));
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="file-line" size={15} /></span>
|
||
<span>
|
||
{effectiveDeck
|
||
? t('fileViewer.exportPdfAllSlides')
|
||
: t('fileViewer.exportPdf')}
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="share-menu-item share-menu-subitem"
|
||
role="menuitem"
|
||
disabled={!canPptx}
|
||
title={
|
||
onExportAsPptx
|
||
? streaming
|
||
? t('fileViewer.exportPptxBusy')
|
||
: t('fileViewer.exportPptxHint')
|
||
: t('fileViewer.exportPptxNa')
|
||
}
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
fireShareExport('pptx', () => {
|
||
if (onExportAsPptx) onExportAsPptx(file.name);
|
||
});
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="file-ppt-line" size={15} /></span>
|
||
<span>{t('fileViewer.exportPptx') + '…'}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="share-menu-item share-menu-subitem"
|
||
role="menuitem"
|
||
onClick={openImageExportModal}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="image-line" size={15} /></span>
|
||
<span>{t('fileViewer.exportImage')}</span>
|
||
</button>
|
||
<div className="share-menu-subsection-label" role="presentation">
|
||
{t('fileViewer.shareMenuSourceFiles')}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="share-menu-item share-menu-subitem"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
fireShareExport('zip', () => exportProjectAsZip({
|
||
projectId,
|
||
filePath: file.name,
|
||
fallbackHtml: source ?? '',
|
||
fallbackTitle: exportTitle,
|
||
}));
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="file-zip-line" size={15} /></span>
|
||
<span>{t('fileViewer.exportZip')}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="share-menu-item share-menu-subitem"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
fireShareExport('html', () => exportAsHtml(source ?? '', exportTitle));
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="file-code-line" size={15} /></span>
|
||
<span>{t('fileViewer.exportHtml')}</span>
|
||
</button>
|
||
{/* Export as Markdown — pass-through download of the
|
||
artifact source with a `.md` extension. No conversion
|
||
runs; the file body is identical to the Source view.
|
||
Useful for piping the artifact into markdown-aware
|
||
tooling (LLM context windows, vault apps). See
|
||
issue #279. */}
|
||
<button
|
||
type="button"
|
||
className="share-menu-item share-menu-subitem"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
fireShareExport('markdown', () => exportAsMd(source ?? '', exportTitle));
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="file-line" size={15} /></span>
|
||
<span>{t('fileViewer.exportMd')}</span>
|
||
</button>
|
||
<div className="share-menu-divider" />
|
||
<div className="share-menu-section-label" role="presentation">
|
||
{t('fileViewer.shareMenuSave')}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
disabled={savingTemplate}
|
||
onClick={() => {
|
||
fireShareExport('template', () => {
|
||
openSaveAsTemplateModal();
|
||
});
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><RemixIcon name="file-copy-line" size={15} /></span>
|
||
<span>
|
||
{savingTemplate
|
||
? t('fileViewer.savingTemplate')
|
||
: templateNote
|
||
? templateNote
|
||
: t('fileViewer.saveAsTemplate')}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</>)}
|
||
<div className="viewer-body" ref={previewBodyRef}>
|
||
{source === null ? (
|
||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||
) : mode === 'preview' ? (
|
||
<div
|
||
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-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)
|
||
}
|
||
>
|
||
<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
|
||
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>
|
||
</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>
|
||
{commentPortalHost && commentSidePanel
|
||
? createPortal(commentSidePanel, commentPortalHost)
|
||
: commentPortalId
|
||
? null
|
||
: commentSidePanel}
|
||
{inspectMode && activeInspectTarget ? (
|
||
<InspectPanel
|
||
target={activeInspectTarget}
|
||
onApply={(prop, value) => {
|
||
const target = activeInspectTarget;
|
||
setInspectOverrides((current) =>
|
||
updateInspectOverride(current, target.elementId, target.selector, prop, value),
|
||
);
|
||
postInspectSet(target.elementId, target.selector, prop, value);
|
||
}}
|
||
onResetElement={(elementId) => {
|
||
setInspectOverrides((current) => {
|
||
if (!(elementId in current)) return current;
|
||
const next = { ...current };
|
||
delete next[elementId];
|
||
return next;
|
||
});
|
||
postInspectReset(elementId);
|
||
setActiveInspectTarget((current) => current && current.elementId === elementId
|
||
? current
|
||
: current);
|
||
}}
|
||
onSaveToSource={() => {
|
||
void saveInspectToSource();
|
||
}}
|
||
onClose={() => {
|
||
setActiveInspectTarget(null);
|
||
if (boardMode && boardTool === 'inspect') {
|
||
setActiveCommentTarget(null);
|
||
setHoveredCommentTarget(null);
|
||
}
|
||
}}
|
||
saving={savingInspect}
|
||
savedAt={inspectSavedAt}
|
||
error={inspectError}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<pre className="viewer-source">{source}</pre>
|
||
)}
|
||
</div>
|
||
{inTabPresent && source ? (
|
||
<div
|
||
className="present-overlay"
|
||
role="dialog"
|
||
aria-label={t('fileViewer.exitPresentation')}
|
||
>
|
||
<button
|
||
className="present-exit"
|
||
onClick={() => setInTabPresent(false)}
|
||
aria-label={t('fileViewer.exitPresentation')}
|
||
>
|
||
<Icon name="close" size={13} /> {t('fileViewer.exitPresentation')}
|
||
</button>
|
||
{useUrlLoadPreview ? (
|
||
<iframe
|
||
title="present"
|
||
sandbox="allow-scripts allow-downloads"
|
||
data-od-render-mode="url-load"
|
||
src={activePreviewSrcUrl}
|
||
/>
|
||
) : (
|
||
<iframe
|
||
title="present"
|
||
sandbox="allow-scripts allow-downloads"
|
||
data-od-render-mode="srcdoc"
|
||
srcDoc={srcDoc}
|
||
/>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
{imageExportModalOpen ? (
|
||
<div className="modal-backdrop" role="presentation">
|
||
<div
|
||
className="modal deploy-modal image-export-modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby={imageExportTitleId}
|
||
>
|
||
<div className="modal-head">
|
||
<div className="kicker">IMAGE</div>
|
||
<h2 id={imageExportTitleId}>{t('fileViewer.exportImage')}</h2>
|
||
<p className="subtitle">{t('fileViewer.exportImageModalSubtitle')}</p>
|
||
</div>
|
||
<div className="deploy-form image-export-form">
|
||
<fieldset className="image-export-format-field" disabled={imageExportBusy}>
|
||
<legend>{t('fileViewer.exportImageFormatLabel')}</legend>
|
||
<div className="image-export-format-options">
|
||
{IMAGE_EXPORT_FORMAT_OPTIONS.map((option) => (
|
||
<label
|
||
key={option.value}
|
||
className={`image-export-format-option${imageExportFormat === option.value ? ' active' : ''}`}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="image-export-format"
|
||
value={option.value}
|
||
aria-label={option.label}
|
||
checked={imageExportFormat === option.value}
|
||
onChange={() => changeImageExportFormat(option.value)}
|
||
/>
|
||
<span className="image-export-format-text">
|
||
<strong>{option.label}</strong>
|
||
<span aria-hidden="true">{option.extension}</span>
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</fieldset>
|
||
{imageExportError ? (
|
||
<p className="deploy-error" role="alert">{imageExportError}</p>
|
||
) : null}
|
||
</div>
|
||
<div className="modal-foot">
|
||
<button
|
||
type="button"
|
||
className="ghost-link button-like"
|
||
disabled={imageExportBusy}
|
||
onClick={() => {
|
||
setImageExportModalOpen(false);
|
||
setImageExportError(null);
|
||
}}
|
||
>
|
||
{t('common.cancel')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="viewer-action primary"
|
||
disabled={imageExportBusy || imageExportPreparing || !imageExportPreparedBlob}
|
||
onClick={() => {
|
||
void handleImageExportSave();
|
||
}}
|
||
>
|
||
{imageExportBusy || imageExportPreparing ? t('fileViewer.exportImageSaving') : t('common.save')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{templateModalOpen ? (
|
||
<div className="modal-backdrop" role="presentation">
|
||
<div className="modal deploy-modal" role="dialog" aria-modal="true">
|
||
<div className="modal-head">
|
||
<div className="kicker">TEMPLATE</div>
|
||
<h2>{t('fileViewer.saveAsTemplate')}</h2>
|
||
<p className="subtitle">{t('fileViewer.templateDescPrompt')}</p>
|
||
</div>
|
||
<div className="deploy-form">
|
||
<label className="field" htmlFor={templateNameId}>
|
||
<span className="field-label">{t('fileViewer.templateNamePrompt')}</span>
|
||
<input
|
||
id={templateNameId}
|
||
type="text"
|
||
value={templateName}
|
||
placeholder={t('fileViewer.templateNameDefault')}
|
||
autoFocus
|
||
onChange={(e) => setTemplateName(e.target.value)}
|
||
/>
|
||
</label>
|
||
<label className="field" htmlFor={templateDescriptionId}>
|
||
<span className="field-label">{t('fileViewer.templateDescPrompt')}</span>
|
||
<textarea
|
||
id={templateDescriptionId}
|
||
rows={3}
|
||
value={templateDescription}
|
||
placeholder={t('fileViewer.optional')}
|
||
onChange={(e) => setTemplateDescription(e.target.value)}
|
||
/>
|
||
</label>
|
||
{templateSaveError ? <p className="deploy-error">{templateSaveError}</p> : null}
|
||
</div>
|
||
<div className="modal-foot">
|
||
<button
|
||
type="button"
|
||
className="ghost-link button-like"
|
||
disabled={savingTemplate}
|
||
onClick={() => {
|
||
setTemplateModalOpen(false);
|
||
setTemplateSaveError(null);
|
||
}}
|
||
>
|
||
{t('common.cancel')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="viewer-action primary"
|
||
disabled={savingTemplate || !templateName.trim()}
|
||
onClick={() => {
|
||
void handleSaveAsTemplate();
|
||
}}
|
||
>
|
||
{savingTemplate ? t('fileViewer.savingTemplate') : t('common.save')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{deployModalOpen ? (
|
||
<div className="modal-backdrop" role="presentation">
|
||
<div className="modal deploy-modal deploy-flow-modal" role="dialog" aria-modal="true">
|
||
<div className="modal-head">
|
||
<div className="kicker">{deployProviderLabel}</div>
|
||
<h2>{t('fileViewer.deployToProvider', { provider: deployProviderLabel })}</h2>
|
||
<p className="subtitle">{t('fileViewer.deployModalSubtitle')}</p>
|
||
</div>
|
||
<div className="deploy-form">
|
||
<label className="deploy-provider-field">
|
||
<span>{t('fileViewer.deployProviderLabel')}</span>
|
||
<select
|
||
value={deployProviderId}
|
||
onChange={(e) => {
|
||
void changeDeployProvider(e.target.value as WebDeployProviderId);
|
||
}}
|
||
>
|
||
{DEPLOY_PROVIDER_OPTIONS.map((option) => (
|
||
<option key={option.id} value={option.id}>
|
||
{t(option.labelKey)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
<div className="field-label-row">
|
||
<label htmlFor="deploy-token">{t(deployProvider.tokenLabelKey)}</label>
|
||
<div className="field-label-note">
|
||
{deployConfig?.configured ? (
|
||
<p className="hint">{t(deployProvider.tokenReuseHintKey, { provider: deployProviderLabel })}</p>
|
||
) : null}
|
||
{deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? (
|
||
<p className="hint">{t('fileViewer.cloudflareApiTokenScopeHint')}</p>
|
||
) : null}
|
||
<a
|
||
href={deployProvider.tokenLink}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
>
|
||
{t(deployProvider.tokenLinkKey)}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<input
|
||
id="deploy-token"
|
||
type="password"
|
||
value={deployToken}
|
||
placeholder={t(deployProvider.tokenPlaceholderKey, { provider: deployProviderLabel })}
|
||
onChange={(e) => setDeployToken(e.target.value)}
|
||
/>
|
||
<div className="deploy-config-actions">
|
||
<button
|
||
type="button"
|
||
className="ghost-link button-like"
|
||
disabled={savingDeployConfig}
|
||
onClick={() => {
|
||
void saveDeployConfig();
|
||
}}
|
||
>
|
||
{savingDeployConfig ? t('fileViewer.savingConfig') : t('fileViewer.save')}
|
||
</button>
|
||
</div>
|
||
{deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? (
|
||
<>
|
||
<div className="deploy-field-grid single-field">
|
||
<label>
|
||
<span>{t('fileViewer.cloudflareAccountId')}</span>
|
||
<input
|
||
value={cloudflareAccountId}
|
||
onChange={(e) => setCloudflareAccountId(e.target.value)}
|
||
/>
|
||
<span className="field-hint">{t('fileViewer.cloudflareAccountIdHint')}</span>
|
||
</label>
|
||
</div>
|
||
<div className="deploy-field-grid cloudflare-domain-grid">
|
||
<label>
|
||
<span>{t('fileViewer.cloudflareDomainPrefixLabel')}</span>
|
||
<input
|
||
value={cloudflareDomainPrefix}
|
||
placeholder={t('fileViewer.cloudflareDomainPrefixPlaceholder')}
|
||
onChange={(e) => setCloudflareDomainPrefix(e.target.value)}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>{t('fileViewer.cloudflareZoneLabel')}</span>
|
||
<select
|
||
value={cloudflareZoneId}
|
||
disabled={cloudflareZonesLoading || (!deployConfig?.configured && !cloudflareZones.length)}
|
||
onChange={(e) => setCloudflareZoneId(e.target.value)}
|
||
>
|
||
{cloudflareZones.length === 0 ? (
|
||
<option value="">{t('fileViewer.cloudflareZonePlaceholder')}</option>
|
||
) : null}
|
||
{cloudflareZones.map((zone) => (
|
||
<option key={zone.id} value={zone.id}>
|
||
{zone.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="deploy-config-actions secondary">
|
||
<button
|
||
type="button"
|
||
className="ghost-link button-like"
|
||
disabled={cloudflareZonesLoading || !deployConfig?.configured}
|
||
onClick={() => {
|
||
void loadCloudflareZones();
|
||
}}
|
||
>
|
||
{cloudflareZonesLoading ? t('fileViewer.cloudflareZonesLoading') : t('fileViewer.cloudflareZonesRefresh')}
|
||
</button>
|
||
</div>
|
||
{cloudflareZonesError ? (
|
||
<p className="deploy-error">{cloudflareZonesError}</p>
|
||
) : cloudflareZonesLoading ? (
|
||
<p className="hint">{t('fileViewer.cloudflareZonesLoading')}</p>
|
||
) : deployConfig?.configured && cloudflareZones.length === 0 ? (
|
||
<p className="hint">{t('fileViewer.cloudflareZonesEmpty')}</p>
|
||
) : (
|
||
<p className="hint">{t('fileViewer.cloudflareCustomDomainHint')}</p>
|
||
)}
|
||
{cloudflareDomainPrefix.trim() && !isValidCloudflareDomainPrefixInput(cloudflareDomainPrefix) ? (
|
||
<p className="deploy-error">{t('fileViewer.cloudflareDomainPrefixInvalid')}</p>
|
||
) : cloudflareHostnamePreview ? (
|
||
<p className="hint">
|
||
{t('fileViewer.cloudflareHostnamePreview', { hostname: cloudflareHostnamePreview })}
|
||
</p>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<div className="deploy-field-grid">
|
||
<label>
|
||
<span>{t('fileViewer.vercelTeamId')}</span>
|
||
<input
|
||
value={teamId}
|
||
placeholder={t('fileViewer.optional')}
|
||
onChange={(e) => setTeamId(e.target.value)}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>{t('fileViewer.vercelTeamSlug')}</span>
|
||
<input
|
||
value={teamSlug}
|
||
placeholder={t('fileViewer.optional')}
|
||
onChange={(e) => setTeamSlug(e.target.value)}
|
||
/>
|
||
</label>
|
||
</div>
|
||
)}
|
||
<p className="hint">{t(deployProvider.previewHintKey)}</p>
|
||
{deployError ? <p className="deploy-error">{deployError}</p> : null}
|
||
{!deployError
|
||
&& deployPhase === 'idle'
|
||
&& deployResultCards.length > 0
|
||
&& deployResultState(activeDeployment?.status) === 'ready' ? (
|
||
<p className="hint" role="status">
|
||
{t('fileViewer.deployLinkReady')} · {t('fileViewer.deployResultLabel')}
|
||
</p>
|
||
) : null}
|
||
{deployResultCards.length > 0 ? (
|
||
<div className={`deploy-result-block ${deployResultState(activeDeployment?.status)}`}>
|
||
<div className="deploy-result-summary">
|
||
<div className="deploy-result-summary-head">
|
||
<div className="deploy-result-label">{t('fileViewer.deployResultLabel')}</div>
|
||
<div className={`deploy-result-badge ${deployResultState(activeDeployment?.status)}`}>
|
||
{statusLabelFor(deployResultState(activeDeployment?.status))}
|
||
</div>
|
||
</div>
|
||
{activeDeployment?.statusMessage ? (
|
||
<p className="deploy-result-message">{activeDeployment.statusMessage}</p>
|
||
) : null}
|
||
<div className="deploy-result-links">
|
||
{deployResultCards.map((card) => {
|
||
const state = deployResultState(card.status);
|
||
const canRetry = state === 'delayed' || state === 'protected';
|
||
const isDisabled = state === 'protected' || state === 'failed';
|
||
return (
|
||
<div key={card.id} className={`deploy-result-link ${state}`}>
|
||
<div className="deploy-result-link-main">
|
||
<div className="deploy-result-link-head">
|
||
<span className="deploy-result-link-label">{card.label}</span>
|
||
<span className={`deploy-result-link-state ${state}`}>{statusLabelFor(state)}</span>
|
||
</div>
|
||
{card.message ? (
|
||
<p className="deploy-result-link-message">{card.message}</p>
|
||
) : null}
|
||
<a
|
||
className="deploy-result-url"
|
||
href={card.url}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
>
|
||
{card.url}
|
||
</a>
|
||
</div>
|
||
<div className="deploy-result-actions">
|
||
{canRetry ? (
|
||
<button
|
||
type="button"
|
||
className="viewer-action"
|
||
disabled={deployPhase === 'preparing-link'}
|
||
onClick={() => {
|
||
void retryDeploymentLink();
|
||
}}
|
||
>
|
||
{deployPhase === 'preparing-link'
|
||
? t('fileViewer.preparingPublicLink')
|
||
: t('fileViewer.retryLink')}
|
||
</button>
|
||
) : null}
|
||
<button
|
||
type="button"
|
||
className="viewer-action"
|
||
onClick={() => {
|
||
void copyDeployLink(card.url);
|
||
}}
|
||
>
|
||
<Icon name="copy" size={14} />
|
||
<span>{copyDeployLabel(card.url)}</span>
|
||
</button>
|
||
<a
|
||
className={`ghost-link ${isDisabled ? 'disabled' : ''}`}
|
||
href={isDisabled ? undefined : card.url}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
aria-disabled={isDisabled}
|
||
>
|
||
<Icon name="upload" size={14} />
|
||
{t('fileViewer.open')}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="modal-foot">
|
||
<button
|
||
type="button"
|
||
className="ghost-link button-like"
|
||
onClick={() => setDeployModalOpen(false)}
|
||
>
|
||
{t('common.cancel')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="viewer-action primary"
|
||
disabled={deploying || savingDeployConfig || deployPhase !== 'idle'}
|
||
onClick={() => {
|
||
void deployToSelectedProvider();
|
||
}}
|
||
>
|
||
{deployButtonLabel}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{deploySavedToast ? (
|
||
<Toast
|
||
message={deploySavedToast.message}
|
||
details={deploySavedToast.details}
|
||
tone="success"
|
||
placement="top"
|
||
ttlMs={3600}
|
||
onDismiss={() => setDeploySavedToast(null)}
|
||
/>
|
||
) : null}
|
||
{imageExportSavedToast ? (
|
||
<Toast
|
||
message={imageExportSavedToast.message}
|
||
details={imageExportSavedToast.details}
|
||
tone="success"
|
||
placement="top"
|
||
ttlMs={3600}
|
||
onDismiss={() => setImageExportSavedToast(null)}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function baseDirFor(fileName: string): string {
|
||
const idx = fileName.lastIndexOf('/');
|
||
return idx >= 0 ? fileName.slice(0, idx + 1) : '';
|
||
}
|
||
|
||
function toOwnerRelativePath(ownerFileName: string, targetPath: string): string {
|
||
const normalize = (value: string) => decodeURIComponent(value).replace(/^\/+/, '');
|
||
const squash = (parts: string[]) => {
|
||
const out: string[] = [];
|
||
for (const part of parts) {
|
||
if (!part || part === '.') continue;
|
||
if (part === '..') {
|
||
if (out.length > 0) out.pop();
|
||
continue;
|
||
}
|
||
out.push(part);
|
||
}
|
||
return out;
|
||
};
|
||
const ownerDirPath = normalize(baseDirFor(ownerFileName));
|
||
const targetFilePath = normalize(targetPath);
|
||
const ownerParts = squash(ownerDirPath.split('/'));
|
||
const targetParts = squash(targetFilePath.split('/'));
|
||
|
||
let common = 0;
|
||
while (
|
||
common < ownerParts.length &&
|
||
common < targetParts.length &&
|
||
ownerParts[common] === targetParts[common]
|
||
) {
|
||
common += 1;
|
||
}
|
||
|
||
const up = new Array(ownerParts.length - common).fill('..');
|
||
const down = targetParts.slice(common);
|
||
const rel = [...up, ...down].join('/');
|
||
return rel || '.';
|
||
}
|
||
|
||
function hasRelativeAssetRefs(html: string): boolean {
|
||
const attr = /\s(?:src|href)\s*=\s*["']([^"']+)["']/gi;
|
||
let match: RegExpExecArray | null;
|
||
while ((match = attr.exec(html)) !== null) {
|
||
const value = match[1]?.trim();
|
||
if (!value) continue;
|
||
if (/^(?:https?:|data:|blob:|mailto:|tel:|#|\/)/i.test(value)) continue;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async function inlineRelativeAssets(
|
||
html: string,
|
||
projectId: string,
|
||
fileName: string,
|
||
): Promise<string> {
|
||
const replacements: Array<Promise<{ from: string; to: string } | null>> = [];
|
||
const links = html.match(/<link\b[^>]*>/gi) ?? [];
|
||
for (const tag of links) {
|
||
const rel = readHtmlAttr(tag, 'rel');
|
||
const href = readHtmlAttr(tag, 'href');
|
||
if (!rel || !/\bstylesheet\b/i.test(rel) || !href) continue;
|
||
replacements.push(
|
||
fetchProjectRelativeText(projectId, fileName, href).then((css) =>
|
||
css == null
|
||
? null
|
||
: {
|
||
from: tag,
|
||
to:
|
||
`<style data-od-inline-asset="${escapeHtmlAttr(href)}">\n` +
|
||
`${css.replace(/<\/style/gi, '<\\/style')}\n</style>`,
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
const scripts = html.match(/<script\b[^>]*\bsrc\s*=\s*["'][^"']+["'][^>]*>\s*<\/script>/gi) ?? [];
|
||
for (const tag of scripts) {
|
||
const src = readHtmlAttr(tag, 'src');
|
||
if (!src) continue;
|
||
replacements.push(
|
||
fetchProjectRelativeText(projectId, fileName, src).then((js) => {
|
||
if (js == null) return null;
|
||
const open = tag.match(/^<script\b[^>]*>/i)?.[0] ?? '<script>';
|
||
const attrs = open
|
||
.replace(/^<script/i, '')
|
||
.replace(/>$/i, '')
|
||
.replace(/\ssrc\s*=\s*(['"])[\s\S]*?\1/i, '');
|
||
return {
|
||
from: tag,
|
||
to: `<script${attrs}>\n${js.replace(/<\/script/gi, '<\\/script')}\n</script>`,
|
||
};
|
||
}),
|
||
);
|
||
}
|
||
|
||
const resolved = (await Promise.all(replacements)).filter(
|
||
(item): item is { from: string; to: string } => item !== null,
|
||
);
|
||
return resolved.reduce((next, { from, to }) => next.replace(from, () => to), html);
|
||
}
|
||
|
||
async function fetchProjectRelativeText(
|
||
projectId: string,
|
||
ownerFileName: string,
|
||
assetRef: string,
|
||
): Promise<string | null> {
|
||
const filePath = resolveProjectRelativePath(ownerFileName, assetRef);
|
||
if (!filePath) return null;
|
||
try {
|
||
const resp = await fetch(projectRawUrl(projectId, filePath));
|
||
if (!resp.ok) return null;
|
||
return await resp.text();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function resolveProjectRelativePath(ownerFileName: string, assetRef: string): string | null {
|
||
if (/^(?:https?:|data:|blob:|mailto:|tel:|#|\/)/i.test(assetRef)) return null;
|
||
try {
|
||
const url = new URL(assetRef, `https://od.local/${baseDirFor(ownerFileName)}`);
|
||
if (url.origin !== 'https://od.local') return null;
|
||
return decodeURIComponent(url.pathname.replace(/^\/+/, ''));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function readHtmlAttr(tag: string, name: string): string | null {
|
||
const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i'));
|
||
return match?.[2] ?? null;
|
||
}
|
||
|
||
function escapeHtmlAttr(value: string): string {
|
||
return value
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
function ImageViewer({
|
||
projectId,
|
||
file,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
}) {
|
||
const t = useT();
|
||
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
|
||
return (
|
||
<div className="viewer image-viewer">
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left">
|
||
<span className="viewer-meta">
|
||
{file.kind === 'sketch'
|
||
? t('fileViewer.sketchMeta', { size: humanSize(file.size) })
|
||
: t('fileViewer.imageMeta', { size: humanSize(file.size) })}
|
||
</span>
|
||
</div>
|
||
<div className="viewer-toolbar-actions">
|
||
<a
|
||
className="ghost-link"
|
||
href={projectFileUrl(projectId, file.name)}
|
||
download={file.name}
|
||
>
|
||
{t('fileViewer.download')}
|
||
</a>
|
||
<a
|
||
className="ghost-link"
|
||
href={projectFileUrl(projectId, file.name)}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
>
|
||
{t('fileViewer.open')}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<div className="viewer-body image-body">
|
||
<img alt={file.name} src={url} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SketchViewer({
|
||
projectId,
|
||
file,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
}) {
|
||
const t = useT();
|
||
return (
|
||
<div className="viewer image-viewer sketch-viewer">
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left">
|
||
<span className="viewer-meta">
|
||
{t('fileViewer.sketchMeta', { size: humanSize(file.size) })}
|
||
</span>
|
||
</div>
|
||
<FileActions projectId={projectId} file={file} />
|
||
</div>
|
||
<div className="viewer-body image-body">
|
||
<SketchPreview projectId={projectId} file={file} className="viewer-sketch-preview" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function VideoViewer({
|
||
projectId,
|
||
file,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
}) {
|
||
const t = useT();
|
||
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
|
||
return (
|
||
<div className="viewer video-viewer">
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left">
|
||
<span className="viewer-meta">
|
||
{t('fileViewer.videoMeta', { size: humanSize(file.size) })}
|
||
</span>
|
||
</div>
|
||
<FileActions projectId={projectId} file={file} />
|
||
</div>
|
||
<div className="viewer-body video-body">
|
||
<video src={url} controls playsInline preload="metadata" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AudioViewer({
|
||
projectId,
|
||
file,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
}) {
|
||
const t = useT();
|
||
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
|
||
return (
|
||
<div className="viewer audio-viewer">
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left">
|
||
<span className="viewer-meta">
|
||
{t('fileViewer.audioMeta', { size: humanSize(file.size) })}
|
||
</span>
|
||
</div>
|
||
<FileActions projectId={projectId} file={file} />
|
||
</div>
|
||
<div className="viewer-body audio-body">
|
||
<div className="audio-card">
|
||
<Icon name="mic" size={28} />
|
||
<div className="audio-card-name">{file.name}</div>
|
||
<audio src={url} controls preload="metadata" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type SvgViewerMode = 'preview' | 'source';
|
||
|
||
interface SvgViewerProps {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
initialMode?: SvgViewerMode;
|
||
initialSource?: string | null | undefined;
|
||
}
|
||
|
||
export function SvgViewer({
|
||
projectId,
|
||
file,
|
||
initialMode = 'preview',
|
||
initialSource,
|
||
}: SvgViewerProps) {
|
||
const t = useT();
|
||
const [mode, setMode] = useState<SvgViewerMode>(initialMode);
|
||
const [source, setSource] = useState<string | null>(initialSource ?? null);
|
||
const [loadingSource, setLoadingSource] = useState(false);
|
||
const [sourceError, setSourceError] = useState(false);
|
||
const [reloadKey, setReloadKey] = useState(0);
|
||
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}`;
|
||
|
||
useEffect(() => {
|
||
if (mode !== 'source') return;
|
||
if (initialSource !== undefined && reloadKey === 0) return;
|
||
let cancelled = false;
|
||
setLoadingSource(true);
|
||
setSourceError(false);
|
||
void fetchProjectFileText(projectId, file.name, {
|
||
cache: 'no-store',
|
||
cacheBustKey: `${Math.round(file.mtime)}-${reloadKey}`,
|
||
}).then((next) => {
|
||
if (cancelled) return;
|
||
if (next === null) {
|
||
setSource('');
|
||
setSourceError(true);
|
||
} else {
|
||
setSource(next);
|
||
}
|
||
setLoadingSource(false);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId, file.name, file.mtime, initialSource, mode, reloadKey]);
|
||
|
||
return (
|
||
<div className="viewer svg-viewer">
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left">
|
||
<span className="viewer-meta">
|
||
{t('fileViewer.imageMeta', { size: humanSize(file.size) })}
|
||
</span>
|
||
</div>
|
||
<div className="viewer-toolbar-actions">
|
||
<div className="viewer-tabs">
|
||
<button
|
||
type="button"
|
||
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
|
||
aria-pressed={mode === 'preview'}
|
||
onClick={() => setMode('preview')}
|
||
>
|
||
{t('fileViewer.preview')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
|
||
aria-pressed={mode === 'source'}
|
||
onClick={() => setMode('source')}
|
||
>
|
||
{t('fileViewer.source')}
|
||
</button>
|
||
</div>
|
||
<span className="viewer-divider" aria-hidden />
|
||
<button
|
||
type="button"
|
||
className="viewer-action"
|
||
onClick={() => setReloadKey((n) => n + 1)}
|
||
title={t('fileViewer.reloadDisk')}
|
||
>
|
||
<Icon name="reload" size={13} />
|
||
<span>{t('fileViewer.reload')}</span>
|
||
</button>
|
||
<a
|
||
className="ghost-link"
|
||
href={projectFileUrl(projectId, file.name)}
|
||
download={file.name}
|
||
>
|
||
{t('fileViewer.download')}
|
||
</a>
|
||
<a
|
||
className="ghost-link"
|
||
href={projectFileUrl(projectId, file.name)}
|
||
target="_blank"
|
||
rel="noreferrer noopener"
|
||
>
|
||
{t('fileViewer.open')}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<div className={`viewer-body ${mode === 'preview' ? 'image-body' : ''}`}>
|
||
{mode === 'preview' ? (
|
||
<img alt={file.name} src={url} />
|
||
) : loadingSource ? (
|
||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||
) : sourceError ? (
|
||
<div className="viewer-empty">{t('fileViewer.previewUnavailable')}</div>
|
||
) : (
|
||
<pre className="viewer-source">{source ?? ''}</pre>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TextViewer({
|
||
projectId,
|
||
file,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
}) {
|
||
const t = useT();
|
||
const [text, setText] = useState<string | null>(null);
|
||
const [reloadKey, setReloadKey] = useState(0);
|
||
const [copied, setCopied] = useState(false);
|
||
useEffect(() => {
|
||
setText(null);
|
||
let cancelled = false;
|
||
void fetchProjectFileText(projectId, file.name).then((t) => {
|
||
if (!cancelled) setText(t ?? '');
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId, file.name, file.mtime, reloadKey]);
|
||
|
||
async function copy() {
|
||
if (text == null) return;
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
setCopied(true);
|
||
window.setTimeout(() => setCopied(false), 1500);
|
||
} catch {
|
||
// best-effort fallback
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
try {
|
||
document.execCommand('copy');
|
||
setCopied(true);
|
||
window.setTimeout(() => setCopied(false), 1500);
|
||
} finally {
|
||
document.body.removeChild(ta);
|
||
}
|
||
}
|
||
}
|
||
|
||
const displayText = useMemo(
|
||
() => (text == null ? null : formatJsonFileTextForDisplay(file, text)),
|
||
[file.name, file.mime, text],
|
||
);
|
||
const lineCount = displayText ? displayText.split('\n').length : 0;
|
||
|
||
return (
|
||
<div className="viewer text-viewer">
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left" />
|
||
<div className="viewer-toolbar-actions">
|
||
<button
|
||
type="button"
|
||
className="viewer-action"
|
||
onClick={() => setReloadKey((n) => n + 1)}
|
||
title={t('fileViewer.reloadDisk')}
|
||
>
|
||
<Icon name="reload" size={13} />
|
||
<span>{t('fileViewer.reload')}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="viewer-action"
|
||
disabled
|
||
title={t('fileViewer.saveDisabled')}
|
||
>
|
||
<Icon name="check" size={13} />
|
||
<span>{t('fileViewer.save')}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="viewer-action"
|
||
onClick={() => void copy()}
|
||
title={t('fileViewer.copyTitle')}
|
||
>
|
||
<Icon name={copied ? 'check' : 'copy'} size={13} />
|
||
<span>{copied ? t('fileViewer.copied') : t('fileViewer.copy')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="viewer-body">
|
||
{text === null ? (
|
||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||
) : displayText !== null && lineCount > 0 ? (
|
||
<CodeWithLines text={displayText} />
|
||
) : (
|
||
<pre className="viewer-source">{displayText}</pre>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatJsonFileTextForDisplay(file: ProjectFile, text: string): string {
|
||
if (!isJsonFile(file)) return text;
|
||
try {
|
||
if (hasPrecisionSensitiveJsonNumberText(text)) return text;
|
||
const parsed = JSON.parse(text) as unknown;
|
||
if (hasUnsafeJsonNumber(parsed)) return text;
|
||
return JSON.stringify(parsed, null, 2);
|
||
} catch {
|
||
return text;
|
||
}
|
||
}
|
||
|
||
function hasPrecisionSensitiveJsonNumberText(text: string): boolean {
|
||
let inString = false;
|
||
let escaped = false;
|
||
const numberTokenPattern = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
||
for (let i = 0; i < text.length;) {
|
||
const char = text[i];
|
||
if (inString) {
|
||
if (escaped) {
|
||
escaped = false;
|
||
} else if (char === '\\') {
|
||
escaped = true;
|
||
} else if (char === '"') {
|
||
inString = false;
|
||
}
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
if (char === '"') {
|
||
inString = true;
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
numberTokenPattern.lastIndex = i;
|
||
const match = numberTokenPattern.exec(text);
|
||
if (!match) {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
const token = match[0];
|
||
if (isSignedNegativeZeroJsonNumberToken(token)) return true;
|
||
if (/[.eE]/.test(token) && isPrecisionSensitiveJsonNumberToken(token)) return true;
|
||
i = numberTokenPattern.lastIndex;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function isSignedNegativeZeroJsonNumberToken(token: string): boolean {
|
||
return /^-0(?:\.0+)?(?:[eE][+-]?\d+)?$/.test(token);
|
||
}
|
||
|
||
function isPrecisionSensitiveJsonNumberToken(token: string): boolean {
|
||
const parsed = Number(token);
|
||
if (!Number.isFinite(parsed)) return true;
|
||
const rendered = JSON.stringify(parsed);
|
||
if (!rendered) return true;
|
||
const originalValue = parseJsonNumberTokenAsDecimal(token);
|
||
const renderedValue = parseJsonNumberTokenAsDecimal(rendered);
|
||
return (
|
||
!originalValue ||
|
||
!renderedValue ||
|
||
originalValue.coefficient !== renderedValue.coefficient ||
|
||
originalValue.exponent !== renderedValue.exponent
|
||
);
|
||
}
|
||
|
||
function parseJsonNumberTokenAsDecimal(token: string): { coefficient: bigint; exponent: number } | null {
|
||
const match = /^(-)?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(token);
|
||
if (!match) return null;
|
||
const [, sign, integerPart, fractionPart = '', exponentPart = '0'] = match;
|
||
const coefficient = BigInt(`${sign ?? ''}${integerPart}${fractionPart}`);
|
||
const exponent = Number(exponentPart) - fractionPart.length;
|
||
return normalizeDecimalParts(coefficient, exponent);
|
||
}
|
||
|
||
function normalizeDecimalParts(coefficient: bigint, exponent: number): { coefficient: bigint; exponent: number } {
|
||
if (coefficient === 0n) return { coefficient: 0n, exponent: 0 };
|
||
let normalizedCoefficient = coefficient;
|
||
let normalizedExponent = exponent;
|
||
while (normalizedCoefficient % 10n === 0n) {
|
||
normalizedCoefficient /= 10n;
|
||
normalizedExponent += 1;
|
||
}
|
||
return { coefficient: normalizedCoefficient, exponent: normalizedExponent };
|
||
}
|
||
|
||
function hasUnsafeJsonNumber(value: unknown): boolean {
|
||
if (typeof value === 'number') {
|
||
return !Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value));
|
||
}
|
||
if (Array.isArray(value)) return value.some(hasUnsafeJsonNumber);
|
||
if (value && typeof value === 'object') return Object.values(value).some(hasUnsafeJsonNumber);
|
||
return false;
|
||
}
|
||
|
||
function isJsonFile(file: ProjectFile): boolean {
|
||
return file.name.toLowerCase().endsWith('.json') || file.mime.toLowerCase().startsWith('application/json');
|
||
}
|
||
|
||
function MarkdownViewer({
|
||
projectId,
|
||
file,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
}) {
|
||
const t = useT();
|
||
const [text, setText] = useState<string | null>(null);
|
||
const [reloadKey, setReloadKey] = useState(0);
|
||
const [copied, setCopied] = useState(false);
|
||
const markdownArticleRef = useRef<HTMLElement | null>(null);
|
||
const copyBlockTimerRef = useRef<number | null>(null);
|
||
const copiedMarkdownBlockRef = useRef<HTMLElement | null>(null);
|
||
const status = file.artifactManifest?.status ?? 'complete';
|
||
const isStreaming = status === 'streaming';
|
||
const isError = status === 'error';
|
||
|
||
useEffect(() => {
|
||
setText(null);
|
||
copiedMarkdownBlockRef.current = null;
|
||
if (copyBlockTimerRef.current) {
|
||
window.clearTimeout(copyBlockTimerRef.current);
|
||
copyBlockTimerRef.current = null;
|
||
}
|
||
let cancelled = false;
|
||
void fetchProjectFileText(projectId, file.name).then((next) => {
|
||
if (!cancelled) setText(next ?? '');
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId, file.name, file.mtime, reloadKey]);
|
||
|
||
useEffect(() => {
|
||
return () => {
|
||
copiedMarkdownBlockRef.current = null;
|
||
if (copyBlockTimerRef.current) {
|
||
window.clearTimeout(copyBlockTimerRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
|
||
async function copy() {
|
||
if (text == null) return;
|
||
const didCopy = await copyTextToClipboard(text);
|
||
if (didCopy) {
|
||
setCopied(true);
|
||
window.setTimeout(() => setCopied(false), 1500);
|
||
}
|
||
}
|
||
|
||
const html = useMemo(() => {
|
||
if (text === null) return null;
|
||
const renderPartial = MarkdownRenderer.renderPartial ?? renderMarkdownToSafeHtml;
|
||
return decorateMarkdownCodeBlocks(renderPartial(text));
|
||
}, [text]);
|
||
|
||
useEffect(() => {
|
||
const article = markdownArticleRef.current;
|
||
if (!article) return;
|
||
ensureMarkdownCodeBlockControls(article, t);
|
||
if (copiedMarkdownBlockRef.current?.isConnected) {
|
||
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, true, t);
|
||
}
|
||
}, [html, t]);
|
||
|
||
async function handleMarkdownBodyClick(event: ReactMouseEvent<HTMLElement>) {
|
||
const target = event.target;
|
||
if (!(target instanceof Element)) return;
|
||
const button = target.closest<HTMLButtonElement>(`button[${MARKDOWN_COPY_BLOCK_ATTR}]`);
|
||
if (!button) return;
|
||
const block = button.closest('.markdown-code-block');
|
||
if (!(block instanceof HTMLElement)) return;
|
||
const pre = block.querySelector('pre');
|
||
if (!pre) return;
|
||
const didCopy = await copyTextToClipboard(pre.textContent ?? '');
|
||
if (!didCopy) return;
|
||
if (copiedMarkdownBlockRef.current && copiedMarkdownBlockRef.current !== block) {
|
||
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, false, t);
|
||
}
|
||
copiedMarkdownBlockRef.current = block;
|
||
setMarkdownCodeBlockCopiedState(block, true, t);
|
||
if (copyBlockTimerRef.current) {
|
||
window.clearTimeout(copyBlockTimerRef.current);
|
||
}
|
||
copyBlockTimerRef.current = window.setTimeout(() => {
|
||
if (copiedMarkdownBlockRef.current) {
|
||
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, false, t);
|
||
}
|
||
copiedMarkdownBlockRef.current = null;
|
||
copyBlockTimerRef.current = null;
|
||
}, 1800);
|
||
}
|
||
|
||
return (
|
||
<div className="viewer text-viewer">
|
||
<div className="viewer-toolbar">
|
||
<div className="viewer-toolbar-left">
|
||
{isStreaming ? <span className="viewer-meta">{t('fileViewer.markdownStreamingMeta')}</span> : null}
|
||
{isError ? <span className="viewer-meta">{t('fileViewer.markdownErrorMeta')}</span> : null}
|
||
</div>
|
||
<div className="viewer-toolbar-actions">
|
||
<button
|
||
type="button"
|
||
className="viewer-action"
|
||
onClick={() => setReloadKey((n) => n + 1)}
|
||
title={t('fileViewer.reloadDisk')}
|
||
>
|
||
<Icon name="reload" size={13} />
|
||
<span>{t('fileViewer.reload')}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="viewer-action"
|
||
onClick={() => void copy()}
|
||
title={t('fileViewer.copyTitle')}
|
||
>
|
||
<Icon name={copied ? 'check' : 'copy'} size={13} />
|
||
<span>{copied ? t('fileViewer.copied') : t('fileViewer.copy')}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="viewer-body">
|
||
{html === null ? (
|
||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||
) : (
|
||
<>
|
||
{isStreaming ? <div className="markdown-status">{t('fileViewer.markdownStreamingStatus')}</div> : null}
|
||
{isError ? <div className="markdown-status markdown-status-error">{t('fileViewer.markdownErrorStatus')}</div> : null}
|
||
{/* Safe by contract: renderMarkdownToSafeHtml escapes raw HTML and rejects unsafe link protocols. */}
|
||
<article
|
||
ref={markdownArticleRef}
|
||
className="markdown-rendered"
|
||
onClick={(event) => void handleMarkdownBodyClick(event)}
|
||
dangerouslySetInnerHTML={{ __html: html }}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CodeWithLines({ text }: { text: string }) {
|
||
const lines = text.split('\n');
|
||
// Trailing newline produces a phantom empty line — keep gutter aligned.
|
||
const gutter = lines.map((_, i) => `${i + 1}`).join('\n');
|
||
return (
|
||
<pre className="code-viewer">
|
||
<code className="gutter" aria-hidden>
|
||
{gutter}
|
||
</code>
|
||
<code className="lines">{text}</code>
|
||
</pre>
|
||
);
|
||
}
|
||
|
||
function humanSize(bytes: number): string {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
function documentMetaLabel(file: ProjectFile, t: TranslateFn): string {
|
||
if (file.kind === 'pdf') return t('fileViewer.pdfMeta');
|
||
if (file.kind === 'document') return t('fileViewer.documentMeta');
|
||
if (file.kind === 'presentation') return t('fileViewer.presentationMeta');
|
||
if (file.kind === 'spreadsheet') return t('fileViewer.spreadsheetMeta');
|
||
return t('fileViewer.binaryMeta', { size: humanSize(file.size) });
|
||
}
|