mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat: improve responsive design handoff * feat: refine cross-platform design outputs Changelog:\n- Add auto-fit responsive preview behavior for tablet/mobile frames.\n- Add landing page and OS widgets metadata options with project header chips.\n- Strengthen prompt contracts for modern breakpoints, app-specific modules, CJX-ready UX, and final product surfaces.\n- Require cross-platform outputs to use separate platform files instead of tabbed demo selectors.\n- Add DESIGN-MANIFEST.json plus richer handoff guidance to daemon/client exports.\n- Update archive/export tests for manifest and responsive viewport matrix. * feat: enforce screen-file design outputs Changelog:\n- Enforce screen-file-first generation for landing pages, app screens, platform surfaces, and OS widgets.\n- Update design handoff and manifest exports so coding tools map each screen file to separate routes/surfaces.\n- Strengthen minimal-brief visual guidance to avoid monochrome or unstyled design outputs. * fix: address responsive handoff review feedback * fix: address handoff review blockers * fix: preserve proxy auth and normalized export entry * fix: narrow frame wrapper filter to directory paths only * fix: make artifact save failure banner generic --------- Co-authored-by: Huy Hoàng <macos@MacBook-Pro-Hoang.local>
6188 lines
228 KiB
TypeScript
6188 lines
228 KiB
TypeScript
import { 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 } from './AppChromeHeader';
|
||
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,
|
||
fetchProjectFileText,
|
||
liveArtifactPreviewUrl,
|
||
projectFileUrl,
|
||
projectRawUrl,
|
||
LiveArtifactRefreshError,
|
||
refreshLiveArtifact,
|
||
updateDeployConfig,
|
||
type WebDeployConfigResponse,
|
||
type WebCloudflarePagesDeploySelection,
|
||
type WebDeploymentInfo,
|
||
type WebDeployProjectFileResponse,
|
||
type WebDeployProviderId,
|
||
type WebUpdateDeployConfigRequest,
|
||
writeProjectTextFile,
|
||
} from '../providers/registry';
|
||
import type { ProjectFilePreview } from '../providers/registry';
|
||
import {
|
||
exportAsHtml,
|
||
exportAsJsx,
|
||
exportAsMd,
|
||
exportAsPdf,
|
||
exportProjectAsPdf,
|
||
exportProjectAsZip,
|
||
exportReactComponentAsHtml,
|
||
exportReactComponentAsZip,
|
||
openSandboxedPreviewInNewTab,
|
||
} from '../runtime/exports';
|
||
import { buildReactComponentSrcdoc } from '../runtime/react-component';
|
||
import { buildSrcdoc } from '../runtime/srcdoc';
|
||
import { 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 {
|
||
buildBoardCommentAttachments,
|
||
liveSnapshotForComment,
|
||
overlayBoundsFromSnapshot,
|
||
selectionKindLabel,
|
||
targetFromSnapshot,
|
||
type PreviewCommentSnapshot,
|
||
} from '../comments';
|
||
import type {
|
||
ChatCommentAttachment,
|
||
PreviewComment,
|
||
PreviewCommentMember,
|
||
PreviewCommentTarget,
|
||
} from '../types';
|
||
import { ManualEditPanel, emptyManualEditDraft, type ManualEditDraft } from './ManualEditPanel';
|
||
import {
|
||
applyManualEditPatch,
|
||
readManualEditAttributes,
|
||
readManualEditFields,
|
||
readManualEditOuterHtml,
|
||
readManualEditStyles,
|
||
} from '../edit-mode/source-patches';
|
||
import type { ManualEditBridgeMessage, ManualEditHistoryEntry, ManualEditPatch, ManualEditTarget } from '../edit-mode/types';
|
||
import { isRenderableSketchJson, SketchPreview } from './SketchPreview';
|
||
|
||
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 };
|
||
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
|
||
type PreviewCanvasSize = { width: number; height: number };
|
||
type PreviewViewportPreset = {
|
||
id: PreviewViewportId;
|
||
width: number | null;
|
||
height: number | null;
|
||
labelKey: keyof Dict;
|
||
titleKey: keyof Dict;
|
||
};
|
||
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',
|
||
},
|
||
];
|
||
|
||
// 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 InspectTarget = {
|
||
elementId: string;
|
||
selector: string;
|
||
label: string;
|
||
text: string;
|
||
style: InspectStyleSnapshot;
|
||
};
|
||
|
||
const MAX_CACHED_SLIDE_STATES = 64;
|
||
const htmlPreviewSlideState = new Map<string, SlideState>();
|
||
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 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;
|
||
}) {
|
||
return (
|
||
<div className="viewer-viewport-switcher" role="group" aria-label={t('fileViewer.viewportAria')}>
|
||
{PREVIEW_VIEWPORT_PRESETS.map((preset) => (
|
||
<button
|
||
key={preset.id}
|
||
type="button"
|
||
className={`viewer-action viewer-viewport-button${viewport === preset.id ? ' active' : ''}`}
|
||
aria-pressed={viewport === preset.id}
|
||
title={t(preset.titleKey)}
|
||
tabIndex={tabIndex}
|
||
onClick={() => onViewport(preset.id)}
|
||
>
|
||
{t(preset.labelKey)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function previewViewportStyle(
|
||
viewport: PreviewViewportId,
|
||
previewScale = 1,
|
||
canvasSize?: PreviewCanvasSize,
|
||
): 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);
|
||
return {
|
||
'--preview-viewport-width': `${preset.width}px`,
|
||
'--preview-viewport-height': `${preset.height}px`,
|
||
'--preview-scale': effectiveScale,
|
||
'--preview-user-scale': previewScale,
|
||
};
|
||
}
|
||
|
||
export function effectivePreviewScale(
|
||
viewport: PreviewViewportId,
|
||
previewScale: number,
|
||
canvasSize?: PreviewCanvasSize,
|
||
) {
|
||
if (viewport === 'desktop') return previewScale;
|
||
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
|
||
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale;
|
||
const canvasPadding = 48;
|
||
const 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);
|
||
}
|
||
|
||
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 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);
|
||
}
|
||
}
|
||
|
||
interface Props {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
liveHtml?: string;
|
||
isDeck?: boolean;
|
||
onExportAsPptx?: ((fileName: string) => void) | undefined;
|
||
streaming?: boolean;
|
||
previewComments?: PreviewComment[];
|
||
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
|
||
onRemovePreviewComment?: (commentId: string) => Promise<void>;
|
||
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<void> | void;
|
||
onFileSaved?: () => Promise<void> | void;
|
||
}
|
||
|
||
export function FileViewer({
|
||
projectId,
|
||
file,
|
||
liveHtml,
|
||
isDeck,
|
||
onExportAsPptx,
|
||
streaming,
|
||
previewComments = [],
|
||
onSavePreviewComment,
|
||
onRemovePreviewComment,
|
||
onSendBoardCommentAttachments,
|
||
onFileSaved,
|
||
}: Props) {
|
||
const rendererMatch = artifactRendererRegistry.resolve({
|
||
file,
|
||
isDeckHint: Boolean(isDeck),
|
||
});
|
||
|
||
if (rendererMatch?.renderer.id === 'html' || rendererMatch?.renderer.id === 'deck-html') {
|
||
return (
|
||
<HtmlViewer
|
||
projectId={projectId}
|
||
file={file}
|
||
liveHtml={liveHtml}
|
||
isDeck={rendererMatch.renderer.id === 'deck-html'}
|
||
onExportAsPptx={onExportAsPptx}
|
||
streaming={Boolean(streaming)}
|
||
previewComments={previewComments}
|
||
onSavePreviewComment={onSavePreviewComment}
|
||
onRemovePreviewComment={onRemovePreviewComment}
|
||
onSendBoardCommentAttachments={onSendBoardCommentAttachments}
|
||
onFileSaved={onFileSaved}
|
||
/>
|
||
);
|
||
}
|
||
if (rendererMatch?.renderer.id === 'react-component') {
|
||
return <ReactComponentViewer projectId={projectId} file={file} />;
|
||
}
|
||
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 [previewViewport, setPreviewViewport] = useState<PreviewViewportId>('desktop');
|
||
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 [inTabPresent, setInTabPresent] = useState(false);
|
||
const presentWrapRef = useRef<HTMLDivElement | null>(null);
|
||
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
|
||
useEffect(() => {
|
||
if (typeof document === 'undefined') return;
|
||
setChromeActionsHost(document.getElementById(APP_CHROME_FILE_ACTIONS_ID));
|
||
}, []);
|
||
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(() => {
|
||
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;
|
||
|
||
function bumpZoom(delta: number) {
|
||
setZoom((z) => Math.max(25, Math.min(200, z + delta)));
|
||
}
|
||
|
||
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]);
|
||
|
||
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 present-trigger"
|
||
aria-haspopup="menu"
|
||
aria-expanded={presentMenuOpen}
|
||
onClick={() => setPresentMenuOpen((v) => !v)}
|
||
>
|
||
<Icon name="present" size={13} />
|
||
<span>{t('fileViewer.present')}</span>
|
||
<Icon name="chevron-down" size={11} />
|
||
</button>
|
||
{presentMenuOpen ? (
|
||
<div className="present-menu" role="menu">
|
||
<button role="menuitem" onClick={presentInThisTab}>
|
||
<span className="present-icon"><Icon name="eye" size={13} /></span>{' '}
|
||
{t('fileViewer.presentInTab')}
|
||
</button>
|
||
<button role="menuitem" onClick={presentFullscreen}>
|
||
<span className="present-icon"><Icon name="play" size={13} /></span>{' '}
|
||
{t('fileViewer.presentFullscreen')}
|
||
</button>
|
||
<button role="menuitem" onClick={presentNewTab}>
|
||
<span className="present-icon"><Icon name="share" size={13} /></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')}
|
||
aria-label={t('fileViewer.reloadAria')}
|
||
>
|
||
<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 />
|
||
<button
|
||
type="button"
|
||
className="icon-only"
|
||
onClick={() => bumpZoom(-25)}
|
||
title={t('fileViewer.zoomOut')}
|
||
aria-label={t('fileViewer.zoomOut')}
|
||
tabIndex={mode === 'preview' ? 0 : -1}
|
||
>
|
||
<Icon name="minus" size={14} />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="viewer-action viewer-zoom-level"
|
||
onClick={() => setZoom(100)}
|
||
title={t('fileViewer.resetZoom')}
|
||
tabIndex={mode === 'preview' ? 0 : -1}
|
||
>
|
||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="icon-only"
|
||
onClick={() => bumpZoom(25)}
|
||
title={t('fileViewer.zoomIn')}
|
||
aria-label={t('fileViewer.zoomIn')}
|
||
tabIndex={mode === 'preview' ? 0 : -1}
|
||
>
|
||
<Icon name="plus" size={14} />
|
||
</button>
|
||
<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}
|
||
{mode === 'preview' ? (
|
||
<div
|
||
className={`live-artifact-preview-layer preview-viewport preview-viewport-${previewViewport}`}
|
||
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
|
||
>
|
||
<div className="preview-frame-clip">
|
||
<div style={previewScaleShellStyle(previewViewport, previewScale)}>
|
||
<iframe
|
||
ref={iframeRef}
|
||
data-testid="live-artifact-preview-frame"
|
||
title={liveArtifact.title}
|
||
sandbox="allow-scripts allow-popups"
|
||
src={previewUrl}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : 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`;
|
||
}
|
||
|
||
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 BoardComposerPopover({
|
||
target,
|
||
existing,
|
||
draft,
|
||
notes,
|
||
onDraft,
|
||
onAddDraft,
|
||
onRemoveQueuedNote,
|
||
onClose,
|
||
onSaveComment,
|
||
onSendBatch,
|
||
onRemove,
|
||
sending,
|
||
t,
|
||
}: {
|
||
target: PreviewCommentSnapshot;
|
||
existing: PreviewComment | null;
|
||
draft: string;
|
||
notes: string[];
|
||
onDraft: (value: string) => void;
|
||
onAddDraft: () => void;
|
||
onRemoveQueuedNote: (index: number) => void;
|
||
onClose: () => void;
|
||
onSaveComment: () => void | Promise<void>;
|
||
onSendBatch: () => void | Promise<void>;
|
||
onRemove: (commentId: string) => void | Promise<void>;
|
||
sending: boolean;
|
||
t: TranslateFn;
|
||
}) {
|
||
const pendingCount = notes.length + (draft.trim() ? 1 : 0);
|
||
const podMembers = target.podMembers ?? [];
|
||
const titleId = useId();
|
||
return (
|
||
<div
|
||
className="comment-popover"
|
||
data-testid="comment-popover"
|
||
role="dialog"
|
||
aria-modal="false"
|
||
aria-labelledby={titleId}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
onClose();
|
||
}
|
||
}}
|
||
>
|
||
<div className="comment-popover-head">
|
||
<div title={target.elementId}>
|
||
<strong id={titleId}>{target.elementId}</strong>
|
||
<span>{target.label}</span>
|
||
<span>{selectionKindLabel(target.selectionKind, target.memberCount)}</span>
|
||
</div>
|
||
<button type="button" className="ghost" onClick={onClose} title={t('common.close')}>
|
||
{t('common.close')}
|
||
</button>
|
||
</div>
|
||
{podMembers.length > 0 ? (
|
||
<div className="board-pod-summary">
|
||
<strong>{target.memberCount || podMembers.length} captured items</strong>
|
||
<div className="board-pod-members">
|
||
{podMembers.slice(0, 6).map((member) => (
|
||
<span key={member.elementId} className="board-pod-chip">
|
||
{summarizeMember(member)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{notes.length > 0 ? (
|
||
<div className="board-note-list">
|
||
{notes.map((note, index) => (
|
||
<div key={`${target.elementId}-${index}`} className="board-note-item">
|
||
<span>{note}</span>
|
||
<button type="button" className="ghost" onClick={() => onRemoveQueuedNote(index)}>
|
||
Remove
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
<textarea
|
||
data-testid="comment-popover-input"
|
||
value={draft}
|
||
autoFocus
|
||
aria-label={t('chat.comments.placeholder')}
|
||
placeholder={t('chat.comments.placeholder')}
|
||
onChange={(event) => onDraft(event.target.value)}
|
||
/>
|
||
<div className="comment-popover-actions">
|
||
{existing ? (
|
||
<button type="button" className="comment-popover-remove" onClick={() => onRemove(existing.id)}>
|
||
{t('chat.comments.remove')}
|
||
</button>
|
||
) : <span />}
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
disabled={!draft.trim()}
|
||
onClick={onAddDraft}
|
||
>
|
||
Add note
|
||
</button>
|
||
{target.selectionKind === 'pod' ? null : (
|
||
<button
|
||
type="button"
|
||
className="ghost"
|
||
disabled={!draft.trim()}
|
||
onClick={() => void onSaveComment()}
|
||
>
|
||
Save comment
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="primary"
|
||
data-testid="comment-add-send"
|
||
disabled={pendingCount === 0 || sending}
|
||
onClick={() => void onSendBatch()}
|
||
>
|
||
{sending ? 'Sending...' : 'Send to chat'}
|
||
</button>
|
||
</div>
|
||
</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>
|
||
|
||
<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 summarizeMember(member: PreviewCommentMember): string {
|
||
const text = String(member.text || '').trim();
|
||
if (text) {
|
||
const trimmed = text.length > 24 ? `${text.slice(0, 21)}...` : text;
|
||
return `${member.label || member.elementId} · ${trimmed}`;
|
||
}
|
||
return member.label || member.elementId;
|
||
}
|
||
|
||
function CommentPreviewOverlays({
|
||
comments,
|
||
liveTargets,
|
||
hoveredTarget,
|
||
activeTarget,
|
||
boardTool,
|
||
scale,
|
||
strokePoints,
|
||
onOpenComment,
|
||
}: {
|
||
comments: PreviewComment[];
|
||
liveTargets: Map<string, PreviewCommentSnapshot>;
|
||
hoveredTarget: PreviewCommentSnapshot | null;
|
||
activeTarget: PreviewCommentSnapshot | null;
|
||
boardTool: BoardTool;
|
||
scale: number;
|
||
strokePoints: StrokePoint[];
|
||
onOpenComment: (comment: PreviewComment, snapshot: PreviewCommentSnapshot) => void;
|
||
}) {
|
||
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 targetOverlay = activeTarget ?? hoveredTarget;
|
||
return (
|
||
<div className="comment-overlay-layer" aria-hidden={false}>
|
||
{visibleComments.map(({ comment, index, snapshot }) => {
|
||
const bounds = overlayBoundsFromSnapshot(snapshot, scale);
|
||
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}`}
|
||
>
|
||
<div className="comment-saved-outline" />
|
||
<button
|
||
type="button"
|
||
className="comment-saved-pin"
|
||
onClick={() => onOpenComment(comment, snapshot)}
|
||
title={`${comment.elementId}: ${comment.note}`}
|
||
aria-label={`Open comment for ${comment.elementId}`}
|
||
>
|
||
{index + 1}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
{targetOverlay ? (
|
||
<CommentTargetOverlay
|
||
snapshot={targetOverlay}
|
||
scale={scale}
|
||
selected={Boolean(activeTarget)}
|
||
/>
|
||
) : null}
|
||
{boardTool === 'pod' && strokePoints.length > 1 ? (
|
||
<svg className="board-pod-stroke">
|
||
<polyline
|
||
points={strokePoints.map((point) => `${point.x * scale},${point.y * scale}`).join(' ')}
|
||
/>
|
||
</svg>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CommentTargetOverlay({
|
||
snapshot,
|
||
scale,
|
||
selected,
|
||
}: {
|
||
snapshot: PreviewCommentSnapshot;
|
||
scale: number;
|
||
selected: boolean;
|
||
}) {
|
||
const displayMembers = podDisplayMembers(snapshot);
|
||
if (displayMembers.length > 0) {
|
||
const overlayWeights = podOverlayWeights(displayMembers);
|
||
return (
|
||
<>
|
||
{displayMembers.map((member, index) => {
|
||
const bounds = overlayBoundsFromSnapshot(member, scale);
|
||
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})`,
|
||
};
|
||
return (
|
||
<div
|
||
key={`${member.elementId}-${index}`}
|
||
className={`comment-target-overlay comment-target-overlay--member${selected ? ' selected' : ''}`}
|
||
style={overlayStyle}
|
||
data-testid="comment-target-overlay"
|
||
>
|
||
<span className="comment-target-overlay-label">{snapshot.elementId}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
const bounds = overlayBoundsFromSnapshot(snapshot, scale);
|
||
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,
|
||
}));
|
||
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 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)));
|
||
}
|
||
|
||
function ReactComponentViewer({
|
||
projectId,
|
||
file,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
}) {
|
||
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);
|
||
|
||
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]);
|
||
|
||
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) {
|
||
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]);
|
||
|
||
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')}
|
||
aria-label={t('fileViewer.reloadAria')}
|
||
>
|
||
<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"
|
||
aria-haspopup="menu"
|
||
aria-expanded={shareMenuOpen}
|
||
onClick={() => setShareMenuOpen((v) => !v)}
|
||
>
|
||
<span>{t('fileViewer.shareLabel')}</span>
|
||
<Icon name="chevron-down" size={11} />
|
||
</button>
|
||
{shareMenuOpen ? (
|
||
<div className="share-menu-popover" role="menu">
|
||
<button
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
exportAsJsx(source, exportTitle, sourceExtension);
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><Icon name="file-code" size={14} /></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"><Icon name="file" size={14} /></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"><Icon name="download" size={14} /></span>
|
||
<span>{t('fileViewer.exportZip')}</span>
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="viewer-body">
|
||
{source === null || (mode === 'preview' && !srcDoc) ? (
|
||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||
) : mode === 'preview' ? (
|
||
<iframe
|
||
data-testid="react-component-preview-frame"
|
||
title={file.name}
|
||
sandbox="allow-scripts"
|
||
srcDoc={srcDoc}
|
||
/>
|
||
) : (
|
||
<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,
|
||
file,
|
||
liveHtml,
|
||
isDeck,
|
||
onExportAsPptx,
|
||
streaming,
|
||
previewComments = [],
|
||
onSavePreviewComment,
|
||
onRemovePreviewComment,
|
||
onSendBoardCommentAttachments,
|
||
onFileSaved,
|
||
}: {
|
||
projectId: string;
|
||
file: ProjectFile;
|
||
liveHtml?: string;
|
||
isDeck: boolean;
|
||
onExportAsPptx?: ((fileName: string) => void) | undefined;
|
||
streaming: boolean;
|
||
previewComments?: PreviewComment[];
|
||
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
|
||
onRemovePreviewComment?: (commentId: string) => Promise<void>;
|
||
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<void> | void;
|
||
onFileSaved?: () => Promise<void> | void;
|
||
}) {
|
||
const t = useT();
|
||
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 [previewViewport, setPreviewViewport] = useState<PreviewViewportId>('desktop');
|
||
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
|
||
const zoomMenuRef = useRef<HTMLDivElement | null>(null);
|
||
const [presentMenuOpen, setPresentMenuOpen] = useState(false);
|
||
const [shareMenuOpen, setShareMenuOpen] = useState(false);
|
||
// 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('');
|
||
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 [boardTool, setBoardTool] = useState<BoardTool>('inspect');
|
||
const [inspectMode, setInspectMode] = useState(false);
|
||
// for hint managing hint box state
|
||
const [openHintBox, setOpenHintBox] = useState(true);
|
||
const [manualEditMode, setManualEditMode] = useState(false);
|
||
const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([]);
|
||
const [selectedManualEditTarget, setSelectedManualEditTarget] = useState<ManualEditTarget | 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 templateNameId = useId();
|
||
const templateDescriptionId = 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 [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 [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
|
||
const previewStateKey = `${projectId}:${file.name}`;
|
||
const previewScale = zoom / 100;
|
||
|
||
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 [previewBodyRef, previewBodySize] = usePreviewCanvasSize<HTMLDivElement>();
|
||
const overlayPreviewScale = effectivePreviewScale(previewViewport, previewScale, previewBodySize);
|
||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||
const shareRef = useRef<HTMLDivElement | null>(null);
|
||
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
|
||
useEffect(() => {
|
||
if (typeof document === 'undefined') return;
|
||
setChromeActionsHost(document.getElementById(APP_CHROME_FILE_ACTIONS_ID));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
liveCommentTargetsRef.current = liveCommentTargets;
|
||
}, [liveCommentTargets]);
|
||
|
||
useEffect(() => {
|
||
if (liveHtml !== undefined) {
|
||
setSource(liveHtml);
|
||
return;
|
||
}
|
||
setSource(null);
|
||
let cancelled = false;
|
||
void fetchProjectFileText(projectId, file.name).then((text) => {
|
||
if (!cancelled) setSource(text);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [projectId, file.name, file.mtime, liveHtml, reloadKey]);
|
||
|
||
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 previewSource = inlinedSource ?? 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.
|
||
const useUrlLoadPreview = shouldUrlLoadHtmlPreview({
|
||
mode,
|
||
isDeck: effectiveDeck,
|
||
commentMode: boardMode || manualEditMode,
|
||
inspectMode,
|
||
forceInline,
|
||
});
|
||
const previewSrcUrl = useMemo(
|
||
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}`,
|
||
[projectId, file.name, file.mtime, reloadKey],
|
||
);
|
||
|
||
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,
|
||
commentBridge: boardMode && !manualEditMode,
|
||
inspectBridge: inspectMode,
|
||
editBridge: manualEditMode,
|
||
}) : ''),
|
||
[previewSource, effectiveDeck, projectId, file.name, previewStateKey, boardMode, manualEditMode, inspectMode],
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (!effectiveDeck) {
|
||
setSlideState(null);
|
||
return;
|
||
}
|
||
setSlideState(htmlPreviewSlideState.get(previewStateKey) ?? null);
|
||
function onMessage(ev: MessageEvent) {
|
||
if (ev.source !== iframeRef.current?.contentWindow) 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, 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 }, '*');
|
||
}, [manualEditMode, srcDoc]);
|
||
|
||
function syncBridgeModes() {
|
||
const win = iframeRef.current?.contentWindow;
|
||
if (!win) return;
|
||
win.postMessage({ type: 'od:comment-mode', enabled: boardMode, mode: boardTool }, '*');
|
||
win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*');
|
||
}
|
||
|
||
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 (ev.source !== iframeRef.current?.contentWindow) 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 || ''),
|
||
selectionKind: 'element',
|
||
memberCount: undefined,
|
||
});
|
||
});
|
||
setLiveCommentTargets(next);
|
||
}
|
||
window.addEventListener('message', onMessage);
|
||
return () => window.removeEventListener('message', onMessage);
|
||
}, [inspectMode, boardMode, file.name]);
|
||
|
||
useEffect(() => {
|
||
setActiveCommentTarget(null);
|
||
setHoveredCommentTarget(null);
|
||
setLiveCommentTargets(new Map());
|
||
setCommentDraft('');
|
||
setActiveInspectTarget(null);
|
||
setInspectOverrides({});
|
||
setInspectSavedAt(null);
|
||
setInspectError(null);
|
||
setQueuedBoardNotes([]);
|
||
setStrokePoints([]);
|
||
setManualEditTargets([]);
|
||
setSelectedManualEditTarget(null);
|
||
setManualEditDraft(emptyManualEditDraft());
|
||
setManualEditHistory([]);
|
||
setManualEditUndone([]);
|
||
setManualEditError(null);
|
||
}, [file.name]);
|
||
|
||
// Selecting a new file or turning inspect off resets the panel target.
|
||
useEffect(() => {
|
||
if (!inspectMode) {
|
||
setActiveInspectTarget(null);
|
||
setInspectError(null);
|
||
}
|
||
}, [inspectMode]);
|
||
|
||
// 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(() => {
|
||
if (source == null) return;
|
||
setManualEditDraft((current) => (
|
||
current.fullSource === source ? current : { ...current, fullSource: source }
|
||
));
|
||
}, [source]);
|
||
|
||
useEffect(() => {
|
||
if (!boardMode) {
|
||
setActiveCommentTarget((current) => (current ? null : current));
|
||
setHoveredCommentTarget((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),
|
||
},
|
||
htmlHint: String(data.htmlHint || ''),
|
||
selectionKind: data.selectionKind === 'pod' ? 'pod' : 'element',
|
||
memberCount: finiteBridgeInteger(data.memberCount),
|
||
podMembers: Array.isArray(data.podMembers) ? data.podMembers : undefined,
|
||
});
|
||
function onMessage(ev: MessageEvent) {
|
||
if (ev.source !== iframeRef.current?.contentWindow) 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) ?? null
|
||
: null
|
||
));
|
||
setHoveredCommentTarget((current) => (
|
||
current
|
||
? current.selectionKind === 'pod'
|
||
? current
|
||
: next.get(current.elementId) ?? null
|
||
: null
|
||
));
|
||
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 existing = previewComments.find((comment) => comment.elementId === snapshot.elementId);
|
||
setActiveCommentTarget(snapshot);
|
||
setHoveredCommentTarget(snapshot);
|
||
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
|
||
setCommentDraft(existing?.note ?? '');
|
||
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);
|
||
setQueuedBoardNotes([]);
|
||
setCommentDraft('');
|
||
setStrokePoints([]);
|
||
}
|
||
}
|
||
window.addEventListener('message', onMessage);
|
||
return () => window.removeEventListener('message', onMessage);
|
||
}, [boardMode, file.name, previewComments]);
|
||
|
||
useEffect(() => {
|
||
if (!manualEditMode) {
|
||
setManualEditTargets([]);
|
||
setSelectedManualEditTarget(null);
|
||
setManualEditError(null);
|
||
return;
|
||
}
|
||
function onMessage(ev: MessageEvent) {
|
||
if (ev.source !== iframeRef.current?.contentWindow) 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);
|
||
setSelectedManualEditTarget((current) =>
|
||
current ? data.targets.find((target) => target.id === current.id) ?? null : current,
|
||
);
|
||
return;
|
||
}
|
||
if (data.type === 'od-edit-select') {
|
||
selectManualEditTarget(data.target);
|
||
}
|
||
}
|
||
window.addEventListener('message', onMessage);
|
||
return () => window.removeEventListener('message', onMessage);
|
||
}, [manualEditMode, source]);
|
||
|
||
function selectManualEditTarget(target: ManualEditTarget) {
|
||
const base = source ?? '';
|
||
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: readManualEditStyles(base, target.id),
|
||
attributesText: JSON.stringify(readManualEditAttributes(base, target.id), null, 2),
|
||
outerHtml: readManualEditOuterHtml(base, target.id) || target.outerHtml,
|
||
fullSource: base,
|
||
});
|
||
setManualEditError(null);
|
||
}
|
||
|
||
async function applyManualEdit(patch: ManualEditPatch, label: string) {
|
||
if (manualEditSavingRef.current) return;
|
||
if (source == null) return;
|
||
manualEditSavingRef.current = true;
|
||
setManualEditSaving(true);
|
||
setManualEditError(null);
|
||
try {
|
||
const baseSource = source;
|
||
const result = applyManualEditPatch(baseSource, patch);
|
||
if (!result.ok) {
|
||
setManualEditError(result.error ?? 'Could not apply edit.');
|
||
return;
|
||
}
|
||
if (!(await confirmManualEditHistorySource(
|
||
baseSource,
|
||
'The file changed outside manual edit mode. Refreshing before applying manual edits.',
|
||
))) return;
|
||
const saved = await writeProjectTextFile(projectId, file.name, result.source, {
|
||
artifactManifest: file.artifactManifest,
|
||
});
|
||
if (!saved) {
|
||
setManualEditError('Could not save the edited file.');
|
||
return;
|
||
}
|
||
const entry: ManualEditHistoryEntry = {
|
||
id: `${Date.now()}-${manualEditHistory.length}`,
|
||
label,
|
||
patch,
|
||
beforeSource: baseSource,
|
||
afterSource: result.source,
|
||
createdAt: Date.now(),
|
||
};
|
||
setSource(result.source);
|
||
setInlinedSource(null);
|
||
setManualEditHistory((current) => [entry, ...current]);
|
||
setManualEditUndone([]);
|
||
setManualEditDraft((current) => ({ ...current, fullSource: result.source }));
|
||
await onFileSaved?.();
|
||
} 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);
|
||
setInlinedSource(null);
|
||
setManualEditHistory([]);
|
||
setManualEditUndone([]);
|
||
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);
|
||
setInlinedSource(null);
|
||
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);
|
||
setInlinedSource(null);
|
||
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 (ev.source !== iframeRef.current?.contentWindow) return;
|
||
const data = ev.data as
|
||
| { type?: string; elementId?: string; selector?: string; label?: string; text?: string; style?: InspectStyleSnapshot }
|
||
| null;
|
||
if (!data || data.type !== 'od:comment-target') return;
|
||
if (!data.elementId || !data.selector) return;
|
||
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 : {},
|
||
});
|
||
setInspectError(null);
|
||
setInspectSavedAt(null);
|
||
}
|
||
window.addEventListener('message', onMessage);
|
||
return () => window.removeEventListener('message', onMessage);
|
||
}, [inspectMode]);
|
||
|
||
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() {
|
||
const win = iframeRef.current?.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 (!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 }));
|
||
} 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);
|
||
} 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 bumpZoom(delta: number) {
|
||
setZoom((z) => Math.max(25, Math.min(200, z + delta)));
|
||
}
|
||
|
||
function clearBoardComposer() {
|
||
setActiveCommentTarget(null);
|
||
setHoveredCommentTarget(null);
|
||
setCommentDraft('');
|
||
setQueuedBoardNotes([]);
|
||
setStrokePoints([]);
|
||
}
|
||
|
||
function activateBoard(nextTool?: BoardTool) {
|
||
setMode('preview');
|
||
setBoardMode(true);
|
||
if (nextTool) {
|
||
setBoardTool(nextTool);
|
||
}
|
||
}
|
||
|
||
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 {
|
||
await onSendBoardCommentAttachments(
|
||
buildBoardCommentAttachments({
|
||
target: targetFromSnapshot(activeCommentTarget),
|
||
notes: nextNotes,
|
||
}),
|
||
);
|
||
clearBoardComposer();
|
||
} finally {
|
||
setSendingBoardBatch(false);
|
||
}
|
||
}
|
||
|
||
async function savePersistentComment() {
|
||
if (!activeCommentTarget || !commentDraft.trim() || !onSavePreviewComment) return;
|
||
const saved = await onSavePreviewComment(
|
||
targetFromSnapshot(activeCommentTarget),
|
||
commentDraft.trim(),
|
||
false,
|
||
);
|
||
if (saved) {
|
||
setCommentDraft('');
|
||
}
|
||
}
|
||
|
||
const showPresent = source !== null;
|
||
const canShare = source !== null;
|
||
const exportTitle = file.name.replace(/\.html?$/i, '') || file.name;
|
||
const canPptx = canShare && Boolean(onExportAsPptx) && !streaming;
|
||
const boardAvailable = source !== null;
|
||
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')
|
||
: t('fileViewer.deployToProvider', { provider: deployProviderLabel });
|
||
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')
|
||
: `${t('fileViewer.copyDeployLink')} · ${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');
|
||
};
|
||
|
||
return (
|
||
<div className="viewer html-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')}
|
||
aria-label={t('fileViewer.reloadAria')}
|
||
>
|
||
<Icon name="reload" size={14} />
|
||
</button>
|
||
<div className="viewer-tabs">
|
||
<button
|
||
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
|
||
onClick={() => setMode('preview')}
|
||
>
|
||
{t('fileViewer.preview')}
|
||
</button>
|
||
<button
|
||
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
|
||
onClick={() => setMode('source')}
|
||
>
|
||
{t('fileViewer.source')}
|
||
</button>
|
||
</div>
|
||
{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">
|
||
<button
|
||
type="button"
|
||
className={`viewer-toggle${boardMode ? ' active' : ''}`}
|
||
data-testid="board-mode-toggle"
|
||
title={t('fileViewer.tweaks')}
|
||
aria-pressed={boardMode}
|
||
disabled={!boardAvailable}
|
||
onClick={() => {
|
||
if (boardMode) {
|
||
setBoardMode(false);
|
||
clearBoardComposer();
|
||
return;
|
||
}
|
||
setManualEditMode(false);
|
||
activateBoard(boardTool);
|
||
}}
|
||
>
|
||
<Icon name="tweaks" size={13} />
|
||
<span>{t('fileViewer.tweaks')}</span>
|
||
<span className="switch" aria-hidden />
|
||
</button>
|
||
{boardMode ? (
|
||
<>
|
||
<button
|
||
className={`viewer-action${boardTool === 'inspect' ? ' active' : ''}`}
|
||
type="button"
|
||
data-testid="comment-mode-toggle"
|
||
disabled={!boardAvailable}
|
||
title="Pick one element"
|
||
aria-label="Picker"
|
||
aria-pressed={boardTool === 'inspect'}
|
||
onClick={() => activateBoard('inspect')}
|
||
>
|
||
<Icon name="edit" size={13} />
|
||
<span>Picker</span>
|
||
</button>
|
||
<button
|
||
className={`viewer-action${boardTool === 'pod' ? ' active' : ''}`}
|
||
type="button"
|
||
disabled={!boardAvailable}
|
||
title="Draw a pod selection"
|
||
aria-label="Pods"
|
||
aria-pressed={boardTool === 'pod'}
|
||
onClick={() => activateBoard('pod')}
|
||
>
|
||
<Icon name="draw" size={13} />
|
||
<span>Pods</span>
|
||
</button>
|
||
</>
|
||
) : null}
|
||
<button
|
||
className={`viewer-action${inspectMode ? ' active' : ''}`}
|
||
type="button"
|
||
data-testid="inspect-mode-toggle"
|
||
title="Inspect"
|
||
aria-pressed={inspectMode}
|
||
onClick={() => {
|
||
setInspectMode((v) => {
|
||
const next = !v;
|
||
if (next) {
|
||
setBoardMode(false);
|
||
clearBoardComposer();
|
||
setManualEditMode(false);
|
||
setOpenHintBox(true);
|
||
}
|
||
return next;
|
||
});
|
||
}}
|
||
>
|
||
<Icon name="tweaks" size={13} />
|
||
<span>Inspect</span>
|
||
</button>
|
||
<button
|
||
className={`viewer-action${manualEditMode ? ' active' : ''}`}
|
||
type="button"
|
||
data-testid="manual-edit-mode-toggle"
|
||
title={t('fileViewer.edit')}
|
||
aria-pressed={manualEditMode}
|
||
onClick={() => {
|
||
if (!manualEditMode) {
|
||
setBoardMode(false);
|
||
clearBoardComposer();
|
||
setInspectMode(false);
|
||
setMode('preview');
|
||
}
|
||
setManualEditMode((value) => !value);
|
||
}}
|
||
>
|
||
<Icon name="edit" size={13} />
|
||
<span>{t('fileViewer.edit')}</span>
|
||
</button>
|
||
<span className="viewer-divider" aria-hidden />
|
||
<PreviewViewportControls
|
||
viewport={previewViewport}
|
||
onViewport={setPreviewViewport}
|
||
t={t}
|
||
/>
|
||
<span className="viewer-divider" aria-hidden />
|
||
<button
|
||
type="button"
|
||
className="icon-only"
|
||
onClick={() => bumpZoom(-25)}
|
||
title={t('fileViewer.zoomOut')}
|
||
aria-label={t('fileViewer.zoomOut')}
|
||
>
|
||
<Icon name="minus" size={14} />
|
||
</button>
|
||
<div className="zoom-menu" ref={zoomMenuRef}>
|
||
<button
|
||
type="button"
|
||
className="viewer-action zoom-trigger"
|
||
aria-haspopup="menu"
|
||
aria-expanded={zoomMenuOpen}
|
||
onClick={() => setZoomMenuOpen((v) => !v)}
|
||
style={{ minWidth: 64 }}
|
||
>
|
||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span>
|
||
<Icon name="chevron-down" size={11} />
|
||
</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>
|
||
<button
|
||
type="button"
|
||
className="icon-only"
|
||
onClick={() => bumpZoom(25)}
|
||
title={t('fileViewer.zoomIn')}
|
||
aria-label={t('fileViewer.zoomIn')}
|
||
>
|
||
<Icon name="plus" size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{((filePrimaryActions: ReactNode) => (
|
||
chromeActionsHost ? createPortal(filePrimaryActions, chromeActionsHost) : filePrimaryActions
|
||
))(<>
|
||
{showPresent ? (
|
||
<div className="present-wrap chrome-present-wrap">
|
||
<button
|
||
className="chrome-action chrome-action-secondary present-trigger"
|
||
aria-haspopup="menu"
|
||
aria-expanded={presentMenuOpen}
|
||
onClick={() => setPresentMenuOpen((v) => !v)}
|
||
>
|
||
<Icon name="present" size={13} />
|
||
<span>{t('fileViewer.present')}</span>
|
||
<Icon name="chevron-down" size={11} />
|
||
</button>
|
||
{presentMenuOpen ? (
|
||
<div className="present-menu" role="menu">
|
||
<button role="menuitem" onClick={presentInThisTab}>
|
||
<span className="present-icon"><Icon name="eye" size={13} /></span>{' '}
|
||
{t('fileViewer.presentInTab')}
|
||
</button>
|
||
<button role="menuitem" onClick={presentFullscreen}>
|
||
<span className="present-icon"><Icon name="play" size={13} /></span>{' '}
|
||
{t('fileViewer.presentFullscreen')}
|
||
</button>
|
||
<button role="menuitem" onClick={presentNewTab}>
|
||
<span className="present-icon"><Icon name="share" size={13} /></span>{' '}
|
||
{t('fileViewer.presentNewTab')}
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
{canShare ? (
|
||
<div className="share-menu chrome-share-menu" ref={shareRef}>
|
||
<button
|
||
className="chrome-action chrome-action-primary"
|
||
aria-haspopup="menu"
|
||
aria-expanded={shareMenuOpen}
|
||
onClick={() => setShareMenuOpen((v) => !v)}
|
||
>
|
||
<Icon name="share" size={13} />
|
||
<span>{t('fileViewer.shareLabel')}</span>
|
||
<Icon name="chevron-down" size={11} />
|
||
</button>
|
||
{shareMenuOpen ? (
|
||
<div className="share-menu-popover" role="menu">
|
||
<button
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
void exportProjectAsPdf({
|
||
deck: effectiveDeck,
|
||
fallbackPdf: () => exportAsPdf(source ?? '', exportTitle, { deck: effectiveDeck }),
|
||
filePath: file.name,
|
||
projectId,
|
||
title: exportTitle,
|
||
});
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
|
||
<span>
|
||
{effectiveDeck
|
||
? t('fileViewer.exportPdfAllSlides')
|
||
: t('fileViewer.exportPdf')}
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
disabled={!canPptx}
|
||
title={
|
||
onExportAsPptx
|
||
? streaming
|
||
? t('fileViewer.exportPptxBusy')
|
||
: t('fileViewer.exportPptxHint')
|
||
: t('fileViewer.exportPptxNa')
|
||
}
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
if (onExportAsPptx) onExportAsPptx(file.name);
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><Icon name="present" size={14} /></span>
|
||
<span>{t('fileViewer.exportPptx') + '…'}</span>
|
||
</button>
|
||
<div className="share-menu-divider" />
|
||
<button
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
void exportProjectAsZip({
|
||
projectId,
|
||
filePath: file.name,
|
||
fallbackHtml: source ?? '',
|
||
fallbackTitle: exportTitle,
|
||
});
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><Icon name="download" size={14} /></span>
|
||
<span>{t('fileViewer.exportZip')}</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
exportAsHtml(source ?? '', exportTitle);
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><Icon name="file-code" size={14} /></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"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
setShareMenuOpen(false);
|
||
exportAsMd(source ?? '', exportTitle);
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
|
||
<span>{t('fileViewer.exportMd')}</span>
|
||
</button>
|
||
<div className="share-menu-divider" />
|
||
<button
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
disabled={savingTemplate}
|
||
onClick={() => {
|
||
openSaveAsTemplateModal();
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
|
||
<span>
|
||
{savingTemplate
|
||
? t('fileViewer.savingTemplate')
|
||
: templateNote
|
||
? templateNote
|
||
: t('fileViewer.saveAsTemplate')}
|
||
</span>
|
||
</button>
|
||
<div className="share-menu-divider" />
|
||
{DEPLOY_PROVIDER_OPTIONS.map((option) => (
|
||
<button
|
||
key={option.id}
|
||
type="button"
|
||
className="share-menu-item"
|
||
role="menuitem"
|
||
onClick={() => {
|
||
void openDeployModal(option.id);
|
||
}}
|
||
>
|
||
<span className="share-menu-icon"><Icon name="upload" size={14} /></span>
|
||
<span>{deployActionLabelFor(option.id)}</span>
|
||
</button>
|
||
))}
|
||
{deployCopyLinks.length > 0 ? (
|
||
<div className="share-menu-divider" />
|
||
) : null}
|
||
{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"><Icon name="copy" size={14} /></span>
|
||
<span>{copyDeployMenuLabel(item.providerLabel, item.url)}</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' : 'comment-preview-layer'} preview-viewport preview-viewport-${previewViewport}`}
|
||
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
|
||
>
|
||
{manualEditMode ? (
|
||
<ManualEditPanel
|
||
targets={manualEditTargets}
|
||
selectedTarget={selectedManualEditTarget}
|
||
draft={manualEditDraft}
|
||
history={manualEditHistory}
|
||
error={manualEditError}
|
||
canUndo={manualEditHistory.length > 0}
|
||
canRedo={manualEditUndone.length > 0}
|
||
busy={manualEditSaving}
|
||
onSelectTarget={selectManualEditTarget}
|
||
onDraftChange={setManualEditDraft}
|
||
onApplyPatch={(patch, label) => {
|
||
void applyManualEdit(patch, label);
|
||
}}
|
||
onError={setManualEditError}
|
||
onCancelDraft={() => {
|
||
if (selectedManualEditTarget) selectManualEditTarget(selectedManualEditTarget);
|
||
}}
|
||
onUndo={() => {
|
||
void undoManualEdit();
|
||
}}
|
||
onRedo={() => {
|
||
void redoManualEdit();
|
||
}}
|
||
/>
|
||
) : null}
|
||
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
|
||
<div
|
||
style={previewScaleShellStyle(previewViewport, previewScale)}
|
||
>
|
||
{useUrlLoadPreview ? (
|
||
<iframe
|
||
ref={iframeRef}
|
||
data-testid="artifact-preview-frame"
|
||
data-od-render-mode="url-load"
|
||
title={file.name}
|
||
sandbox="allow-scripts"
|
||
src={previewSrcUrl}
|
||
onLoad={syncBridgeModes}
|
||
/>
|
||
) : (
|
||
<iframe
|
||
ref={iframeRef}
|
||
data-testid="artifact-preview-frame"
|
||
data-od-render-mode="srcdoc"
|
||
title={file.name}
|
||
sandbox="allow-scripts"
|
||
srcDoc={srcDoc}
|
||
// Re-seeds the iframe-side bridge with the host's
|
||
// authoritative inspect override map after each srcdoc
|
||
// rebuild, then syncs comment/edit bridge modes.
|
||
// URL-loaded iframes have no inspect bridge, so the
|
||
// replay handler is intentionally only on the srcDoc
|
||
// branch.
|
||
onLoad={() => {
|
||
replayInspectOverridesToIframe();
|
||
syncBridgeModes();
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{boardMode ? (
|
||
<CommentPreviewOverlays
|
||
comments={previewComments}
|
||
liveTargets={liveCommentTargets}
|
||
hoveredTarget={hoveredCommentTarget}
|
||
activeTarget={activeCommentTarget}
|
||
boardTool={boardTool}
|
||
scale={overlayPreviewScale}
|
||
strokePoints={strokePoints}
|
||
onOpenComment={(comment, snapshot) => {
|
||
setActiveCommentTarget(snapshot);
|
||
setHoveredCommentTarget(snapshot);
|
||
setCommentDraft(comment.note);
|
||
setQueuedBoardNotes([]);
|
||
}}
|
||
/>
|
||
) : null}
|
||
{boardMode && activeCommentTarget ? (
|
||
<BoardComposerPopover
|
||
target={activeCommentTarget}
|
||
existing={previewComments.find((comment) => comment.elementId === activeCommentTarget.elementId) ?? null}
|
||
draft={commentDraft}
|
||
notes={queuedBoardNotes}
|
||
onDraft={setCommentDraft}
|
||
onAddDraft={queueCurrentDraft}
|
||
onRemoveQueuedNote={(index) =>
|
||
setQueuedBoardNotes((current) => current.filter((_, currentIndex) => currentIndex !== index))
|
||
}
|
||
onClose={clearBoardComposer}
|
||
onSaveComment={savePersistentComment}
|
||
onSendBatch={sendBoardBatch}
|
||
onRemove={async (commentId) => {
|
||
if (!onRemovePreviewComment) return;
|
||
await onRemovePreviewComment(commentId);
|
||
clearBoardComposer();
|
||
}}
|
||
sending={sendingBoardBatch || streaming}
|
||
t={t}
|
||
/>
|
||
) : null}
|
||
{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)}
|
||
saving={savingInspect}
|
||
savedAt={inspectSavedAt}
|
||
error={inspectError}
|
||
/>
|
||
) : null}
|
||
{/*
|
||
Hint banner for Inspect / Picker modes. The bridge in
|
||
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
|
||
with every element annotated with `data-od-id` /
|
||
`data-screen-label`, so `liveCommentTargets.size` is the
|
||
authoritative annotation count for the current artifact.
|
||
|
||
Two states:
|
||
- "has targets": the existing copy ("Click any element with
|
||
`data-od-id` to tune its style.") for users who just don't
|
||
see the crosshair cursor.
|
||
- "no targets" (issue #890): a freeform-generated artifact
|
||
(e.g. PRD → HTML through a Claude-Code-compatible CLI
|
||
without a skill) ships zero `data-od-id` annotations. The
|
||
bridge's click handler walks up to <html>, finds nothing,
|
||
and bails — clicks no-op silently. The static copy made
|
||
this look broken; the empty-state copy explains what's
|
||
missing and how to fix it. Mirrored across Inspect and
|
||
Picker because the failure surface is identical.
|
||
*/}
|
||
{(inspectMode || (boardMode && boardTool === 'inspect'))
|
||
&& openHintBox
|
||
&& !activeInspectTarget
|
||
&& !activeCommentTarget ? (
|
||
<div className="inspect-empty-hint-container">
|
||
{liveCommentTargets.size === 0 ? (
|
||
<div
|
||
className="inspect-empty-hint"
|
||
data-testid="inspect-empty-hint-no-targets"
|
||
>
|
||
This artifact has no <code>data-od-id</code>{' '}
|
||
annotations yet — ask the agent to add them to the
|
||
sections you want to{' '}
|
||
{inspectMode ? 'inspect' : 'comment on'}.
|
||
</div>
|
||
) : (
|
||
<div
|
||
className="inspect-empty-hint"
|
||
data-testid="inspect-empty-hint"
|
||
>
|
||
Click any element with <code>data-od-id</code> to{' '}
|
||
{inspectMode ? 'tune its style' : 'leave a comment'}.
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
title="Close Inspect Hint"
|
||
aria-label="Close Inspect Hint"
|
||
onClick={() => setOpenHintBox(false)}
|
||
className="orbit-artifact-ghost"
|
||
>
|
||
<Icon className="" name="close" size={12} />
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<pre className="viewer-source">{source}</pre>
|
||
)}
|
||
</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"
|
||
data-od-render-mode="url-load"
|
||
src={previewSrcUrl}
|
||
/>
|
||
) : (
|
||
<iframe
|
||
title="present"
|
||
sandbox="allow-scripts"
|
||
data-od-render-mode="srcdoc"
|
||
srcDoc={srcDoc}
|
||
/>
|
||
)}
|
||
</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}
|
||
{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}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function baseDirFor(fileName: string): string {
|
||
const idx = fileName.lastIndexOf('/');
|
||
return idx >= 0 ? fileName.slice(0, idx + 1) : '';
|
||
}
|
||
|
||
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) });
|
||
}
|