Refine Studio preview interactions (#3000)

* Refine studio preview interactions

* Fix deck toolbar navigation for transform tracks

* Fix manual edit preview close

* Fix Simple Deck toolbar scrolling

* Fix preview screenshot capture

* Fix deck preview progress sync

* Refine edit target selection for grouped elements (#3068)

* Prefer child edit targets over grouped parents

* Keep edit inspector header and footer fixed

* Shorten floating edit inspector

* Show readable edit target names

* Allow dragging the floating edit inspector

* Add explicit edit inspector actions

* Show preview comment count in toolbar

* Separate annotation and comment toolbar groups

* Remove annotation toolbar divider

* Close edit inspector from footer actions

* Hide edit inspector until target hover

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>

* Fix manual edit iframe regression test

* Fix Studio interaction review feedback

Generated-By: looper 0.9.2 (runner=fixer, agent=codex)

* Fix saved comment link classification

Generated-By: looper 0.9.2 (runner=fixer, agent=codex)

---------

Co-authored-by: chaoxiaoche <chaoxiaoche@chaoxiaochedeMacBook-Pro.local>
Co-authored-by: Siri-Ray <2667192167@qq.com>
This commit is contained in:
chaoxiaoche 2026-05-28 20:52:37 +08:00 committed by GitHub
parent 4abc08bb17
commit 831208b823
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2646 additions and 583 deletions

View file

@ -837,19 +837,50 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
}
}
async function uploadClipboardImagesFromAsyncClipboard() {
if (!navigator.clipboard?.read) return false;
try {
const items = await navigator.clipboard.read();
const files: File[] = [];
const stamp = Date.now();
for (const item of items) {
const imageType = item.types.find((type) => type.startsWith('image/'));
if (!imageType) continue;
const blob = await item.getType(imageType);
const extension = imageType.split('/')[1]?.replace('jpeg', 'jpg') || 'png';
files.push(new File([blob], `clipboard-screenshot-${stamp}.${extension}`, { type: imageType }));
}
if (files.length === 0) return false;
await uploadFiles(files);
return true;
} catch (err) {
console.warn('Could not read image from clipboard', err);
return false;
}
}
useEffect(() => {
function onAnnotation(e: Event) {
const detail = (e as CustomEvent<AnnotationEventDetail>).detail;
if (!detail) return;
void (async () => {
let acked = false;
const ack = (result: { ok: boolean; message?: string }) => {
if (acked) return;
acked = true;
detail.ack?.(result);
};
let uploaded: ChatAttachment[] = [];
let visualAttachmentInput: Parameters<typeof buildVisualAnnotationAttachment>[0] | null = null;
let visualAttachment: ChatCommentAttachment | null = null;
if (detail.file) {
const id = await ensureProject();
if (!id) return;
setUploading(true);
try {
try {
if (detail.file) {
const id = await ensureProject();
if (!id) {
ack({ ok: false, message: t('chat.annotationProjectCreateFailed') });
return;
}
setUploading(true);
const result = await uploadProjectFiles(id, [detail.file]);
if (result.uploaded.length > 0) {
uploaded = result.uploaded;
@ -894,45 +925,57 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
if (result.failed.length > 0) {
const detailText = result.error ? ` (${result.error})` : '';
setUploadError(`Attachment upload failed for ${result.failed.length} file(s)${detailText}.`);
if (uploaded.length === 0) {
ack({ ok: false, message: t('chat.annotationUploadFailed') });
return;
}
}
} finally {
setUploading(false);
}
}
setUploading(false);
if (detail.action === 'send') {
if (streaming) {
if (uploaded.length > 0) setStaged((s) => [...s, ...uploaded]);
if (visualAttachmentInput) {
setStagedVisualComments((current) => [
...current,
buildVisualAnnotationAttachment({
...visualAttachmentInput!,
order: commentAttachments.length + current.length + 1,
}),
]);
if (detail.action === 'send') {
if (streaming) {
if (uploaded.length > 0) setStaged((s) => [...s, ...uploaded]);
if (visualAttachmentInput) {
setStagedVisualComments((current) => [
...current,
buildVisualAnnotationAttachment({
...visualAttachmentInput!,
order: commentAttachments.length + current.length + 1,
}),
]);
}
if (detail.note) setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
setStreamingAnnotationSendPending(true);
textareaRef.current?.focus();
ack({ ok: true });
return;
}
if (detail.note) setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
setStreamingAnnotationSendPending(true);
textareaRef.current?.focus();
if (visualAttachmentInput) {
visualAttachment = buildVisualAnnotationAttachment({
...visualAttachmentInput,
order: commentAttachments.length + stagedVisualComments.length + 1,
});
}
const prompt = [draft.trim(), detail.note].filter(Boolean).join('\n');
const attachments = [...staged, ...uploaded];
const nextCommentAttachments = currentCommentAttachments(visualAttachment ? [visualAttachment] : []);
sendComposedTurn(prompt, attachments, nextCommentAttachments, currentRunContextMeta());
ack({ ok: true });
return;
}
if (visualAttachmentInput) {
visualAttachment = buildVisualAnnotationAttachment({
...visualAttachmentInput,
order: commentAttachments.length + stagedVisualComments.length + 1,
});
}
const prompt = [draft.trim(), detail.note].filter(Boolean).join('\n');
const attachments = [...staged, ...uploaded];
const nextCommentAttachments = currentCommentAttachments(visualAttachment ? [visualAttachment] : []);
sendComposedTurn(prompt, attachments, nextCommentAttachments, currentRunContextMeta());
return;
}
if (detail.note) {
setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
textareaRef.current?.focus();
if (detail.note) {
setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
textareaRef.current?.focus();
}
ack({ ok: true });
} catch (err) {
console.warn('Could not send annotation', err);
setUploadError(err instanceof Error ? err.message : t('chat.annotationFailed'));
ack({ ok: false, message: t('chat.annotationFailed') });
} finally {
setUploading(false);
}
})();
}
@ -949,6 +992,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
stagedSkills,
stagedVisualComments,
streaming,
t,
]);
useEffect(() => {
@ -982,7 +1026,9 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
if (files.length > 0) {
e.preventDefault();
void uploadFiles(files);
return;
}
void uploadClipboardImagesFromAsyncClipboard();
}
function handleDrop(e: React.DragEvent<HTMLDivElement>) {

View file

@ -64,6 +64,8 @@ import {
exportReactComponentAsZip,
openSandboxedPreviewInNewTab,
requestPreviewSnapshot,
requestPreviewSnapshotResult,
type PreviewSnapshotResult,
} from '../runtime/exports';
import { buildReactComponentSrcdoc } from '../runtime/react-component';
import { findHtmlEntriesReferencing } from '../runtime/jsx-module-refs';
@ -544,6 +546,97 @@ function manualEditPreviewShellStyle(
return previewScaleShellStyle(viewport, previewScale);
}
async function previewSnapshotDataUrlToBlob(dataUrl: string): Promise<Blob> {
const response = await fetch(dataUrl);
return response.blob();
}
async function previewSnapshotBlobFromIframes(
iframes: Array<HTMLIFrameElement | null>,
t: (key: keyof Dict, vars?: Record<string, string | number>) => string,
): Promise<{ blob: Blob; fallback?: PreviewSnapshotResult }> {
let fallback: PreviewSnapshotResult | undefined;
for (const iframe of iframes) {
if (!iframe) {
fallback ??= { ok: false, reason: 'loading' };
continue;
}
const result = await requestPreviewSnapshotResultWithRetries(iframe);
if (result.ok) {
return { blob: await previewSnapshotDataUrlToBlob(result.snapshot.dataUrl), fallback };
}
fallback ??= result;
}
throw new Error(snapshotFailureMessage(fallback, t));
}
async function requestPreviewSnapshotResultWithRetries(
iframe: HTMLIFrameElement,
): Promise<PreviewSnapshotResult> {
let last: PreviewSnapshotResult | null = null;
const timeouts = [750, 1500, 3000, 8000];
for (const timeout of timeouts) {
const result = await requestPreviewSnapshotResult(iframe, timeout);
if (result.ok) return result;
last = result;
if (result.reason === 'render-error' || result.reason === 'post-message-error') return result;
}
return last ?? { ok: false, reason: 'timeout' };
}
function snapshotFailureMessage(
result: PreviewSnapshotResult | undefined,
t: (key: keyof Dict, vars?: Record<string, string | number>) => string,
): string {
if (!result) return t('fileViewer.screenshotCaptureFailed');
if (!result.ok && result.reason === 'loading') return t('fileViewer.screenshotPreviewLoading');
return t('fileViewer.screenshotCaptureFailed');
}
function clipboardFailureMessage(
err: unknown,
t: (key: keyof Dict, vars?: Record<string, string | number>) => string,
): string {
const message = err instanceof Error ? err.message : String(err || '');
if (/clipboard|notallowed|permission|denied|write/i.test(message)) {
return t('fileViewer.screenshotClipboardDenied');
}
if (message === t('fileViewer.screenshotPreviewLoading')) return message;
return message || t('fileViewer.screenshotCaptureFailed');
}
function manualEditFloatingPanelStyle(
target: ManualEditTarget,
previewScale: number,
canvasSize: PreviewCanvasSize | undefined,
): CSSProperties {
const scale = Number.isFinite(previewScale) && previewScale > 0 ? previewScale : 1;
const panelWidth = 320;
const preferredPanelHeight = 380;
const pad = 12;
const canvasWidth = canvasSize?.width ?? 1200;
const canvasHeight = canvasSize?.height ?? 800;
const panelHeight = Math.min(preferredPanelHeight, Math.max(260, canvasHeight - pad * 2));
const targetLeft = target.rect.x * scale;
const targetTop = target.rect.y * scale;
const targetRight = (target.rect.x + target.rect.width) * scale;
let left = targetRight + pad;
if (left + panelWidth > canvasWidth - pad) {
left = Math.max(pad, targetLeft - panelWidth - pad);
}
const top = Math.max(
pad,
Math.min(targetTop, Math.max(pad, canvasHeight - panelHeight - pad)),
);
return {
left,
top,
width: panelWidth,
height: panelHeight,
maxHeight: `calc(100% - ${pad * 2}px)`,
};
}
export function cancelManualEditPendingStyleSnapshot(
pending: ManualEditPendingStyleSave | null,
id: string,
@ -1912,7 +2005,18 @@ function formatCommentTime(ts: number, t: TranslateFn): string {
function commentDisplayLabel(comment: PreviewComment, t: TranslateFn): string {
if (comment.elementId.startsWith('pin-')) return t('chat.comments.pin');
return commentTargetDisplayName(comment);
const label = String(comment.label || '').trim().toLowerCase();
const htmlHint = String(comment.htmlHint || '').trim().toLowerCase();
const elementId = String(comment.elementId || '').trim().toLowerCase();
const source = `${label} ${htmlHint} ${elementId}`;
if (/\b(?:img|picture|video|canvas|svg)\b/.test(source)) return t('chat.comments.targetImage');
if (/\b(?:button|input|textarea|select|label)\b/.test(source)) return t('chat.comments.targetControl');
if (/^<a\b/.test(htmlHint)) return t('chat.comments.targetLink');
if (/\b(?:h1|h2|h3|h4|h5|h6|p|span|strong|em|small|li|dt|dd)\b/.test(source)) return t('chat.comments.targetText');
if (/\b(?:section|main|header|footer|nav|article|aside)\b/.test(source)) return t('chat.comments.targetSection');
if (label.endsWith('.html') || elementId.startsWith('file-comment-')) return t('chat.comments.targetPage');
if (comment.text.trim()) return t('chat.comments.targetText');
return t('chat.comments.targetArea');
}
export function CommentSidePanel({
@ -2055,42 +2159,60 @@ export function CommentSidePanel({
{composer ? <div className="comment-side-composer">{composer}</div> : null}
{onCreateComment ? (
<form
className="comment-side-new-comment"
className="comment-side-new-comment composer"
onSubmit={(event) => {
event.preventDefault();
void submitNewComment();
}}
>
<textarea
value={newCommentDraft}
placeholder={t('chat.comments.placeholder')}
aria-label={t('chat.comments.placeholder')}
onChange={(event) => setNewCommentDraft(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void submitNewComment();
}
}}
/>
<div className="comment-side-new-comment-actions">
<button
type="button"
className="comment-side-attach"
title={t('chat.attachTitle')}
aria-label={t('chat.attachAria')}
disabled
>
<Icon name="attach" size={14} />
</button>
<button
type="submit"
className="comment-side-new-comment-send"
disabled={!canCreateComment}
>
<Icon name="arrow-up" size={13} />
<span>{sending ? t('chat.comments.sending') : t('chat.send')}</span>
</button>
<div className="composer-shell comment-side-new-comment-shell">
<div className="composer-input-wrap">
<div className="composer-textarea-layer">
<textarea
value={newCommentDraft}
placeholder={t('chat.comments.placeholder')}
aria-label={t('chat.comments.placeholder')}
onChange={(event) => setNewCommentDraft(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
void submitNewComment();
}
}}
/>
</div>
</div>
<div className="composer-row comment-side-new-comment-actions">
<button
type="button"
className="icon-btn"
title={t('chat.cliSettingsTitle')}
aria-label={t('chat.cliSettingsAria')}
disabled
>
<span className="composer-tools-at" aria-hidden>
@
</span>
</button>
<button
type="button"
className="icon-btn"
title={t('chat.attachTitle')}
aria-label={t('chat.attachAria')}
disabled
>
<Icon name="attach" size={15} />
</button>
<span className="composer-spacer" />
<button
type="submit"
className={`composer-send${sending ? ' is-sending' : ''}`}
disabled={!canCreateComment}
>
<Icon name="send" size={13} />
<span>{sending ? t('chat.comments.sending') : t('chat.send')}</span>
</button>
</div>
</div>
</form>
) : null}
@ -3483,6 +3605,9 @@ function ReactComponentViewer({
</button>
{shareMenuOpen ? (
<div className="share-menu-popover" role="menu">
<div className="share-menu-section-label" role="presentation">
{t('common.share')}
</div>
<button
type="button"
className="share-menu-item"
@ -3855,7 +3980,8 @@ function HtmlViewer({
const [agentToolsOpen, setAgentToolsOpen] = useState(false);
const [drawOverlayOpen, setDrawOverlayOpen] = useState(false);
const [drawOverlayIntent, setDrawOverlayIntent] = useState<'draw' | 'screenshot'>('draw');
const [screenshotToast, setScreenshotToast] = useState(false);
const [screenshotCaptureActive, setScreenshotCaptureActive] = useState(false);
const [screenshotToast, setScreenshotToast] = useState<string | null>(null);
// for hint managing hint box state
const [openHintBox, setOpenHintBox] = useState(true);
const [manualEditMode, setManualEditModeRaw] = useState(false);
@ -4010,8 +4136,9 @@ function HtmlViewer({
});
});
}, []);
const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([]);
const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([]);
const [selectedManualEditTarget, setSelectedManualEditTarget] = useState<ManualEditTarget | null>(null);
const [manualEditPanelPosition, setManualEditPanelPosition] = useState<{ left: number; top: number } | null>(null);
const selectedManualEditTargetIdRef = useRef<string | null>(null);
const [manualEditDraft, setManualEditDraft] = useState<ManualEditDraft>(() => emptyManualEditDraft());
const [manualEditHistory, setManualEditHistory] = useState<ManualEditHistoryEntry[]>([]);
@ -4313,7 +4440,7 @@ function HtmlViewer({
editMode: manualEditMode,
urlModeBridge,
inspectMode,
drawMode: drawOverlayOpen,
drawMode: drawOverlayOpen || screenshotCaptureActive,
forceInline: forceInline || needsSandboxShim,
needsFocusGuard,
});
@ -4399,10 +4526,12 @@ function HtmlViewer({
return () => window.removeEventListener('message', onMessage);
}, []);
// Lazy transport preloads an empty shell only while URL-load is the active
// transport. Once srcdoc becomes active (sandbox shim, Draw, Tweaks, etc.),
// mount the real artifact HTML directly so we do not depend on a postMessage
// activation that can race (#2253) and strand the iframe blank (#2361, #2791).
const useLazySrcDocTransport = !manualEditMode && useUrlLoadPreview;
// transport. Once srcdoc becomes active (sandbox shim, Draw, Screenshot,
// Tweaks, etc.), mount the real artifact HTML directly so we do not depend on
// a postMessage activation that can race (#2253) and strand the iframe blank
// (#2361, #2791).
const captureModeActive = drawOverlayOpen || screenshotCaptureActive;
const useLazySrcDocTransport = !manualEditMode && !captureModeActive && useUrlLoadPreview;
const srcDocTransportContent = useLazySrcDocTransport ? lazySrcDocTransport : srcDoc;
const urlTransportSrc = useUrlLoadPreview ? activePreviewSrcUrl : 'about:blank';
const activateSrcDocTransport = useCallback((target: HTMLIFrameElement | null = srcDocPreviewIframeRef.current) => {
@ -4468,21 +4597,23 @@ function HtmlViewer({
activateSrcDocTransport();
}, [activateSrcDocTransport, useUrlLoadPreview]);
// Re-activate srcDoc transport when exiting manual edit mode to ensure
// the preview renders correctly when switching from Edit to Draw mode.
// Without this, the preview can remain blank because the frozen source
// is cleared but the iframe content is not refreshed.
// Leaving Manual Edit swaps the iframe from a fully materialized srcDoc
// document back to the lazy transport shell. Remount the shell before
// activation; posting into the old edit document can mark the new HTML as
// activated, then React replaces the iframe with an empty shell and the
// dedupe check suppresses the real activation.
const prevManualEditModeRef = useRef(manualEditMode);
useEffect(() => {
const wasInEditMode = prevManualEditModeRef.current;
const isNowInEditMode = manualEditMode;
prevManualEditModeRef.current = isNowInEditMode;
// When exiting edit mode (was true, now false), re-activate the transport
if (wasInEditMode && !isNowInEditMode && !useUrlLoadPreview) {
activateSrcDocTransport();
activatedSrcDocTransportHtmlRef.current = null;
setSrcDocShellReady(false);
setSrcDocTransportResetKey((key) => key + 1);
}
}, [manualEditMode, useUrlLoadPreview, activateSrcDocTransport]);
}, [manualEditMode, useUrlLoadPreview]);
useEffect(() => {
restorePreviewScrollPosition();
@ -4717,6 +4848,7 @@ function HtmlViewer({
setManualEditViewportWidth(null);
setManualEditTargets([]);
setSelectedManualEditTarget(null);
setManualEditPanelPosition(null);
selectedManualEditTargetIdRef.current = null;
setManualEditDraft(emptyManualEditDraft());
setManualEditHistory([]);
@ -4909,6 +5041,7 @@ function HtmlViewer({
if (!manualEditMode) {
setManualEditTargets([]);
setSelectedManualEditTarget(null);
setManualEditPanelPosition(null);
setManualEditError(null);
manualEditPendingStyleRef.current = null;
if (manualEditStyleTimerRef.current) {
@ -4937,6 +5070,12 @@ function HtmlViewer({
void selectManualEditTarget(data.target);
return;
}
if (data.type === 'od-edit-hover') {
if (data.target.id !== selectedManualEditTargetIdRef.current) {
void selectManualEditTarget(data.target);
}
return;
}
if (data.type === 'od-edit-text-commit') {
void applyManualEdit({
id: String(data.id),
@ -4993,14 +5132,6 @@ function HtmlViewer({
setManualEditError('Saved styles differed from the active preview. Reconciled the selected target from source.');
}
function scheduleManualEditStyleSave() {
if (manualEditStyleTimerRef.current) clearTimeout(manualEditStyleTimerRef.current);
manualEditStyleTimerRef.current = setTimeout(() => {
manualEditStyleTimerRef.current = null;
void flushManualEditStyleSave();
}, 1000);
}
function clearManualEditStyleTimer() {
if (!manualEditStyleTimerRef.current) return;
clearTimeout(manualEditStyleTimerRef.current);
@ -5027,29 +5158,66 @@ function HtmlViewer({
manualEditPendingStyleRef.current = pending;
setManualEditError(null);
previewStyleToIframe(id, styles, version);
scheduleManualEditStyleSave();
}
async function flushManualEditStyleSave(): Promise<boolean> {
const pending = manualEditPendingStyleRef.current;
if (!pending) return true;
if (manualEditSavingRef.current) {
scheduleManualEditStyleSave();
return false;
}
if (manualEditSavingRef.current) return false;
manualEditPendingStyleRef.current = null;
return applyManualEdit({ id: pending.id, kind: 'set-style', styles: pending.styles }, pending.label);
}
function cancelManualEditStyleDraft() {
const pending = manualEditPendingStyleRef.current;
if (!pending) return;
clearManualEditStyleTimer();
manualEditPendingStyleRef.current = null;
const base = sourceRef.current ?? '';
const target = pending.id === '__body__'
? null
: selectedManualEditTarget?.id === pending.id
? selectedManualEditTarget
: manualEditTargets.find((item) => item.id === pending.id) ?? null;
const sourceStyles = target
? inspectorManualEditStyles(target, base)
: readManualEditStyles(base, pending.id);
const resetStyles = MANUAL_EDIT_STYLE_PROPS.reduce<Partial<ManualEditStyles>>((acc, key) => {
acc[key] = sourceStyles[key] ?? '';
return acc;
}, {});
previewStyleToIframe(pending.id, resetStyles, nextManualEditPreviewVersion());
if (!target || target.id === selectedManualEditTarget?.id) {
setManualEditDraft((current) => ({
...current,
styles: target ? sourceStyles : current.styles,
fullSource: base,
}));
}
setManualEditError(null);
}
async function exitManualEditModeAfterFlush(): Promise<boolean> {
const ok = await flushManualEditStyleSave();
if (!ok) return false;
setManualEditPanelPosition(null);
setManualEditMode(false);
return true;
}
function cancelManualEditModeAndExit() {
cancelManualEditStyleDraft();
selectedManualEditTargetIdRef.current = null;
setSelectedManualEditTarget(null);
setManualEditPanelPosition(null);
setManualEditDraft(emptyManualEditDraft(sourceRef.current ?? ''));
setManualEditError(null);
setManualEditMode(false);
postSelectedManualEditTargetToIframe(null);
}
async function selectManualEditTarget(target: ManualEditTarget) {
if (!(await flushManualEditStyleSave())) return;
if (manualEditPendingStyleRef.current?.id !== target.id) cancelManualEditStyleDraft();
const base = sourceRef.current ?? '';
const fields = readManualEditFields(base, target.id);
setSelectedManualEditTarget(target);
@ -5067,8 +5235,9 @@ function HtmlViewer({
}
async function clearManualEditTargetSelection() {
if (!(await flushManualEditStyleSave())) return;
cancelManualEditStyleDraft();
setSelectedManualEditTarget(null);
setManualEditPanelPosition(null);
setManualEditDraft(emptyManualEditDraft(sourceRef.current ?? ''));
setManualEditError(null);
}
@ -5142,7 +5311,6 @@ function HtmlViewer({
} finally {
manualEditSavingRef.current = false;
setManualEditSaving(false);
if (manualEditPendingStyleRef.current) scheduleManualEditStyleSave();
}
}
@ -5772,7 +5940,7 @@ function HtmlViewer({
function activateScreenshotTool() {
fireArtifactToolbarClick('draw');
const activateScreenshot = () => {
const activateScreenshot = async () => {
setCommentPanelOpen(false);
setCommentCreateMode(false);
setBoardMode(false);
@ -5780,17 +5948,40 @@ function HtmlViewer({
setInspectMode(false);
setDrawOverlayIntent('screenshot');
setMode('preview');
setDrawOverlayOpen(true);
setScreenshotToast(false);
setScreenshotToast(t('fileViewer.screenshotCopying'));
setDrawOverlayOpen(false);
closeArtifactToolMenus();
setScreenshotCaptureActive(true);
try {
await new Promise<void>((resolve) => window.setTimeout(resolve, 0));
const srcDocIframe = srcDocPreviewIframeRef.current;
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
setScreenshotToast(t('fileViewer.screenshotClipboardDenied'));
return;
}
const activeIframe = iframeRef.current;
const { blob } = await previewSnapshotBlobFromIframes([
srcDocIframe,
activeIframe === srcDocIframe ? null : activeIframe,
], t);
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
setScreenshotToast(t('fileViewer.screenshotCopied'));
} catch (err) {
console.warn('[screenshot] failed to copy preview snapshot:', err);
setScreenshotToast(clipboardFailureMessage(err, t));
} finally {
setScreenshotCaptureActive(false);
}
};
if (manualEditMode) {
void exitManualEditModeAfterFlush().then((ok) => {
if (ok) activateScreenshot();
if (ok) void activateScreenshot();
});
return;
}
activateScreenshot();
void activateScreenshot();
}
function activateCommentTool() {
@ -5958,7 +6149,7 @@ function HtmlViewer({
useEffect(() => {
if (!screenshotToast) return;
const id = window.setTimeout(() => setScreenshotToast(false), 2200);
const id = window.setTimeout(() => setScreenshotToast(null), 2200);
return () => window.clearTimeout(id);
}, [screenshotToast]);
@ -6084,7 +6275,9 @@ function HtmlViewer({
const copyDeployMenuLabel = (providerLabel: string, url: string) =>
copiedDeployLink === url.trim()
? t('fileViewer.copied')
: `${t('fileViewer.copyDeployLink')} · ${providerLabel}`;
: providerLabel.toLowerCase().includes('cloudflare')
? t('fileViewer.copyCloudflareLink')
: t('fileViewer.copyProviderLink', { provider: providerLabel });
const statusLabelFor = (state: ReturnType<typeof deployResultState>) => {
if (state === 'ready') return t('fileViewer.deployLinkReady');
if (state === 'protected') return t('fileViewer.deployLinkProtectedLabel');
@ -6121,7 +6314,10 @@ function HtmlViewer({
void exitManualEditModeAfterFlush();
}}
onCancelDraft={() => {
if (selectedManualEditTarget) selectManualEditTarget(selectedManualEditTarget);
cancelManualEditModeAndExit();
}}
onSaveDraft={() => {
void exitManualEditModeAfterFlush();
}}
onUndo={() => {
void undoManualEdit();
@ -6129,6 +6325,17 @@ function HtmlViewer({
onRedo={() => {
void redoManualEdit();
}}
floatingStyle={selectedManualEditTarget
? {
...manualEditFloatingPanelStyle(
selectedManualEditTarget,
overlayPreviewScale,
previewBodySize,
),
...(manualEditPanelPosition ?? {}),
}
: undefined}
onFloatingPositionChange={setManualEditPanelPosition}
onPickImage={async (pickedFile) => {
const result = await uploadProjectFiles(projectId, [pickedFile]);
const uploaded = result.uploaded[0];
@ -6349,19 +6556,6 @@ function HtmlViewer({
{boardMode && !commentCreateMode && boardTool === 'inspect' ? <span className="viewer-action-active-dot" aria-hidden /> : null}
</button>
</div>
<button
type="button"
className={`viewer-action viewer-action-icon viewer-comment-toggle${boardMode && commentCreateMode ? ' active' : ''}`}
data-testid="comment-panel-toggle"
data-tooltip={t('chat.tabComments')}
title={t('chat.tabComments')}
aria-label={t('chat.tabComments')}
aria-pressed={boardMode && commentCreateMode}
onClick={activateCommentCreateTool}
>
<RemixIcon name="message-3-line" size={15} />
{boardMode && commentCreateMode ? <span className="viewer-action-active-dot" aria-hidden /> : null}
</button>
<button
className={`viewer-action viewer-action-icon${manualEditMode ? ' active' : ''}`}
type="button"
@ -6398,6 +6592,21 @@ function HtmlViewer({
>
<RemixIcon name="screenshot-2-line" size={15} />
</button>
<span className="viewer-toolbar-tool-divider" aria-hidden />
<button
type="button"
className={`viewer-action viewer-comment-count-trigger viewer-comment-toggle${boardMode && commentCreateMode ? ' active' : ''}`}
data-testid="comment-panel-toggle"
data-tooltip={t('chat.tabComments')}
title={t('chat.tabComments')}
aria-label={`${t('chat.tabComments')} (${visibleSideComments.length})`}
aria-pressed={boardMode && commentCreateMode}
onClick={activateCommentCreateTool}
>
<RemixIcon name="message-3-line" size={15} />
<span className="viewer-comment-count" aria-hidden>{visibleSideComments.length}</span>
{boardMode && commentCreateMode ? <span className="viewer-action-active-dot" aria-hidden /> : null}
</button>
{source !== null && mode === 'preview' ? (
<div className="zoom-menu viewer-toolbar-zoom" ref={zoomMenuRef}>
<button
@ -6521,9 +6730,62 @@ function HtmlViewer({
</button>
{shareMenuOpen ? (
<div className="share-menu-popover" role="menu">
{deployCopyLinks.length > 0 ? (
<>
<div className="share-menu-section-label" role="presentation">
{t('fileViewer.shareMenuShareLink')}
</div>
{deployCopyLinks.map((item) => (
<button
key={`copy-${item.providerId}`}
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
void copyDeployLink(item.url);
}}
>
<span className="share-menu-icon"><RemixIcon name="file-copy-line" size={15} /></span>
<span>{copyDeployMenuLabel(item.providerLabel, item.url)}</span>
</button>
))}
<div className="share-menu-divider" />
</>
) : null}
<div className="share-menu-section-label" role="presentation">
{t('fileViewer.shareMenuPublishOnline')}
</div>
{DEPLOY_PROVIDER_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
const format =
option.id === 'cloudflare-pages'
? 'cloudflare_pages'
: option.id === 'vercel-self'
? 'vercel'
: 'vercel';
fireShareExport(format, () => openDeployModal(option.id));
}}
>
<span className="share-menu-icon"><RemixIcon name="upload-cloud-line" size={15} /></span>
<span>{deployActionLabelFor(option.id)}</span>
</button>
))}
<div className="share-menu-divider" />
<div className="share-menu-section-label" role="presentation">
{t('fileViewer.shareMenuDownload')}
</div>
<div className="share-menu-subsection-label" role="presentation">
{t('fileViewer.shareMenuPresentation')}
</div>
<button
type="button"
className="share-menu-item"
className="share-menu-item share-menu-subitem"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
@ -6545,7 +6807,7 @@ function HtmlViewer({
</button>
<button
type="button"
className="share-menu-item"
className="share-menu-item share-menu-subitem"
role="menuitem"
disabled={!canPptx}
title={
@ -6565,58 +6827,10 @@ function HtmlViewer({
<span className="share-menu-icon"><RemixIcon name="file-ppt-line" size={15} /></span>
<span>{t('fileViewer.exportPptx') + '…'}</span>
</button>
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
fireShareExport('zip', () => exportProjectAsZip({
projectId,
filePath: file.name,
fallbackHtml: source ?? '',
fallbackTitle: exportTitle,
}));
}}
>
<span className="share-menu-icon"><RemixIcon name="file-zip-line" size={15} /></span>
<span>{t('fileViewer.exportZip')}</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
fireShareExport('html', () => exportAsHtml(source ?? '', exportTitle));
}}
>
<span className="share-menu-icon"><RemixIcon name="file-code-line" size={15} /></span>
<span>{t('fileViewer.exportHtml')}</span>
</button>
{/* Export as Markdown pass-through download of the
artifact source with a `.md` extension. No conversion
runs; the file body is identical to the Source view.
Useful for piping the artifact into markdown-aware
tooling (LLM context windows, vault apps). See
issue #279. */}
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
fireShareExport('markdown', () => exportAsMd(source ?? '', exportTitle));
}}
>
<span className="share-menu-icon"><RemixIcon name="file-line" size={15} /></span>
<span>{t('fileViewer.exportMd')}</span>
</button>
{!useUrlLoadPreview ? (
<button
type="button"
className="share-menu-item"
className="share-menu-item share-menu-subitem"
role="menuitem"
onClick={async () => {
setShareMenuOpen(false);
@ -6640,7 +6854,60 @@ function HtmlViewer({
<span>{t('fileViewer.exportImage')}</span>
</button>
) : null}
<div className="share-menu-subsection-label" role="presentation">
{t('fileViewer.shareMenuSourceFiles')}
</div>
<button
type="button"
className="share-menu-item share-menu-subitem"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
fireShareExport('zip', () => exportProjectAsZip({
projectId,
filePath: file.name,
fallbackHtml: source ?? '',
fallbackTitle: exportTitle,
}));
}}
>
<span className="share-menu-icon"><RemixIcon name="file-zip-line" size={15} /></span>
<span>{t('fileViewer.exportZip')}</span>
</button>
<button
type="button"
className="share-menu-item share-menu-subitem"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
fireShareExport('html', () => exportAsHtml(source ?? '', exportTitle));
}}
>
<span className="share-menu-icon"><RemixIcon name="file-code-line" size={15} /></span>
<span>{t('fileViewer.exportHtml')}</span>
</button>
{/* Export as Markdown pass-through download of the
artifact source with a `.md` extension. No conversion
runs; the file body is identical to the Source view.
Useful for piping the artifact into markdown-aware
tooling (LLM context windows, vault apps). See
issue #279. */}
<button
type="button"
className="share-menu-item share-menu-subitem"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
fireShareExport('markdown', () => exportAsMd(source ?? '', exportTitle));
}}
>
<span className="share-menu-icon"><RemixIcon name="file-line" size={15} /></span>
<span>{t('fileViewer.exportMd')}</span>
</button>
<div className="share-menu-divider" />
<div className="share-menu-section-label" role="presentation">
{t('fileViewer.shareMenuSave')}
</div>
<button
type="button"
className="share-menu-item"
@ -6661,45 +6928,6 @@ function HtmlViewer({
: 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={() => {
const format =
option.id === 'cloudflare-pages'
? 'cloudflare_pages'
: option.id === 'vercel-self'
? 'vercel'
: 'vercel';
fireShareExport(format, () => openDeployModal(option.id));
}}
>
<span className="share-menu-icon"><RemixIcon name="upload-cloud-line" size={15} /></span>
<span>{deployActionLabelFor(option.id)}</span>
</button>
))}
{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"><RemixIcon name="file-copy-line" size={15} /></span>
<span>{copyDeployMenuLabel(item.providerLabel, item.url)}</span>
</button>
))}
</div>
) : null}
</div>
@ -6734,7 +6962,7 @@ function HtmlViewer({
captureTarget={null}
filePath={file.name}
sendDisabled={streaming}
sendDisabledReason="当前正有任务在执行"
sendDisabledReason={t('chat.annotationSendDisabledReason')}
>
<div className="artifact-preview-transport-stack">
<iframe
@ -6878,11 +7106,11 @@ function HtmlViewer({
<div className="screenshot-toast-anchor">
<div className="screenshot-toast" role="status" aria-live="polite">
<RemixIcon name="checkbox-circle-line" size={16} />
<span></span>
<span>{screenshotToast}</span>
<button
type="button"
aria-label={t('common.close')}
onClick={() => setScreenshotToast(false)}
onClick={() => setScreenshotToast(null)}
>
<RemixIcon name="close-line" size={16} />
</button>

View file

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react';
import { useT } from '../i18n';
import { emptyManualEditStyles, type ManualEditHistoryEntry, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../edit-mode/types';
import { Icon } from './Icon';
@ -27,15 +27,20 @@ export function ManualEditPanel({
draft,
error,
canUndo,
busy,
onDraftChange,
onStyleChange,
onInvalidStyle,
onError,
onClearSelection,
onCancelDraft,
onSaveDraft,
onExit,
onApplyPatch,
onPickImage,
pageStylesEnabled = true,
floatingStyle,
onFloatingPositionChange,
}: {
targets: ManualEditTarget[];
selectedTarget: ManualEditTarget | null;
@ -52,10 +57,13 @@ export function ManualEditPanel({
onInvalidStyle?: (id: string, keys: Array<keyof ManualEditStyles>) => void;
onApplyPatch: (patch: ManualEditPatch, label: string) => void;
onPickImage?: (file: File) => Promise<string | null>;
floatingStyle?: CSSProperties;
onFloatingPositionChange?: (position: { left: number; top: number }) => void;
onError: (message: string) => void;
onClearSelection: () => void;
onExit?: () => void;
onCancelDraft: () => void;
onSaveDraft: () => void;
onUndo: () => void;
onRedo: () => void;
}) {
@ -65,6 +73,7 @@ export function ManualEditPanel({
const selectedTargetRef = useRef<ManualEditTarget | null>(selectedTarget);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const targetForInspector = selectedTarget;
const panelTitle = targetForInspector ? readableManualEditTargetName(targetForInspector) : t('manualEdit.fallbackTitle');
useEffect(() => {
selectedTargetRef.current = selectedTarget;
}, [selectedTarget]);
@ -85,134 +94,300 @@ export function ManualEditPanel({
onStyleChange?.(targetForInspector.id, normalized.styles, `Style: ${targetForInspector.label}`);
};
const startPanelDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (!onFloatingPositionChange) return;
event.preventDefault();
event.stopPropagation();
const panel = event.currentTarget.closest('.manual-edit-right') as HTMLElement | null;
const parent = panel?.parentElement;
if (!panel || !parent) return;
const startX = event.clientX;
const startY = event.clientY;
const startLeft = panel.offsetLeft;
const startTop = panel.offsetTop;
const parentRect = parent.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const pad = 8;
const maxLeft = Math.max(pad, parentRect.width - panelRect.width - pad);
const maxTop = Math.max(pad, parentRect.height - panelRect.height - pad);
const ownerDocument = panel.ownerDocument;
const move = (moveEvent: PointerEvent) => {
onFloatingPositionChange({
left: clamp(startLeft + moveEvent.clientX - startX, pad, maxLeft),
top: clamp(startTop + moveEvent.clientY - startY, pad, maxTop),
});
};
const up = () => {
ownerDocument.removeEventListener('pointermove', move);
ownerDocument.removeEventListener('pointerup', up);
ownerDocument.removeEventListener('pointercancel', up);
};
ownerDocument.addEventListener('pointermove', move);
ownerDocument.addEventListener('pointerup', up);
ownerDocument.addEventListener('pointercancel', up);
};
return (
<aside className="manual-edit-right">
<aside
className={`manual-edit-right${floatingStyle ? ' manual-edit-floating' : ''}`}
style={floatingStyle}
>
<section className="manual-edit-modal cc-panel">
<div className="manual-edit-titlebar">
<span>Edit</span>
{floatingStyle ? (
<button
type="button"
className="manual-edit-drag-handle"
aria-label={t('manualEdit.movePanel')}
title={t('manualEdit.movePanel')}
onPointerDown={startPanelDrag}
>
<span aria-hidden />
</button>
) : null}
<span title={panelTitle}>{panelTitle}</span>
{onExit ? (
<button
type="button"
className="manual-edit-titlebar-close"
aria-label="Close edit panel"
title="Close edit panel"
aria-label={t('manualEdit.closePanel')}
title={t('manualEdit.closePanel')}
onClick={onExit}
>
<Icon name="close" size={16} />
</button>
) : null}
</div>
{targetForInspector ? (
<StyleInspector
styles={draft.styles}
layoutEnabled={targetForInspector.isLayoutContainer}
onClearSelection={onClearSelection}
onChange={changeTargetStyle}
/>
) : !targetForInspector ? (
<PageInspector
enabled={pageStylesEnabled}
onStyleChange={(styles) => {
const normalized = normalizeManualEditStyles(styles, { layoutEnabled: true });
if (!normalized.ok) {
onError(normalized.error);
onInvalidStyle?.('__body__', Object.keys(styles) as Array<keyof ManualEditStyles>);
return;
}
onError('');
onStyleChange?.('__body__', normalized.styles, 'Page styles');
}}
/>
) : null}
<div className="manual-edit-scroll">
{targetForInspector ? (
<StyleInspector
targetKind={targetForInspector.kind}
styles={draft.styles}
layoutEnabled={targetForInspector.isLayoutContainer}
onClearSelection={onClearSelection}
onChange={changeTargetStyle}
/>
) : !targetForInspector ? (
<PageInspector
enabled={pageStylesEnabled}
onStyleChange={(styles) => {
const normalized = normalizeManualEditStyles(styles, { layoutEnabled: true });
if (!normalized.ok) {
onError(normalized.error);
onInvalidStyle?.('__body__', Object.keys(styles) as Array<keyof ManualEditStyles>);
return;
}
onError('');
onStyleChange?.('__body__', normalized.styles, 'Page styles');
}}
/>
) : null}
{targetForInspector?.kind === 'image' && onPickImage ? (
<div className="cc-section">
<header className="cc-section-head">IMAGE</header>
<div className="cc-section-body">
<button
type="button"
className="cc-action-btn"
disabled={uploadingImage}
onClick={() => fileInputRef.current?.click()}
>
{uploadingImage ? t('manualEdit.uploadingImage') : t('manualEdit.uploadImage')}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.currentTarget.files?.[0];
if (!file) return;
e.currentTarget.value = '';
setUploadingImage(true);
try {
const src = await onPickImage(file);
if (src) {
const activeTargetId = selectedTargetRef.current?.id ?? targetForInspector.id;
onApplyPatch(
{ id: activeTargetId, kind: 'set-image', src, alt: draft.alt },
t('manualEdit.uploadImage'),
);
} else {
onError(t('manualEdit.uploadImageFailed'));
}
} finally {
setUploadingImage(false);
}
}}
/>
</div>
</div>
) : null}
{targetForInspector ? (
<div className="cc-section">
<div className="cc-section-body">
{confirmDelete ? (
<>
<p className="cc-delete-confirm">{canUndo ? t('manualEdit.deleteElementConfirm') : t('manualEdit.deleteElement')}</p>
<button
type="button"
className="cc-action-btn cc-action-danger"
onClick={() => {
setConfirmDelete(false);
onApplyPatch(
{ id: targetForInspector.id, kind: 'remove-element' },
t('manualEdit.deleteElement'),
);
}}
>
{t('manualEdit.deleteElement')}
</button>
<button
type="button"
className="cc-action-btn"
onClick={() => setConfirmDelete(false)}
>
{t('common.cancel')}
</button>
</>
) : (
<div className="cc-section">
<header className="cc-section-head">IMAGE</header>
<div className="cc-section-body">
<button
type="button"
className="cc-action-btn cc-action-danger"
onClick={() => setConfirmDelete(true)}
className="cc-action-btn"
disabled={uploadingImage}
onClick={() => fileInputRef.current?.click()}
>
{t('manualEdit.deleteElement')}
{uploadingImage ? t('manualEdit.uploadingImage') : t('manualEdit.uploadImage')}
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.currentTarget.files?.[0];
if (!file) return;
e.currentTarget.value = '';
setUploadingImage(true);
try {
const src = await onPickImage(file);
if (src) {
const activeTargetId = selectedTargetRef.current?.id ?? targetForInspector.id;
onApplyPatch(
{ id: activeTargetId, kind: 'set-image', src, alt: draft.alt },
t('manualEdit.uploadImage'),
);
} else {
onError(t('manualEdit.uploadImageFailed'));
}
} finally {
setUploadingImage(false);
}
}}
/>
</div>
</div>
) : null}
</div>
<div className="manual-edit-footer">
<div className="manual-edit-footer-actions">
<div className="manual-edit-footer-left">
{targetForInspector ? (
confirmDelete ? (
<div className="manual-edit-delete-confirm">
<span>{canUndo ? t('manualEdit.deleteElementConfirm') : t('manualEdit.deleteElement')}</span>
<button
type="button"
className="manual-edit-footer-btn danger"
disabled={busy}
onClick={() => {
setConfirmDelete(false);
onApplyPatch(
{ id: targetForInspector.id, kind: 'remove-element' },
t('manualEdit.deleteElement'),
);
}}
>
{t('manualEdit.deleteElement')}
</button>
<button
type="button"
className="manual-edit-footer-btn subtle"
disabled={busy}
onClick={() => setConfirmDelete(false)}
>
{t('common.cancel')}
</button>
</div>
) : (
<button
type="button"
className="manual-edit-delete-btn"
aria-label={t('manualEdit.deleteElement')}
title={t('manualEdit.deleteElement')}
disabled={busy}
onClick={() => setConfirmDelete(true)}
>
<Icon name="trash" size={15} />
</button>
)
) : null}
</div>
<div className="manual-edit-footer-right">
<button
type="button"
className="manual-edit-footer-btn subtle"
disabled={busy}
onClick={onCancelDraft}
>
{t('common.cancel')}
</button>
<button
type="button"
className="manual-edit-footer-btn primary"
disabled={busy}
onClick={onSaveDraft}
>
{t('common.save')}
</button>
</div>
</div>
) : null}
{error ? <div className="manual-edit-error">{error}</div> : null}
{error ? <div className="manual-edit-error">{error}</div> : null}
</div>
</section>
</aside>
);
}
function readableManualEditTargetName(target: ManualEditTarget): string {
const explicit = firstReadableText(
target.attributes['data-od-label'],
target.attributes['aria-label'],
target.attributes.title,
);
if (explicit) return explicit;
if (target.kind === 'text' || target.kind === 'link' || target.kind === 'token') {
const textName = readableContentName(target.text || target.fields.text || target.label);
if (textName) return textName;
}
if (target.kind === 'image') {
const imageName = readableContentName(target.fields.alt || target.label);
if (imageName) return imageName;
}
const identifierName = readableIdentifierName(
target.attributes.id ||
target.attributes['data-od-id'] ||
target.id,
);
if (identifierName) return identifierName;
const className = readableClassName(target.className);
if (className) return className;
const labelName = readableContentName(target.label);
if (labelName && !looksCodeLikeLabel(labelName)) return labelName;
if (target.kind === 'container') return 'Container';
if (target.kind === 'image') return 'Image';
if (target.kind === 'link') return 'Link';
return 'Text';
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function firstReadableText(...values: Array<string | undefined>): string {
for (const value of values) {
const readable = readableContentName(value);
if (readable) return readable;
}
return '';
}
function readableContentName(value: string | undefined): string {
const clean = (value ?? '').replace(/\s+/g, ' ').trim();
if (!clean) return '';
if (looksGeneratedIdentifier(clean)) return '';
return clean.length > 42 ? `${clean.slice(0, 39).trim()}...` : clean;
}
function readableIdentifierName(value: string | undefined): string {
const raw = (value ?? '').trim();
if (!raw || looksGeneratedIdentifier(raw)) return '';
const lastSelectorPart = (raw.includes('.') ? raw.split('.').filter(Boolean).at(-1) : raw) ?? '';
const lastIdPart = (lastSelectorPart.includes('#') ? lastSelectorPart.split('#').filter(Boolean).at(-1) : lastSelectorPart) ?? '';
return humanizeIdentifier(lastIdPart);
}
function readableClassName(value: string | undefined): string {
const classes = (value ?? '').split(/\s+/).map((item) => item.trim()).filter(Boolean);
const candidate = classes.find((item) => {
const lower = item.toLowerCase();
return !looksGeneratedIdentifier(item) && !['container', 'wrapper', 'group', 'section', 'row', 'col'].includes(lower);
}) ?? classes.find((item) => !looksGeneratedIdentifier(item));
return humanizeIdentifier(candidate);
}
function humanizeIdentifier(value: string | undefined): string {
const clean = (value ?? '')
.replace(/^[_#.\s-]+|[_#.\s-]+$/g, '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!clean || looksGeneratedIdentifier(clean)) return '';
return clean.charAt(0).toUpperCase() + clean.slice(1);
}
function looksCodeLikeLabel(value: string): boolean {
return /^[a-z][a-z0-9-]*(?:[#.][\w-]+)+$/i.test(value) || /^[a-z][a-z0-9-]*\s+#/.test(value);
}
function looksGeneratedIdentifier(value: string): boolean {
return /^path(?:-\d+)+$/i.test(value) || /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i.test(value);
}
function PageInspector({
enabled,
onStyleChange,
@ -393,14 +568,19 @@ function styleLabel(key: keyof ManualEditStyles): string {
}
function StyleInspector({
styles, layoutEnabled, onClearSelection, onChange,
targetKind, styles, layoutEnabled, onClearSelection, onChange,
}: {
targetKind: ManualEditTarget['kind'];
styles: ManualEditStyles;
layoutEnabled: boolean;
onClearSelection: () => void;
onChange: (key: keyof ManualEditStyles, value: string) => void;
}) {
const u = (key: keyof ManualEditStyles, value: string) => onChange(key, value);
const showTypography = targetKind === 'text' || targetKind === 'link' || targetKind === 'token';
const showSize = targetKind !== 'text' && targetKind !== 'link' && targetKind !== 'token';
const showLayout = layoutEnabled;
const showBox = targetKind === 'container' || targetKind === 'image' || targetKind === 'token';
return (
<div className="cc-inspector">
@ -409,43 +589,47 @@ function StyleInspector({
Page
</button>
</div>
<Section title="TYPOGRAPHY">
<FontRow value={styles.fontFamily} onChange={(v) => u('fontFamily', v)} />
<PairRow>
<UnitRow label="Size" value={styles.fontSize} onChange={(v) => u('fontSize', v)} unit="px" autoUnit />
<DropdownRow label="Weight" value={styles.fontWeight} onChange={(v) => u('fontWeight', v)} options={WEIGHT_OPTS} />
</PairRow>
<PairRow>
<ColorRow label="Color" value={styles.color} onChange={(v) => u('color', v)} />
<DropdownRow label="Align" value={styles.textAlign} onChange={(v) => u('textAlign', v)} options={ALIGN_OPTS} />
</PairRow>
<PairRow>
<UnitRow label="Line" value={styles.lineHeight} onChange={(v) => u('lineHeight', v)} unit="" />
<UnitRow label="Tracking" value={styles.letterSpacing} onChange={(v) => u('letterSpacing', v)} unit="px" autoUnit />
</PairRow>
</Section>
{showTypography ? (
<Section title="TYPOGRAPHY">
<FontRow value={styles.fontFamily} onChange={(v) => u('fontFamily', v)} />
<PairRow>
<UnitRow label="Size" value={styles.fontSize} onChange={(v) => u('fontSize', v)} unit="px" autoUnit />
<DropdownRow label="Weight" value={styles.fontWeight} onChange={(v) => u('fontWeight', v)} options={WEIGHT_OPTS} />
</PairRow>
<PairRow>
<ColorRow label="Color" value={styles.color} onChange={(v) => u('color', v)} />
<DropdownRow label="Align" value={styles.textAlign} onChange={(v) => u('textAlign', v)} options={ALIGN_OPTS} />
</PairRow>
<PairRow>
<UnitRow label="Line" value={styles.lineHeight} onChange={(v) => u('lineHeight', v)} unit="" />
<UnitRow label="Tracking" value={styles.letterSpacing} onChange={(v) => u('letterSpacing', v)} unit="px" autoUnit />
</PairRow>
</Section>
) : null}
<Section title="SIZE">
<PairRow>
<UnitRow label="Width" value={styles.width} onChange={(v) => u('width', v)} unit="px" autoUnit />
<UnitRow label="Height" value={styles.height} onChange={(v) => u('height', v)} unit="px" autoUnit />
</PairRow>
</Section>
{showSize ? (
<Section title="SIZE">
<PairRow>
<UnitRow label="Width" value={styles.width} onChange={(v) => u('width', v)} unit="px" autoUnit />
<UnitRow label="Height" value={styles.height} onChange={(v) => u('height', v)} unit="px" autoUnit />
</PairRow>
</Section>
) : null}
<Section title="LAYOUT" inactive={!layoutEnabled}>
{!layoutEnabled ? (
<p className="cc-section-hint">Select a container or group to edit layout.</p>
) : null}
<PairRow>
<UnitRow label="Gap" value={styles.gap} onChange={(v) => u('gap', v)} unit="px" autoUnit disabled={!layoutEnabled} />
<DropdownRow label="Direction" value={styles.flexDirection} onChange={(v) => u('flexDirection', v)} options={DIRECTION_OPTS} disabled={!layoutEnabled} />
</PairRow>
<PairRow>
<DropdownRow label="Justify" value={styles.justifyContent} onChange={(v) => u('justifyContent', v)} options={JUSTIFY_OPTS} disabled={!layoutEnabled} />
<DropdownRow label="Align" value={styles.alignItems} onChange={(v) => u('alignItems', v)} options={ITEMS_OPTS} disabled={!layoutEnabled} />
</PairRow>
</Section>
{showLayout ? (
<Section title="LAYOUT">
<PairRow>
<UnitRow label="Gap" value={styles.gap} onChange={(v) => u('gap', v)} unit="px" autoUnit />
<DropdownRow label="Direction" value={styles.flexDirection} onChange={(v) => u('flexDirection', v)} options={DIRECTION_OPTS} />
</PairRow>
<PairRow>
<DropdownRow label="Justify" value={styles.justifyContent} onChange={(v) => u('justifyContent', v)} options={JUSTIFY_OPTS} />
<DropdownRow label="Align" value={styles.alignItems} onChange={(v) => u('alignItems', v)} options={ITEMS_OPTS} />
</PairRow>
</Section>
) : null}
{showBox ? (
<Section title="BOX">
<PairRow>
<ColorRow label="Fill" value={styles.backgroundColor} onChange={(v) => u('backgroundColor', v)} />
@ -470,6 +654,7 @@ function StyleInspector({
</PairRow>
<UnitRow label="Radius" value={styles.borderRadius} onChange={(v) => u('borderRadius', v)} unit="px" autoUnit />
</Section>
) : null}
</div>
);
}

View file

@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState, type CSSProperties, type Poin
import { Icon } from './Icon';
import { RemixIcon } from './RemixIcon';
import { useT } from '../i18n';
import type { PreviewVisualMarkKind } from '../types';
import { requestPreviewSnapshot } from '../runtime/exports';
import { isImeComposing } from '../utils/imeComposing';
@ -28,6 +29,7 @@ export interface AnnotationEventDetail {
markKind?: PreviewVisualMarkKind;
bounds?: { x: number; y: number; width: number; height: number };
target?: CaptureTarget | null;
ack?: (result: { ok: boolean; message?: string }) => void;
}
interface Props {
@ -55,6 +57,7 @@ export function PreviewDrawOverlay({
sendDisabled = false,
sendDisabledReason,
}: Props) {
const t = useT();
const wrapRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [note, setNote] = useState('');
@ -66,6 +69,10 @@ export function PreviewDrawOverlay({
const [undoCount, setUndoCount] = useState(0);
const [redoCount, setRedoCount] = useState(0);
const [pendingAction, setPendingAction] = useState<'queue' | 'send' | null>(null);
const [captureWarning, setCaptureWarning] = useState<{
action: 'queue' | 'send';
message: string;
} | null>(null);
const sending = pendingAction !== null;
const redraw = useCallback(() => {
@ -76,19 +83,19 @@ export function PreviewDrawOverlay({
if (!ctx) return;
ctx.clearRect(0, 0, cvs.width, cvs.height);
ctx.strokeStyle = STROKE_COLOR;
ctx.lineWidth = STROKE_WIDTH;
const dpr = window.devicePixelRatio || 1;
ctx.lineWidth = STROKE_WIDTH * dpr;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
const dpr = window.devicePixelRatio || 1;
const all = drawingRef.current ? [...strokesRef.current, drawingRef.current] : strokesRef.current;
for (const s of all) {
const first = s.points[0];
if (!first) continue;
ctx.beginPath();
ctx.moveTo(first.x * dpr, first.y * dpr);
ctx.moveTo(first.x * cvs.width, first.y * cvs.height);
for (let i = 1; i < s.points.length; i++) {
const p = s.points[i]!;
ctx.lineTo(p.x * dpr, p.y * dpr);
ctx.lineTo(p.x * cvs.width, p.y * cvs.height);
}
ctx.stroke();
}
@ -139,7 +146,12 @@ export function PreviewDrawOverlay({
function pointFromEvent(e: PointerEvent): Point {
const cvs = canvasRef.current!;
const rect = cvs.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
const x = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0;
const y = rect.height > 0 ? (e.clientY - rect.top) / rect.height : 0;
return {
x: Math.min(1, Math.max(0, x)),
y: Math.min(1, Math.max(0, y)),
};
}
function activePreviewIframe(): HTMLIFrameElement | null {
@ -222,10 +234,12 @@ export function PreviewDrawOverlay({
}, [active, redraw]);
function strokeBounds(): { x: number; y: number; width: number; height: number } | null {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect || rect.width <= 0 || rect.height <= 0) return null;
const points = strokesRef.current.flatMap((stroke) => stroke.points);
if (points.length === 0) return null;
const xs = points.map((point) => point.x);
const ys = points.map((point) => point.y);
const xs = points.map((point) => point.x * rect.width);
const ys = points.map((point) => point.y * rect.height);
const minX = Math.min(...xs);
const minY = Math.min(...ys);
const maxX = Math.max(...xs);
@ -335,10 +349,10 @@ export function PreviewDrawOverlay({
const first = s.points[0];
if (!first) continue;
ctx.beginPath();
ctx.moveTo(first.x * sx, first.y * sy);
ctx.moveTo(first.x * snap.w, first.y * snap.h);
for (let i = 1; i < s.points.length; i++) {
const p = s.points[i]!;
ctx.lineTo(p.x * sx, p.y * sy);
ctx.lineTo(p.x * snap.w, p.y * snap.h);
}
ctx.stroke();
}
@ -350,6 +364,7 @@ export function PreviewDrawOverlay({
const shouldCapture = hasInk || hasTarget || captureViewport;
const canSubmit = shouldCapture || Boolean(note.trim());
if (sending || !canSubmit) return;
setCaptureWarning(null);
setPendingAction(action);
try {
let file: File | null = null;
@ -358,39 +373,49 @@ export function PreviewDrawOverlay({
const snap = await requestSnapshot();
if (snap) blob = await compositeWithBackground(snap);
if (!blob) {
const cvs = canvasRef.current;
if (cvs) {
const copy = document.createElement('canvas');
copy.width = cvs.width;
copy.height = cvs.height;
const ctx = copy.getContext('2d');
if (ctx) {
ctx.drawImage(cvs, 0, 0);
const dpr = window.devicePixelRatio || 1;
drawCaptureTarget(ctx, dpr, dpr, captureTarget);
blob = await new Promise<Blob | null>((resolve) => copy.toBlob((b) => resolve(b), 'image/png'));
} else {
blob = await new Promise<Blob | null>((resolve) => cvs.toBlob((b) => resolve(b), 'image/png'));
}
}
}
if (blob) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
file = new File([blob], `drawing-${ts}.png`, { type: 'image/png' });
setCaptureWarning({
action,
message: captureViewport && !hasInk && !hasTarget
? t('chat.annotationPreviewMissing')
: t('chat.annotationPreviewMissingInk'),
});
return;
}
const ts = new Date().toISOString().replace(/[:.]/g, '-');
file = new File([blob], `drawing-${ts}.png`, { type: 'image/png' });
}
const kind = markKind();
const detail: AnnotationEventDetail = {
file,
note: note.trim(),
action,
filePath: captureTarget?.filePath || filePath,
markKind: kind,
bounds: kind ? annotationBounds() : undefined,
target: captureTarget,
};
window.dispatchEvent(new CustomEvent(ANNOTATION_EVENT, { detail }));
const result = await new Promise<{ ok: boolean; message?: string }>((resolve) => {
let settled = false;
const finish = (next: { ok: boolean; message?: string }) => {
if (settled) return;
settled = true;
resolve(next);
};
window.setTimeout(() => {
finish({ ok: false, message: t('chat.annotationTimeout') });
}, 60000);
const detail: AnnotationEventDetail = {
file,
note: note.trim(),
action,
filePath: captureTarget?.filePath || filePath,
markKind: kind,
bounds: kind ? annotationBounds() : undefined,
target: captureTarget,
ack: finish,
};
window.dispatchEvent(new CustomEvent(ANNOTATION_EVENT, { detail }));
});
if (!result.ok) {
setCaptureWarning({
action,
message: result.message || t('chat.annotationFailed'),
});
return;
}
clearInk();
setCaptureWarning(null);
setNote('');
} finally {
setPendingAction(null);
@ -432,33 +457,61 @@ export function PreviewDrawOverlay({
/>
) : null}
{active ? (
<div
style={{
position: 'absolute',
left: '50%',
bottom: 16,
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 8px',
background: 'rgba(20,20,20,0.92)',
color: '#fff',
borderRadius: 999,
boxShadow: '0 6px 24px rgba(0,0,0,0.18)',
backdropFilter: 'blur(8px)',
zIndex: 10,
pointerEvents: 'auto',
fontSize: 13,
}}
>
<>
{captureWarning ? (
<div
role="status"
aria-live="polite"
style={{
position: 'absolute',
left: '50%',
bottom: 82,
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
maxWidth: 'min(420px, calc(100% - 32px))',
padding: '8px 12px',
borderRadius: 999,
background: 'rgba(20,20,20,0.92)',
color: '#fff',
boxShadow: '0 6px 24px rgba(0,0,0,0.18)',
backdropFilter: 'blur(8px)',
zIndex: 11,
pointerEvents: 'none',
fontSize: 13,
lineHeight: 1.35,
}}
>
<span>{captureWarning.message}</span>
</div>
) : null}
<div
style={{
position: 'absolute',
left: '50%',
bottom: 16,
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 8px',
background: 'rgba(20,20,20,0.92)',
color: '#fff',
borderRadius: 999,
boxShadow: '0 6px 24px rgba(0,0,0,0.18)',
backdropFilter: 'blur(8px)',
zIndex: 10,
pointerEvents: 'auto',
fontSize: 13,
}}
>
<button
type="button"
onClick={undoStroke}
disabled={!canUndo}
style={historyButtonStyle(canUndo)}
aria-label="Undo"
title="Undo"
aria-label={t('manualEdit.undo')}
title={t('manualEdit.undo')}
>
<RemixIcon name="arrow-go-back-line" size={14} />
</button>
@ -467,8 +520,8 @@ export function PreviewDrawOverlay({
onClick={redoStroke}
disabled={!canRedo}
style={historyButtonStyle(canRedo)}
aria-label="Redo"
title="Redo"
aria-label={t('manualEdit.redo')}
title={t('manualEdit.redo')}
>
<RemixIcon name="arrow-go-forward-line" size={14} />
</button>
@ -477,7 +530,7 @@ export function PreviewDrawOverlay({
value={note}
onChange={(e) => setNote(e.target.value)}
disabled={sending}
placeholder="Add a note for this annotation"
placeholder={t('chat.annotationNotePlaceholder')}
style={{
background: 'rgba(218, 97, 56, 0.18)',
border: '1px solid rgba(248, 150, 104, 0.82)',
@ -514,10 +567,10 @@ export function PreviewDrawOverlay({
{pendingAction === 'queue' ? (
<>
<Icon name="spinner" size={12} />
<span>Queueing...</span>
<span>{t('chat.annotationQueueing')}</span>
</>
) : (
'Queue'
t('chat.annotationQueue')
)}
</button>
<button
@ -534,23 +587,24 @@ export function PreviewDrawOverlay({
{pendingAction === 'send' ? (
<>
<Icon name="spinner" size={12} />
<span>Sending...</span>
<span>{t('chat.annotationSending')}</span>
</>
) : (
'Send'
t('chat.send')
)}
</button>
<button
type="button"
onClick={closeOverlay}
disabled={sending}
aria-label="Close draw toolbar"
title="Close"
aria-label={t('common.close')}
title={t('common.close')}
style={iconButtonStyle}
>
<Icon name="close" size={13} />
</button>
</div>
</div>
</>
) : null}
</div>
);

View file

@ -78,12 +78,6 @@ export function buildManualEditBridge(enabled: boolean): string {
function isDiscoveryTarget(el){
return !!(el && el.matches && el.matches(discoverySelector));
}
function isPrimaryTarget(el){
if (!el || !el.hasAttribute) return false;
if (el.hasAttribute('data-od-id') || el.hasAttribute('data-od-edit')) return true;
var tag = el.tagName ? el.tagName.toLowerCase() : '';
return tag === 'a' || tag === 'button';
}
function inferKind(el){
var explicit = el.getAttribute('data-od-edit');
if (explicit) return explicit;
@ -185,6 +179,14 @@ export function buildManualEditBridge(enabled: boolean): string {
if (!enabled) return;
window.parent.postMessage({ type: 'od-edit-targets', targets: allTargets() }, '*');
}
var lastHoverId = null;
function postHoverTarget(el){
if (!enabled || !el) return;
var id = stableId(el);
if (id === lastHoverId) return;
lastHoverId = id;
window.parent.postMessage({ type: 'od-edit-hover', target: targetFrom(el, true) }, '*');
}
function clearSelectedTarget(){
var selected = document.querySelectorAll('[data-od-edit-selected]');
for (var i = 0; i < selected.length; i++) selected[i].removeAttribute('data-od-edit-selected');
@ -197,15 +199,13 @@ export function buildManualEditBridge(enabled: boolean): string {
}
function closestTarget(event){
var el = event.target;
var fallback = null;
while (el && el !== document.documentElement) {
if (el !== document.body && el !== document.documentElement && isSourceMappable(el) && isDiscoveryTarget(el)) {
if (isPrimaryTarget(el)) return el;
if (!fallback) fallback = el;
return el;
}
el = el.parentElement;
}
return fallback;
return null;
}
function caretRangeFromClick(clickEvent){
try {
@ -341,10 +341,10 @@ export function buildManualEditBridge(enabled: boolean): string {
document.addEventListener('click', function(ev){
if (!enabled) return;
if (ev.target && ev.target.closest && ev.target.closest('[data-od-editing="true"]')) return;
var el = closestTarget(ev);
if (!el) return;
ev.preventDefault();
ev.stopPropagation();
var el = closestTarget(ev);
if (!el) return;
var kind = inferKind(el);
if (kind === 'text' || kind === 'link') {
makeEditable(el, ev);
@ -352,6 +352,13 @@ export function buildManualEditBridge(enabled: boolean): string {
}
window.parent.postMessage({ type: 'od-edit-select', target: targetFrom(el, true) }, '*');
}, true);
document.addEventListener('pointerover', function(ev){
if (!enabled) return;
if (ev.target && ev.target.closest && ev.target.closest('[data-od-editing="true"]')) return;
var el = closestTarget(ev);
if (!el) return;
postHoverTarget(el);
}, true);
window.addEventListener('resize', postTargets);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', postTargets);
else setTimeout(postTargets, 0);
@ -363,9 +370,11 @@ export function buildManualEditBridgeStyle(): string {
return `<style data-od-edit-bridge-style>
html[data-od-edit-mode] body * { cursor: pointer !important; }
html[data-od-edit-mode] [data-od-id],
html[data-od-edit-mode] [data-od-runtime-id] { outline: 1px dashed rgba(37, 99, 235, 0.35); outline-offset: 3px; }
html[data-od-edit-mode] [data-od-runtime-id],
html[data-od-edit-mode] [data-od-source-path] { outline: 1px dashed rgba(37, 99, 235, 0.35); outline-offset: 3px; }
html[data-od-edit-mode] [data-od-id]:hover,
html[data-od-edit-mode] [data-od-runtime-id]:hover { outline: 2px solid #2563eb; }
html[data-od-edit-mode] [data-od-runtime-id]:hover,
html[data-od-edit-mode] [data-od-source-path]:hover { outline: 2px solid #2563eb; }
html[data-od-edit-mode] [data-od-edit-selected] {
outline: 2px solid #2563eb !important;
outline-offset: 4px;

View file

@ -97,6 +97,11 @@ export interface ManualEditSelectMessage {
target: ManualEditTarget;
}
export interface ManualEditHoverMessage {
type: 'od-edit-hover';
target: ManualEditTarget;
}
export interface ManualEditPreviewAppliedMessage {
type: 'od-edit-preview-style-applied';
id: string;
@ -114,6 +119,7 @@ export interface ManualEditTextCommitMessage {
export type ManualEditBridgeMessage =
| ManualEditTargetMessage
| ManualEditSelectMessage
| ManualEditHoverMessage
| ManualEditPreviewAppliedMessage
| ManualEditTextCommitMessage;

View file

@ -807,6 +807,24 @@ export const ar: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -1053,6 +1071,9 @@ export const ar: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1112,6 +1133,19 @@ export const ar: Dict = {
'fileViewer.presentNewTab': 'علامة تبويب جديدة',
'fileViewer.exitPresentation': 'الخروج من العرض',
'fileViewer.shareLabel': "مشاركة",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'تصدير كـ PDF',
'fileViewer.exportPdfAllSlides': 'تصدير كـ PDF (كل الشرائح)',
'fileViewer.exportPptxBusy': 'انتظر انتهاء الدور الحالي.',

View file

@ -695,6 +695,24 @@ export const de: Dict = {
'chat.comments.pinAtCoords': 'bei {x}, {y}',
'chat.comments.capturedItems': '{n} erfasste Elemente',
'chat.comments.clear': 'Löschen',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -941,6 +959,9 @@ export const de: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1000,6 +1021,19 @@ export const de: Dict = {
'fileViewer.presentNewTab': 'Neuer Tab',
'fileViewer.exitPresentation': 'Präsentation beenden',
'fileViewer.shareLabel': "Teilen",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'Als PDF exportieren',
'fileViewer.exportPdfAllSlides': 'Als PDF exportieren (alle Slides)',
'fileViewer.exportPptxBusy': 'Warten Sie, bis der aktuelle Turn abgeschlossen ist.',

View file

@ -1411,6 +1411,24 @@ export const en: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.conversationsTitle': 'Conversations',
'chat.conversationsAria': 'Conversation history',
'chat.newConversation': 'New conversation',
@ -1662,6 +1680,9 @@ export const en: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1728,6 +1749,19 @@ export const en: Dict = {
'fileViewer.presentNewTab': 'New tab',
'fileViewer.exitPresentation': 'Exit presentation',
'fileViewer.shareLabel': "Share",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'Export as PDF',
'fileViewer.exportPdfAllSlides': 'Export as PDF (all slides)',
'fileViewer.exportPptxBusy': 'Wait for the current turn to finish.',

View file

@ -696,6 +696,24 @@ export const esES: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -942,6 +960,9 @@ export const esES: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1001,6 +1022,19 @@ export const esES: Dict = {
'fileViewer.presentNewTab': 'Pestaña nueva',
'fileViewer.exitPresentation': 'Salir de la presentación',
'fileViewer.shareLabel': "Compartir",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'Exportar como PDF',
'fileViewer.exportPdfAllSlides': 'Exportar como PDF (todas las diapositivas)',
'fileViewer.exportPptxBusy': 'Espera a que termine el turno actual.',

View file

@ -829,6 +829,24 @@ export const fa: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -1077,6 +1095,9 @@ export const fa: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1136,6 +1157,19 @@ export const fa: Dict = {
'fileViewer.presentNewTab': 'تب جدید',
'fileViewer.exitPresentation': 'خروج از ارائه',
'fileViewer.shareLabel': "اشتراک‌گذاری",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'صادرکردن به PDF',
'fileViewer.exportPdfAllSlides': 'صادرکردن به PDF (همه اسلایدها)',
'fileViewer.exportPptxBusy': 'منتظر پایان نوبت فعلی باشید.',

View file

@ -1344,6 +1344,24 @@ export const fr: Dict = {
'chat.comments.pinAtCoords': 'à {x}, {y}',
'chat.comments.capturedItems': '{n} élément(s) capturé(s)',
'chat.comments.clear': 'Effacer',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'Aucune cible de texte ou de style modifiable trouvée.',
'chat.inspect.noCommentTargets': 'Aucune cible de texte ou visuelle pouvant recevoir un commentaire na été trouvée.',
'chat.inspect.editHint': 'Sélectionnez une cible de texte ou de style dans laperçu pour la modifier.',
@ -1586,6 +1604,9 @@ export const fr: Dict = {
'manualEdit.editableCount': '{count} modifiable(s)',
'manualEdit.hiddenBadge': 'Masqué',
'manualEdit.title': 'Éditeur manuel',
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': 'Sélectionnez un calque',
'manualEdit.empty': 'Cliquez sur un élément dans laperçu ou choisissez un calque.',
'manualEdit.noEditableLayers': 'Aucun calque modifiable trouvé.',
@ -1652,6 +1673,19 @@ export const fr: Dict = {
'fileViewer.presentNewTab': 'Nouvel onglet',
'fileViewer.exitPresentation': 'Quitter la présentation',
'fileViewer.shareLabel': 'Partager',
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'Exporter en PDF',
'fileViewer.exportPdfAllSlides': 'Exporter en PDF (toutes les diapos)',
'fileViewer.exportPptxBusy': 'Attendez la fin du tour en cours.',

View file

@ -807,6 +807,24 @@ export const hu: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -1053,6 +1071,9 @@ export const hu: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1112,6 +1133,19 @@ export const hu: Dict = {
'fileViewer.presentNewTab': 'Új lap',
'fileViewer.exitPresentation': 'Bemutató bezárása',
'fileViewer.shareLabel': "Megosztás",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'Exportálás PDF-ként',
'fileViewer.exportPdfAllSlides': 'Exportálás PDF-ként (minden dia)',
'fileViewer.exportPptxBusy': 'Várj, amíg az aktuális kör befejeződik.',

View file

@ -921,6 +921,24 @@ export const id: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -1170,6 +1188,9 @@ export const id: Dict = {
'manualEdit.editableCount': '{count} dapat diedit',
'manualEdit.hiddenBadge': 'Tersembunyi',
'manualEdit.title': 'Editor manual',
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': 'Pilih lapisan',
'manualEdit.empty': 'Klik elemen di pratinjau atau pilih lapisan.',
'manualEdit.noEditableLayers': 'Tidak ada lapisan yang dapat diedit.',
@ -1230,6 +1251,19 @@ export const id: Dict = {
'fileViewer.presentNewTab': 'Presentasi di tab baru',
'fileViewer.exitPresentation': 'Keluar presentasi',
'fileViewer.shareLabel': "Bagikan",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'Ekspor PDF',
'fileViewer.exportPdfAllSlides': 'Ekspor PDF semua slide',
'fileViewer.exportPptxBusy': 'Mengekspor PPTX...',

View file

@ -725,6 +725,24 @@ export const it: Dict = {
'chat.comments.updateSend': 'Aggiorna e invia',
'chat.comments.removeAttachment': 'Rimuovi commento allegato',
'chat.comments.removeAttachmentAria': 'Rimuovi commento allegato per {name}',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -968,6 +986,9 @@ export const it: Dict = {
'manualEdit.editableCount': '{count} modificabile',
'manualEdit.hiddenBadge': 'Nascosto',
'manualEdit.title': 'Editor manuale',
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': 'Seleziona un livello',
'manualEdit.empty': 'Clicca un elemento nell\'anteprima o scegli un livello.',
'manualEdit.noEditableLayers': 'Nessun livello modificabile trovato.',
@ -1020,6 +1041,19 @@ export const it: Dict = {
'fileViewer.presentNewTab': 'Nuova scheda',
'fileViewer.exitPresentation': 'Esci dalla presentazione',
'fileViewer.shareLabel': "Condividi",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'Esporta in PDF',
'fileViewer.exportPdfAllSlides': 'Esporta in PDF (tutte le diapositive)',
'fileViewer.exportPptxBusy': 'Attendi la fine del turno in corso.',

View file

@ -694,6 +694,24 @@ export const ja: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -940,6 +958,9 @@ export const ja: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -999,6 +1020,19 @@ export const ja: Dict = {
'fileViewer.presentNewTab': '新しいタブ',
'fileViewer.exitPresentation': 'プレゼンを終了',
'fileViewer.shareLabel': "共有",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'PDFとしてエクスポート',
'fileViewer.exportPdfAllSlides': 'PDFとしてエクスポート全スライド',
'fileViewer.exportPptxBusy': '現在のターンが終わるまでお待ちください。',

View file

@ -807,6 +807,24 @@ export const ko: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -1053,6 +1071,9 @@ export const ko: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1112,6 +1133,19 @@ export const ko: Dict = {
'fileViewer.presentNewTab': '새 탭에서',
'fileViewer.exitPresentation': '프레젠테이션 종료',
'fileViewer.shareLabel': "공유",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'PDF로 내보내기',
'fileViewer.exportPdfAllSlides': 'PDF로 내보내기 (모든 슬라이드)',
'fileViewer.exportPptxBusy': '현재 작업이 끝날 때까지 기다려 주세요.',

View file

@ -807,6 +807,24 @@ export const pl: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -1053,6 +1071,9 @@ export const pl: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1112,6 +1133,19 @@ export const pl: Dict = {
'fileViewer.presentNewTab': 'Nowa karta',
'fileViewer.exitPresentation': 'Wyjdź z prezentacji',
'fileViewer.shareLabel': "Udostępnij",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'Eksportuj jako PDF',
'fileViewer.exportPdfAllSlides': 'Eksportuj jako PDF (wszystkie slajdy)',
'fileViewer.exportPptxBusy': 'Poczekaj, aż bieżąca tura zostanie zakończona.',

View file

@ -828,6 +828,24 @@ export const ptBR: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -1076,6 +1094,9 @@ export const ptBR: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1135,6 +1156,19 @@ export const ptBR: Dict = {
'fileViewer.presentNewTab': 'Nova aba',
'fileViewer.exitPresentation': 'Sair da apresentação',
'fileViewer.shareLabel': "Compartilhar",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'Exportar como PDF',
'fileViewer.exportPdfAllSlides': 'Exportar como PDF (todos os slides)',
'fileViewer.exportPptxBusy': 'Aguarde o turno atual terminar.',

View file

@ -828,6 +828,24 @@ export const ru: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -1076,6 +1094,9 @@ export const ru: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1135,6 +1156,19 @@ export const ru: Dict = {
'fileViewer.presentNewTab': 'Новая вкладка',
'fileViewer.exitPresentation': 'Выйти из презентации',
'fileViewer.shareLabel': "Поделиться",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'Экспорт в PDF',
'fileViewer.exportPdfAllSlides': 'Экспорт в PDF (все слайды)',
'fileViewer.exportPptxBusy': 'Дождитесь окончания текущего хода.',

View file

@ -763,6 +763,24 @@ export const th: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -993,6 +1011,9 @@ export const th: Dict = {
'manualEdit.editableCount': "ใช้แก้ได้ {count} รูปแบบ",
'manualEdit.hiddenBadge': "ซ่อน",
'manualEdit.title': "กล่องควบคุม",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "เลือกเลเยอร์ขึ้นมา",
'manualEdit.empty': "เลือกกล่องด้านบนเพื่อเปิดดู",
'manualEdit.noEditableLayers': "ไม่พบเลเยอร์ที่แก้ไขได้",
@ -1045,6 +1066,19 @@ export const th: Dict = {
'fileViewer.presentNewTab': 'เข้าสู่ลิ้งก์หน้าใหม่',
'fileViewer.exitPresentation': 'หนีออกโหมดคนโชว์',
'fileViewer.shareLabel': "แชร์",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'เปลี่ยนฟอร์แมตเอาไปเป็น PDF',
'fileViewer.exportPdfAllSlides': 'ดาวน์โหลดทั้งหน้าเป็น PDF',
'fileViewer.exportPptxBusy': 'ต้องให้ทำระบบของปัจจุบันจนสำเร็จก่อน',

View file

@ -796,6 +796,24 @@ export const tr: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -1040,6 +1058,9 @@ export const tr: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1099,6 +1120,19 @@ export const tr: Dict = {
'fileViewer.presentNewTab': 'Yeni sekme',
'fileViewer.exitPresentation': 'Sunumdan ayrıl',
'fileViewer.shareLabel': "Paylaş",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'PDF olarak dışa aktar',
'fileViewer.exportPdfAllSlides': 'PDF olarak dışa aktar (tüm slaytlar)',
'fileViewer.exportPptxBusy': 'Güncel sıranın bitmesini bekleyin.',

View file

@ -829,6 +829,24 @@ export const uk: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': 'Image',
'chat.comments.targetControl': 'Control',
'chat.comments.targetLink': 'Link',
'chat.comments.targetText': 'Text',
'chat.comments.targetSection': 'Section',
'chat.comments.targetPage': 'Page',
'chat.comments.targetArea': 'Area',
'chat.annotationNotePlaceholder': 'Add a note for this annotation',
'chat.annotationQueue': 'Queue',
'chat.annotationQueueing': 'Queueing...',
'chat.annotationSending': 'Sending...',
'chat.annotationSendDisabledReason': 'A task is currently running',
'chat.annotationPreviewMissing': 'Could not capture the preview. Please try again.',
'chat.annotationPreviewMissingInk': 'Could not capture the preview. Try again to avoid sending only ink.',
'chat.annotationTimeout': 'Annotation send timed out. Please try again.',
'chat.annotationFailed': 'Annotation send failed. Please try again.',
'chat.annotationProjectCreateFailed': 'Could not create a project, so the annotation was not sent.',
'chat.annotationUploadFailed': 'Attachment upload failed. Please try again.',
'chat.inspect.noEditableTargets': 'This page has no editable elements yet.',
'chat.inspect.noCommentTargets': 'This page has no commentable elements yet.',
'chat.inspect.editHint': 'Click an element in the canvas to edit its styles.',
@ -1095,6 +1113,9 @@ export const uk: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "Hidden",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': 'Edit',
'manualEdit.movePanel': 'Move edit panel',
'manualEdit.closePanel': 'Close edit panel',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "No editable layers found.",
@ -1154,6 +1175,19 @@ export const uk: Dict = {
'fileViewer.presentNewTab': 'Нова вкладка',
'fileViewer.exitPresentation': 'Вийти з режиму презентації',
'fileViewer.shareLabel': "Поділитися",
'fileViewer.shareMenuShareLink': 'SHARE LINK',
'fileViewer.shareMenuPublishOnline': 'PUBLISH ONLINE',
'fileViewer.shareMenuDownload': 'DOWNLOAD',
'fileViewer.shareMenuPresentation': 'Presentation',
'fileViewer.shareMenuSourceFiles': 'Source files',
'fileViewer.shareMenuSave': 'SAVE',
'fileViewer.copyProviderLink': 'Copy {provider} link',
'fileViewer.copyCloudflareLink': 'Copy Cloudflare link',
'fileViewer.screenshotCopying': 'Copying screenshot...',
'fileViewer.screenshotCopied': 'Screenshot copied to clipboard',
'fileViewer.screenshotClipboardDenied': 'Browser blocked clipboard access',
'fileViewer.screenshotPreviewLoading': 'Preview is still loading. Try again in a moment.',
'fileViewer.screenshotCaptureFailed': 'Could not capture the preview. Please try again.',
'fileViewer.exportPdf': 'Експортувати як PDF',
'fileViewer.exportPdfAllSlides': 'Експортувати як PDF (усі слайди)',
'fileViewer.exportPptxBusy': 'Чекайте, поки поточна черга завершиться.',

View file

@ -1403,6 +1403,24 @@ export const zhCN: Dict = {
'chat.comments.pinAtCoords': '位于 {x}, {y}',
'chat.comments.capturedItems': '{n} 个捕获项',
'chat.comments.clear': '清除',
'chat.comments.targetImage': '图片',
'chat.comments.targetControl': '控件',
'chat.comments.targetLink': '链接',
'chat.comments.targetText': '文本',
'chat.comments.targetSection': '分区',
'chat.comments.targetPage': '页面',
'chat.comments.targetArea': '区域',
'chat.annotationNotePlaceholder': '为这条标注添加说明',
'chat.annotationQueue': '加入队列',
'chat.annotationQueueing': '加入队列中...',
'chat.annotationSending': '发送中...',
'chat.annotationSendDisabledReason': '当前正有任务在执行',
'chat.annotationPreviewMissing': '无法截到下面的预览,请重试',
'chat.annotationPreviewMissingInk': '无法截到下面的预览,请重试,避免只附带笔迹',
'chat.annotationTimeout': '标注发送超时,请重试',
'chat.annotationFailed': '标注发送失败,请重试',
'chat.annotationProjectCreateFailed': '无法创建项目,标注未发送',
'chat.annotationUploadFailed': '附件上传失败,请重试',
'chat.conversationsTitle': '对话历史',
'chat.conversationsAria': '对话历史',
'chat.newConversation': '新建对话',
@ -1651,6 +1669,9 @@ export const zhCN: Dict = {
'manualEdit.editableCount': '{count} 个可编辑元素',
'manualEdit.hiddenBadge': '隐藏',
'manualEdit.title': '手动编辑器',
'manualEdit.fallbackTitle': '编辑',
'manualEdit.movePanel': '移动编辑面板',
'manualEdit.closePanel': '关闭编辑面板',
'manualEdit.selectLayer': '选择图层',
'manualEdit.empty': '在预览中点击元素,或选择一个图层。',
'manualEdit.noEditableLayers': '未找到可编辑图层。',
@ -1717,6 +1738,19 @@ export const zhCN: Dict = {
'fileViewer.presentNewTab': '新标签页',
'fileViewer.exitPresentation': '退出演示',
'fileViewer.shareLabel': "分享",
'fileViewer.shareMenuShareLink': '分享链接',
'fileViewer.shareMenuPublishOnline': '发布到线上',
'fileViewer.shareMenuDownload': '下载',
'fileViewer.shareMenuPresentation': '演示文稿',
'fileViewer.shareMenuSourceFiles': '源文件',
'fileViewer.shareMenuSave': '保存',
'fileViewer.copyProviderLink': '复制 {provider} 链接',
'fileViewer.copyCloudflareLink': '复制 Cloudflare 链接',
'fileViewer.screenshotCopying': '正在复制截图…',
'fileViewer.screenshotCopied': '截图已保存到剪贴板',
'fileViewer.screenshotClipboardDenied': '浏览器拒绝写入剪贴板',
'fileViewer.screenshotPreviewLoading': '预览还在加载,请稍后再试',
'fileViewer.screenshotCaptureFailed': '无法截取预览,请重试',
'fileViewer.exportPdf': '导出为 PDF',
'fileViewer.exportPdfAllSlides': '导出为 PDF全部幻灯片',
'fileViewer.exportPptxBusy': '请等待当前任务完成。',

View file

@ -1003,6 +1003,24 @@ export const zhTW: Dict = {
'chat.comments.pinAtCoords': 'at {x}, {y}',
'chat.comments.capturedItems': '{n} captured items',
'chat.comments.clear': 'Clear',
'chat.comments.targetImage': '圖片',
'chat.comments.targetControl': '控制項',
'chat.comments.targetLink': '連結',
'chat.comments.targetText': '文字',
'chat.comments.targetSection': '區段',
'chat.comments.targetPage': '頁面',
'chat.comments.targetArea': '區域',
'chat.annotationNotePlaceholder': '為這條標註加入說明',
'chat.annotationQueue': '加入佇列',
'chat.annotationQueueing': '加入佇列中...',
'chat.annotationSending': '傳送中...',
'chat.annotationSendDisabledReason': '目前正有任務執行中',
'chat.annotationPreviewMissing': '無法截到下方預覽,請重試',
'chat.annotationPreviewMissingInk': '無法截到下方預覽,請重試,避免只附帶筆跡',
'chat.annotationTimeout': '標註傳送逾時,請重試',
'chat.annotationFailed': '標註傳送失敗,請重試',
'chat.annotationProjectCreateFailed': '無法建立專案,因此未送出標註。',
'chat.annotationUploadFailed': '附件上傳失敗,請重試。',
'chat.conversationsTitle': '對話紀錄',
'chat.conversationsAria': '對話紀錄',
'chat.newConversation': '新建對話',
@ -1248,6 +1266,9 @@ export const zhTW: Dict = {
'manualEdit.editableCount': "{count} editable",
'manualEdit.hiddenBadge': "隱藏",
'manualEdit.title': "Manual editor",
'manualEdit.fallbackTitle': '編輯',
'manualEdit.movePanel': '移動編輯面板',
'manualEdit.closePanel': '關閉編輯面板',
'manualEdit.selectLayer': "Select a layer",
'manualEdit.empty': "Click an element in the preview or choose a layer.",
'manualEdit.noEditableLayers': "未找到可編輯圖層。",
@ -1307,6 +1328,19 @@ export const zhTW: Dict = {
'fileViewer.presentNewTab': '新分頁',
'fileViewer.exitPresentation': '離開簡報',
'fileViewer.shareLabel': "分享",
'fileViewer.shareMenuShareLink': '分享連結',
'fileViewer.shareMenuPublishOnline': '發布到線上',
'fileViewer.shareMenuDownload': '下載',
'fileViewer.shareMenuPresentation': '簡報',
'fileViewer.shareMenuSourceFiles': '原始檔案',
'fileViewer.shareMenuSave': '儲存',
'fileViewer.copyProviderLink': '複製 {provider} 連結',
'fileViewer.copyCloudflareLink': '複製 Cloudflare 連結',
'fileViewer.screenshotCopying': '正在複製截圖…',
'fileViewer.screenshotCopied': '截圖已儲存到剪貼簿',
'fileViewer.screenshotClipboardDenied': '瀏覽器拒絕寫入剪貼簿',
'fileViewer.screenshotPreviewLoading': '預覽仍在載入,請稍後再試',
'fileViewer.screenshotCaptureFailed': '無法截取預覽,請重試',
'fileViewer.exportPdf': '匯出為 PDF',
'fileViewer.exportPdfAllSlides': '匯出為 PDF全部投影片',
'fileViewer.exportPptxBusy': '請等待當前任務完成。',

View file

@ -1723,6 +1723,24 @@ export interface Dict {
'chat.comments.pinAtCoords': string;
'chat.comments.capturedItems': string;
'chat.comments.clear': string;
'chat.comments.targetImage': string;
'chat.comments.targetControl': string;
'chat.comments.targetLink': string;
'chat.comments.targetText': string;
'chat.comments.targetSection': string;
'chat.comments.targetPage': string;
'chat.comments.targetArea': string;
'chat.annotationNotePlaceholder': string;
'chat.annotationQueue': string;
'chat.annotationQueueing': string;
'chat.annotationSending': string;
'chat.annotationSendDisabledReason': string;
'chat.annotationPreviewMissing': string;
'chat.annotationPreviewMissingInk': string;
'chat.annotationTimeout': string;
'chat.annotationFailed': string;
'chat.annotationProjectCreateFailed': string;
'chat.annotationUploadFailed': string;
'chat.inspect.noEditableTargets': string;
'chat.inspect.noCommentTargets': string;
'chat.inspect.editHint': string;
@ -1976,6 +1994,9 @@ export interface Dict {
'manualEdit.editableCount': string;
'manualEdit.hiddenBadge': string;
'manualEdit.title': string;
'manualEdit.fallbackTitle': string;
'manualEdit.movePanel': string;
'manualEdit.closePanel': string;
'manualEdit.selectLayer': string;
'manualEdit.empty': string;
'manualEdit.noEditableLayers': string;
@ -2042,6 +2063,19 @@ export interface Dict {
'fileViewer.presentNewTab': string;
'fileViewer.exitPresentation': string;
'fileViewer.shareLabel': string;
'fileViewer.shareMenuShareLink': string;
'fileViewer.shareMenuPublishOnline': string;
'fileViewer.shareMenuDownload': string;
'fileViewer.shareMenuPresentation': string;
'fileViewer.shareMenuSourceFiles': string;
'fileViewer.shareMenuSave': string;
'fileViewer.copyProviderLink': string;
'fileViewer.copyCloudflareLink': string;
'fileViewer.screenshotCopying': string;
'fileViewer.screenshotCopied': string;
'fileViewer.screenshotClipboardDenied': string;
'fileViewer.screenshotPreviewLoading': string;
'fileViewer.screenshotCaptureFailed': string;
'fileViewer.exportPdf': string;
'fileViewer.exportPdfAllSlides': string;
'fileViewer.exportPptxBusy': string;

View file

@ -331,12 +331,18 @@ export function exportAsMd(source: string, title: string): void {
* injected into a srcdoc preview iframe. Returns null if the bridge is not
* present (e.g. URL-load mode) or the capture times out.
*/
export function requestPreviewSnapshot(
export type PreviewSnapshot = { dataUrl: string; w: number; h: number };
export type PreviewSnapshotResult =
| { ok: true; snapshot: PreviewSnapshot }
| { ok: false; reason: 'loading' | 'post-message-error' | 'render-error' | 'timeout'; error?: string };
export function requestPreviewSnapshotResult(
iframe: HTMLIFrameElement,
timeout = 2500,
): Promise<{ dataUrl: string; w: number; h: number } | null> {
timeout = 8000,
): Promise<PreviewSnapshotResult> {
const win = iframe.contentWindow;
if (!win) return Promise.resolve(null);
if (!win) return Promise.resolve({ ok: false, reason: 'loading' });
const id = `snap-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return new Promise((resolve) => {
let done = false;
@ -354,25 +360,35 @@ export function requestPreviewSnapshot(
if (done) return;
done = true;
window.removeEventListener('message', onMsg);
if (d.dataUrl && d.w && d.h) resolve({ dataUrl: d.dataUrl, w: d.w, h: d.h });
else resolve(null);
if (d.dataUrl && d.w && d.h) resolve({ ok: true, snapshot: { dataUrl: d.dataUrl, w: d.w, h: d.h } });
else resolve({ ok: false, reason: 'render-error', error: d.error });
}
window.addEventListener('message', onMsg);
try {
win.postMessage({ type: 'od:snapshot', id }, '*');
} catch {
/* sandboxed */
done = true;
window.removeEventListener('message', onMsg);
resolve({ ok: false, reason: 'post-message-error' });
}
setTimeout(() => {
if (!done) {
done = true;
window.removeEventListener('message', onMsg);
resolve(null);
resolve({ ok: false, reason: 'timeout' });
}
}, timeout);
});
}
export async function requestPreviewSnapshot(
iframe: HTMLIFrameElement,
timeout = 8000,
): Promise<PreviewSnapshot | null> {
const result = await requestPreviewSnapshotResult(iframe, timeout);
return result.ok ? result.snapshot : null;
}
/** Convert a data-URL to a Blob without re-encoding through canvas. */
function dataUrlToBlob(dataUrl: string): Blob {
if (!dataUrl.startsWith('data:')) {

View file

@ -168,13 +168,28 @@ function injectSrcdocTransportActivationBridge(doc: string): string {
function injectSnapshotBridge(doc: string): string {
const script = `<script data-od-snapshot-bridge>(function(){
var SNAPSHOT_STYLE_PROPS = [
'display','position','box-sizing','width','height','min-width','max-width','min-height','max-height',
'margin','margin-top','margin-right','margin-bottom','margin-left',
'padding','padding-top','padding-right','padding-bottom','padding-left',
'border','border-top','border-right','border-bottom','border-left','border-radius',
'font','font-family','font-size','font-weight','font-style','line-height','letter-spacing',
'color','background-color','opacity','transform','transform-origin','overflow','overflow-x','overflow-y',
'white-space','text-align','vertical-align','object-fit','object-position',
'flex','flex-direction','flex-wrap','flex-grow','flex-shrink','flex-basis',
'grid','grid-template-columns','grid-template-rows','grid-column','grid-row',
'gap','row-gap','column-gap','align-items','align-content','align-self',
'justify-items','justify-content','justify-self','inset','top','right','bottom','left',
'z-index','box-shadow','text-shadow'
];
function copyComputedStyle(source, target){
if (!source || !target || source.nodeType !== 1 || target.nodeType !== 1) return;
var computed = window.getComputedStyle(source);
var style = target.getAttribute('style') || '';
for (var i = 0; i < computed.length; i++){
var prop = computed[i];
style += prop + ':' + computed.getPropertyValue(prop) + ';';
for (var i = 0; i < SNAPSHOT_STYLE_PROPS.length; i++){
var prop = SNAPSHOT_STYLE_PROPS[i];
var value = computed.getPropertyValue(prop);
if (value) style += prop + ':' + value + ';';
}
target.setAttribute('style', style);
}
@ -196,13 +211,21 @@ function injectSnapshotBridge(doc: string): string {
syncElementState(originalRoot, cloneRoot);
var originals = originalRoot.querySelectorAll('*');
var clones = cloneRoot.querySelectorAll('*');
var count = Math.min(originals.length, clones.length);
var count = Math.min(originals.length, clones.length, 3500);
for (var i = 0; i < count; i++){
copyComputedStyle(originals[i], clones[i]);
syncElementState(originals[i], clones[i]);
}
var scripts = cloneRoot.querySelectorAll('script');
for (var s = scripts.length - 1; s >= 0; s--) scripts[s].remove();
var links = cloneRoot.querySelectorAll('link[rel~="stylesheet"], link[rel~="preload"], link[rel~="preconnect"]');
for (var l = links.length - 1; l >= 0; l--) links[l].remove();
var styles = cloneRoot.querySelectorAll('style');
for (var st = 0; st < styles.length; st++) {
styles[st].textContent = (styles[st].textContent || '')
.replace(/@import[^;]+;/gi, '')
.replace(/@font-face\\s*\\{[^}]*\\}/gi, '');
}
}
function waitForImages(){
var imgs = Array.prototype.slice.call(document.images || []);
@ -214,6 +237,17 @@ function injectSnapshotBridge(doc: string): string {
});
}));
}
function scrollOffset(){
var doc = document.documentElement;
var body = document.body;
return {
x: Math.max(window.scrollX || 0, doc ? doc.scrollLeft || 0 : 0, body ? body.scrollLeft || 0 : 0),
y: Math.max(window.scrollY || 0, doc ? doc.scrollTop || 0 : 0, body ? body.scrollTop || 0 : 0)
};
}
function escapeAttribute(value){
return String(value || '').replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}
function renderSnapshot(id){
var w = Math.max(1, window.innerWidth || document.documentElement.clientWidth || 1);
var h = Math.max(1, window.innerHeight || document.documentElement.clientHeight || 1);
@ -223,10 +257,17 @@ function injectSnapshotBridge(doc: string): string {
var clone = document.documentElement.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
inlineSnapshotStyles(document.documentElement, clone);
var serializer = new XMLSerializer();
var html = serializer.serializeToString(clone);
var scroll = scrollOffset();
var cloneBody = clone.querySelector('body');
var rootStyle = clone.getAttribute('style') || '';
var bodyStyle = cloneBody ? cloneBody.getAttribute('style') || '' : '';
var bodyContent = cloneBody ? cloneBody.innerHTML : clone.innerHTML;
var wrapperStyle = rootStyle + bodyStyle +
'margin:0;position:relative;left:' + (-scroll.x) + 'px;top:' + (-scroll.y) + 'px;' +
'width:' + docW + 'px;height:' + docH + 'px;overflow:visible;';
var html = '<div xmlns="http://www.w3.org/1999/xhtml" style="' + escapeAttribute(wrapperStyle) + '">' + bodyContent + '</div>';
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + '">' +
'<foreignObject x="' + (-window.scrollX || 0) + '" y="' + (-window.scrollY || 0) + '" width="' + docW + '" height="' + docH + '">' +
'<foreignObject x="0" y="0" width="' + docW + '" height="' + docH + '">' +
html +
'</foreignObject></svg>';
var img = new Image();
@ -244,10 +285,14 @@ function injectSnapshotBridge(doc: string): string {
window.parent.postMessage({ type: 'od:snapshot:result', id: id, error: String(err && err.message || err) }, '*');
}
};
function encodedSvgDataUrl(){
var encoded = encodeURIComponent(svg);
return 'data:image/svg+xml;charset=utf-8,' + encoded;
}
img.onerror = function(){
window.parent.postMessage({ type: 'od:snapshot:result', id: id, error: 'snapshot image failed' }, '*');
};
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
img.src = encodedSvgDataUrl();
}
window.addEventListener('message', function(ev){
var data = ev && ev.data;
@ -1010,7 +1055,7 @@ function meaningfulDomFallbackTarget(el) {
return true;
}
function targetFrom(el, allowDomFallback, clickedEl){
function targetFrom(el, allowDomFallback, clickedEl, clickPoint){
var id = el.getAttribute('data-od-id') || el.getAttribute('data-screen-label');
var selector = annotatedSelectorFor(el);
if (!id && allowDomFallback && meaningfulDomFallbackTarget(el)) {
@ -1023,16 +1068,23 @@ function meaningfulDomFallbackTarget(el) {
var cls = typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
var html = '';
try { html = (el.outerHTML || '').replace(/\\s+/g, ' ').match(/^<[^>]+>/)?.[0] || ''; } catch (_) {}
var position = { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) };
if (clickPoint) {
position = { x: Math.round(clickPoint.x), y: Math.round(clickPoint.y), width: 1, height: 1 };
}
var payload = {
type: 'od:comment-target',
elementId: id,
selector: selector,
label: tag + cls,
text: (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 160),
position: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
position: position,
htmlHint: html.slice(0, 180),
style: styleSnapshot(el)
};
if (clickPoint) {
payload.hoverPoint = { x: Math.round(clickPoint.x), y: Math.round(clickPoint.y) };
}
if (clickedEl && clickedEl !== el) {
var clickedTag = clickedEl.tagName ? clickedEl.tagName.toLowerCase() : 'element';
var clickedCls = typeof clickedEl.className === 'string' && clickedEl.className.trim() ? '.' + clickedEl.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
@ -1261,7 +1313,9 @@ function meaningfulDomFallbackTarget(el) {
if (result) {
ev.preventDefault();
ev.stopPropagation();
var payload = targetFrom(result.target, commentEnabled && mode === 'picker' && !inspectEnabled, result.clicked);
var commentPickerClick = commentEnabled && mode === 'picker' && !inspectEnabled;
var clickPoint = commentPickerClick ? { x: ev.clientX, y: ev.clientY } : null;
var payload = targetFrom(result.target, commentPickerClick, result.clicked, clickPoint);
if (payload) window.parent.postMessage(payload, '*');
return;
}
@ -1296,6 +1350,7 @@ function meaningfulDomFallbackTarget(el) {
label: 'pin',
text: '',
position: { x: pinX - 12, y: pinY - 12, width: 24, height: 24 },
hoverPoint: { x: pinX, y: pinY },
htmlHint: '',
style: null,
freePin: true
@ -1431,9 +1486,35 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
if (document.body && document.body.scrollWidth > document.body.clientWidth + 1) return document.body;
return document.scrollingElement || document.documentElement;
}
function scrollTargets(){
var targets = [];
function add(node){
if (!node) return;
for (var i=0; i<targets.length; i++) if (targets[i] === node) return;
targets.push(node);
}
add(document.scrollingElement);
add(document.documentElement);
add(document.body);
return targets;
}
function maxScrollLeft(){
var targets = scrollTargets();
var value = 0;
for (var i=0; i<targets.length; i++) {
value = Math.max(value, Number(targets[i].scrollLeft || 0));
}
return value;
}
function hasHorizontalScroll(){
var targets = scrollTargets();
for (var i=0; i<targets.length; i++) {
if (targets[i].scrollWidth > targets[i].clientWidth + 1) return true;
}
return false;
}
function isScrollDeck(){
var sc = scroller();
return !!(sc && sc.scrollWidth > sc.clientWidth + 1);
return hasHorizontalScroll();
}
function findActiveByClass(list){
for (var i=0; i<list.length; i++) {
@ -1455,8 +1536,10 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
if (!list || !list.length) return 0;
if (isScrollDeck()) {
var w = Math.max(1, window.innerWidth);
return Math.max(0, Math.min(list.length - 1, Math.round(scroller().scrollLeft / w)));
return Math.max(0, Math.min(list.length - 1, Math.round(maxScrollLeft() / w)));
}
var byTransform = activeIndexFromTransform(list);
if (byTransform >= 0) return byTransform;
var byClass = findActiveByClass(list);
if (byClass >= 0) return byClass;
var byVis = findActiveByVisibility(list);
@ -1492,6 +1575,53 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
}
return false;
}
function transformTrack(list){
if (!list || !list.length) return null;
var first = list[0];
var node = first && first.parentElement;
while (node && node !== document.body && node !== document.documentElement) {
try {
var directSlides = 0;
for (var i=0; i<node.children.length; i++) {
if (node.children[i].classList && node.children[i].classList.contains('slide')) directSlides += 1;
}
var style = window.getComputedStyle(node);
if (
directSlides >= list.length &&
(
node.style.transform ||
style.transform !== 'none' ||
/\\b(?:flex|grid)\\b/i.test(style.display)
)
) {
return node;
}
} catch (_) {}
node = node.parentElement;
}
return null;
}
function activeIndexFromTransform(list){
var track = transformTrack(list);
if (!track) return -1;
var raw = track.style.transform || '';
var match = raw.match(/translateX\\(\\s*(-?[0-9.]+)\\s*(vw|%)\\s*\\)/i);
if (!match) return -1;
var value = parseFloat(match[1]);
if (!Number.isFinite(value)) return -1;
return Math.max(0, Math.min(list.length - 1, Math.round(Math.abs(value) / 100)));
}
function transformGo(i){
var list = slides();
var track = transformTrack(list);
if (!track) return false;
var target = Math.max(0, Math.min(list.length - 1, i));
var unit = /translateX\\(\\s*-?[0-9.]+\\s*%\\s*\\)/i.test(track.style.transform || '') ? '%' : 'vw';
track.style.transform = 'translateX(' + (-target * 100) + unit + ')';
updateDeckChrome(target, list.length);
report();
return true;
}
function updateDeckChrome(i, count){
var cur = document.getElementById('deck-cur');
var total = document.getElementById('deck-total');
@ -1538,7 +1668,15 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
function scrollGo(i){
var list = slides();
var next = Math.max(0, Math.min(list.length - 1, i));
scroller().scrollTo({ left: next * window.innerWidth, behavior: 'smooth' });
var left = next * window.innerWidth;
var targets = scrollTargets();
for (var t=0; t<targets.length; t++) {
try {
targets[t].scrollTo({ left: left, behavior: 'smooth' });
} catch (_) {
try { targets[t].scrollLeft = left; } catch (__) {}
}
}
setTimeout(report, 380);
}
function targetFor(action, list){
@ -1558,6 +1696,7 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
return;
}
if (canSetActive(list) && setActive(target)) return;
if (transformGo(target)) return;
if (action === 'next') dispatchKey('ArrowRight');
else if (action === 'prev') dispatchKey('ArrowLeft');
else if (action === 'first') dispatchKey('Home');
@ -1570,6 +1709,7 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
var target = Math.max(0, Math.min(list.length - 1, i));
if (isScrollDeck()) { scrollGo(target); return; }
if (canSetActive(list) && setActive(target)) return;
if (transformGo(target)) return;
var current = activeIndex(list);
var diff = target - current;
if (!diff) { report(); return; }
@ -1583,6 +1723,7 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
var list = slides();
var i = activeIndex(list);
var count = list.length;
var progressWidth = count ? ((i + 1) / count * 100) + '%' : '0';
window.parent.postMessage({
type: 'od:slide-state',
active: i,
@ -1591,8 +1732,12 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
document.querySelectorAll('.slide-number').forEach(function(el){
el.setAttribute('data-current',i+1); el.setAttribute('data-total',count);
});
document.querySelectorAll('.progress-bar>span').forEach(function(el){
el.style.width=(count?((i+1)/count*100)+'%':'0');
document.querySelectorAll('.progress-bar>span,.deck-progress>span,.deck-progress .bar').forEach(function(el){
el.style.width=progressWidth;
});
document.querySelectorAll('.deck-progress').forEach(function(el){
if (el.querySelector('span,.bar')) return;
el.style.width=progressWidth;
});
} catch (e) {}
}

View file

@ -651,6 +651,10 @@
align-items: center;
gap: 4px;
}
.viewer-toolbar-zoom .zoom-trigger:focus-visible {
outline: none;
box-shadow: none;
}
.zoom-menu-popover {
position: absolute;
top: calc(100% + 6px);
@ -667,6 +671,11 @@
flex-direction: column;
gap: 1px;
}
.viewer-toolbar-zoom .zoom-menu-popover {
left: auto;
right: 0;
transform: none;
}
.zoom-menu-item {
display: flex;
align-items: center;

View file

@ -20,6 +20,12 @@
}
.viewer-toolbar-left { display: inline-flex; align-items: center; gap: 8px; }
.viewer-toolbar-actions { display: inline-flex; gap: 2px; align-items: center; }
.viewer-toolbar-tool-divider {
width: 1px;
height: 18px;
margin: 0 5px;
background: var(--border);
}
.viewer-toolbar .icon-only,
.viewer-toolbar-actions .icon-only {
width: 28px;
@ -141,6 +147,23 @@
height: 30px;
padding: 0;
}
.viewer-comment-count-trigger {
position: relative;
justify-content: center;
min-width: 42px;
height: 30px;
padding: 0 8px;
gap: 5px;
}
.viewer-comment-count {
min-width: 12px;
color: currentColor;
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
text-align: left;
}
.viewer-action-icon[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
@ -1070,32 +1093,36 @@
pointer-events: none;
}
.comment-saved-outline {
position: absolute;
inset: 0;
border: 1px dashed rgba(22, 119, 255, 0.72);
background: rgba(22, 119, 255, 0.08);
display: none;
}
.comment-saved-pin {
.comment-saved-pin,
.comment-active-pin {
position: absolute;
z-index: 2;
left: -8px;
top: -8px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border: 1px solid #0958d9;
border-radius: var(--radius-pill);
background: #1677ff;
left: 0;
top: 0;
width: 42px;
height: 42px;
padding: 0;
border: 3px solid #fff;
border-radius: 50% 50% 50% 10px;
background: #d96a46;
color: white;
font-size: 10px;
font-weight: 700;
line-height: 16px;
box-shadow: var(--shadow-sm);
font-size: 18px;
font-weight: 800;
line-height: 36px;
text-align: center;
box-shadow: 0 10px 22px rgba(33, 24, 18, 0.22);
pointer-events: auto;
transform: translate(-50%, -50%);
}
.comment-saved-pin:hover {
background: #0958d9;
transform: translateY(-1px);
.comment-saved-pin:hover,
.comment-active-pin:hover {
background: #c95e3e;
transform: translate(-50%, calc(-50% - 1px));
}
.comment-active-pin {
pointer-events: none;
}
.comment-popover {
position: absolute;
@ -1510,6 +1537,38 @@
background: #e94a2d;
border-color: #e94a2d;
}
.comment-side-new-comment {
flex: 0 0 auto;
border-top: 1px solid var(--border);
padding: 10px 12px;
background: var(--bg-panel, #fff);
}
.comment-side-new-comment .comment-side-new-comment-shell {
padding: 8px 10px 6px;
gap: 6px;
}
.comment-side-new-comment textarea {
min-height: 88px;
max-height: 160px;
}
.comment-side-new-comment .composer-row {
padding-top: 4px;
}
.comment-side-new-comment .composer-send {
min-width: 0;
max-width: 170px;
}
.comment-side-new-comment .composer-send.is-sending:disabled {
background: var(--text);
border-color: var(--text);
color: var(--bg);
opacity: 1;
}
.comment-side-new-comment .composer-send span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Anchor for the transient comment-save toast. Centered at the top
of the artifact preview so the confirmation is visible without

View file

@ -656,9 +656,15 @@
overflow: hidden;
z-index: 30;
}
.manual-edit-workspace > .manual-edit-right.manual-edit-floating {
right: auto;
bottom: auto;
overflow: visible;
pointer-events: auto;
}
/* Claude Code-style edit inspector panel. */
.cc-panel { display: flex; flex-direction: column; gap: 8px; padding: 8px 0; overflow: auto; background: var(--bg-panel); }
.cc-panel { display: flex; flex-direction: column; gap: 0; padding: 8px 0; overflow: hidden; background: var(--bg-panel); }
.cc-inspector { display: flex; flex-direction: column; gap: 12px; padding: 6px 12px; }
.cc-inspector-nav {
display: flex;
@ -920,22 +926,140 @@
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overflow-y: hidden;
overscroll-behavior: contain;
padding-bottom: 72px;
scrollbar-gutter: stable;
}
.manual-edit-titlebar {
.manual-edit-floating .manual-edit-modal.cc-panel {
height: 100%;
padding-bottom: 10px;
border-radius: 12px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
}
.manual-edit-scroll {
flex: 1 1 auto;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.manual-edit-footer {
flex: 0 0 auto;
padding: 8px 12px 0;
border-top: 1px solid var(--border);
background: var(--bg-panel);
}
.manual-edit-footer .cc-section {
margin: 0;
}
.manual-edit-footer .manual-edit-error {
margin: 8px 0 0;
}
.manual-edit-footer-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 10px;
min-height: 34px;
}
.manual-edit-footer-left,
.manual-edit-footer-right {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.manual-edit-footer-left {
flex: 1 1 auto;
}
.manual-edit-footer-right {
flex: 0 0 auto;
}
.manual-edit-delete-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 0;
border-radius: 8px;
padding: 0;
background: transparent;
color: var(--text);
}
.manual-edit-delete-btn:hover:not(:disabled) {
background: var(--bg-subtle);
}
.manual-edit-delete-btn:disabled,
.manual-edit-footer-btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.manual-edit-footer-btn {
min-width: 62px;
height: 30px;
border: 1px solid var(--border);
border-radius: 999px;
padding: 0 14px;
color: var(--text);
background: var(--bg-panel);
font: inherit;
font-size: 13px;
font-weight: 650;
}
.manual-edit-footer-btn:hover:not(:disabled) {
border-color: var(--border-strong);
background: var(--bg-subtle);
}
.manual-edit-footer-btn.primary {
border-color: transparent;
background: var(--text);
color: var(--bg-panel);
}
.manual-edit-footer-btn.primary:hover:not(:disabled) {
background: var(--text-muted);
}
.manual-edit-footer-btn.danger {
border-color: var(--red-border);
color: var(--red);
background: var(--red-bg);
}
.manual-edit-delete-confirm {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.manual-edit-delete-confirm span {
max-width: 170px;
overflow: hidden;
color: var(--text-muted);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.manual-edit-floating .manual-edit-titlebar {
min-height: 42px;
padding: 10px 12px 4px;
}
.manual-edit-floating .manual-edit-titlebar-close {
width: 30px;
height: 30px;
border-radius: 8px;
}
.manual-edit-titlebar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
min-height: 52px;
padding: 14px 18px 10px;
}
.manual-edit-titlebar > span {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: var(--text);
@ -946,6 +1070,39 @@
white-space: nowrap;
}
.manual-edit-drag-handle {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 28px;
border: 0;
border-radius: 6px;
padding: 0;
background: transparent;
color: var(--text-muted);
cursor: grab;
touch-action: none;
}
.manual-edit-drag-handle:active {
cursor: grabbing;
}
.manual-edit-drag-handle:hover {
background: var(--bg-subtle);
color: var(--text);
}
.manual-edit-drag-handle span {
width: 14px;
height: 18px;
background-image: radial-gradient(currentColor 1.5px, transparent 1.6px);
background-position: 0 0;
background-size: 7px 6px;
}
.manual-edit-titlebar-close {
flex: 0 0 auto;
width: 38px;

View file

@ -889,6 +889,23 @@
background: #ff2442;
color: white;
}
.share-menu-subitem {
padding-left: 18px;
}
.share-menu-section-label {
padding: 8px 10px 4px;
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0;
}
.share-menu-subsection-label {
padding: 6px 10px 2px 18px;
color: var(--text-muted);
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
}
.button-like {
display: inline-flex;

View file

@ -36,6 +36,21 @@ function clickAgentTool(testId: string) {
fireEvent.click(screen.getByTestId(testId));
}
async function hoverManualEditTarget(target = heroTarget()) {
const frame = await waitFor(() => {
const node = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
if (!node.contentWindow) throw new Error('Preview frame not ready');
return node;
});
act(() => {
window.dispatchEvent(new MessageEvent('message', {
data: { type: 'od-edit-hover', target },
source: frame.contentWindow,
}));
});
await waitFor(() => expect(panelState.props).not.toBeNull());
}
afterEach(() => {
cleanup();
panelState.props = null;
@ -78,7 +93,7 @@ describe('FileViewer manual edit history regressions', () => {
);
clickManualTool('manual-edit-mode-toggle');
await waitFor(() => expect(panelState.props).not.toBeNull());
await hoverManualEditTarget();
act(() => {
panelState.props?.onStyleChange?.('hero', { color: '#ef4444' }, 'Style: Hero');
@ -106,6 +121,35 @@ describe('FileViewer manual edit history regressions', () => {
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('true');
});
it('remounts the srcDoc iframe when closing manual edit on a srcDoc-only preview', async () => {
const source = '<!doctype html><html><body><script>localStorage.getItem("od");</script><main data-od-id="hero">Hero</main></body></html>';
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml={source}
/>,
);
clickManualTool('manual-edit-mode-toggle');
await hoverManualEditTarget();
const editFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(editFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
expect(editFrame.srcdoc).toContain('data-od-edit-bridge');
act(() => {
panelState.props?.onExit?.();
});
await waitFor(() => {
const previewFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(previewFrame).not.toBe(editFrame);
expect(previewFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
expect(previewFrame.srcdoc).toContain('Hero');
expect(previewFrame.srcdoc).not.toContain('data-od-edit-bridge');
});
});
it('uses the undone source snapshot for a follow-up edit after undo', async () => {
const initialSource = '<!doctype html><html><body><h1 data-od-id="hero" style="color: #111111">Hero</h1></body></html>';
let persistedSource = initialSource;
@ -141,7 +185,7 @@ describe('FileViewer manual edit history regressions', () => {
);
clickManualTool('manual-edit-mode-toggle');
await waitFor(() => expect(panelState.props).not.toBeNull());
await hoverManualEditTarget();
act(() => {
panelState.props?.onApplyPatch(
@ -203,7 +247,7 @@ describe('FileViewer manual edit history regressions', () => {
);
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await waitFor(() => expect(panelState.props).not.toBeNull());
await hoverManualEditTarget();
const getActivePreviewFrame = () => screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
await waitFor(() => {
@ -261,13 +305,10 @@ describe('FileViewer manual edit history regressions', () => {
);
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await waitFor(() => expect(panelState.props).not.toBeNull());
await hoverManualEditTarget();
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
const postMessageSpy = vi.spyOn(frame.contentWindow!, 'postMessage');
await act(async () => {
await panelState.props?.onSelectTarget(heroTarget());
});
await waitFor(() => expect(panelState.props?.selectedTarget?.id).toBe('hero'));
expect(panelState.props?.draft.text).toBe('Hero');
@ -282,8 +323,7 @@ describe('FileViewer manual edit history regressions', () => {
expect(savedSources[0]).not.toContain('data-od-id="hero"');
expect(savedSources[0]).toContain('data-od-id="body"');
await waitFor(() => expect(panelState.props?.selectedTarget).toBeNull());
expect(panelState.props?.draft.text).toBe('');
expect(panelState.props?.draft.fullSource).not.toContain('data-od-id="hero"');
expect(screen.getByTestId('mock-manual-edit-panel')).toBeTruthy();
expect(postMessageSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'od-edit-selected-target', id: null }),
'*',

View file

@ -6,6 +6,7 @@ import {
FileViewer,
cancelManualEditPendingStyleSnapshot,
} from '../../src/components/FileViewer';
import { emptyManualEditStyles, type ManualEditTarget } from '../../src/edit-mode/types';
import type { ProjectFile } from '../../src/types';
afterEach(() => {
@ -19,6 +20,33 @@ describe('FileViewer manual edit regressions', () => {
fireEvent.click(screen.getByTestId(testId));
}
async function hoverManualEditTarget(target = heroTarget()) {
const frame = await waitFor(() => {
const node = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
if (!node.contentWindow) throw new Error('Preview frame not ready');
return node;
});
act(() => {
window.dispatchEvent(new MessageEvent('message', {
data: { type: 'od-edit-hover', target },
source: frame.contentWindow,
}));
});
await waitFor(() => {
expect(document.querySelector('.manual-edit-right')).not.toBeNull();
});
}
async function findStyleInput(label: string) {
return waitFor(() => {
const input = Array.from(document.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes(label))
?.querySelector('input') as HTMLInputElement | null;
if (!input) throw new Error(`${label} input not found`);
return input;
});
}
it('removes invalid fields from pending manual edit style saves without dropping unrelated fields', () => {
expect(cancelManualEditPendingStyleSnapshot({
id: 'hero',
@ -48,8 +76,27 @@ describe('FileViewer manual edit regressions', () => {
expect(cancelManualEditPendingStyleSnapshot(otherTargetPending, 'cta', ['fontSize'])).toBe(otherTargetPending);
});
it('does not let a pending manual edit style save survive a file switch', () => {
vi.useFakeTimers();
it('opens the page edit panel before a target is hovered or selected', async () => {
const source = '<!doctype html><html><body><main data-od-id="hero">Hero</main></body></html>';
vi.stubGlobal('fetch', vi.fn(async () =>
new Response(source, { status: 200, headers: { 'Content-Type': 'text/html' } }),
));
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml={source}
/>,
);
clickManualTool('manual-edit-mode-toggle');
expect(document.querySelector('.manual-edit-right')).not.toBeNull();
expect(screen.getByText('PAGE')).toBeTruthy();
await hoverManualEditTarget();
expect(document.querySelector('.manual-edit-right')).not.toBeNull();
});
it('does not let a pending manual edit style save survive a file switch', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
@ -61,39 +108,29 @@ describe('FileViewer manual edit regressions', () => {
return new Response('<!doctype html><html><body></body></html>', { status: 200 });
});
vi.stubGlobal('fetch', fetchMock);
try {
const first = htmlPreviewFile();
const second = { ...htmlPreviewFile(), name: 'second.html', path: 'second.html' };
const { rerender } = render(
<FileViewer projectId="project-1" projectKind="prototype" file={first}
liveHtml='<!doctype html><html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
const first = htmlPreviewFile();
const second = { ...htmlPreviewFile(), name: 'second.html', path: 'second.html' };
const { rerender } = render(
<FileViewer projectId="project-1" projectKind="prototype" file={first}
liveHtml='<!doctype html><html><body><main data-od-id="hero">Hero</main></body></html>'
/>,
);
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
const baseSizeInput = Array.from(document.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Base size'))
?.querySelector('input') as HTMLInputElement | null;
if (!baseSizeInput) throw new Error('Base size input not found');
fireEvent.change(baseSizeInput, { target: { value: '18' } });
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
await hoverManualEditTarget();
const baseSizeInput = await findStyleInput('Size');
fireEvent.change(baseSizeInput, { target: { value: '18' } });
rerender(
<FileViewer projectId="project-1" projectKind="prototype" file={second}
liveHtml='<!doctype html><html><body><main data-od-id="second">Second</main></body></html>'
/>,
);
rerender(
<FileViewer projectId="project-1" projectKind="prototype" file={second}
liveHtml='<!doctype html><html><body><main data-od-id="second">Second</main></body></html>'
/>,
);
act(() => {
vi.advanceTimersByTime(1100);
});
expect(fetchMock).not.toHaveBeenCalledWith(
'/api/projects/project-1/files',
expect.objectContaining({ method: 'POST' }),
);
} finally {
vi.useRealTimers();
}
expect(fetchMock).not.toHaveBeenCalledWith(
'/api/projects/project-1/files',
expect.objectContaining({ method: 'POST' }),
);
});
it('clears loaded source immediately on file switch without liveHtml before manual edit can save', async () => {
@ -125,13 +162,8 @@ describe('FileViewer manual edit regressions', () => {
{},
));
fireEvent.click(screen.getByTestId('manual-edit-mode-toggle'));
const baseSizeInput = await waitFor(() => {
const input = Array.from(document.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Base size'))
?.querySelector('input') as HTMLInputElement | null;
if (!input) throw new Error('Base size input not found');
return input;
});
await hoverManualEditTarget();
const baseSizeInput = await findStyleInput('Size');
fireEvent.change(baseSizeInput, { target: { value: '18' } });
rerender(<FileViewer projectId="project-1" projectKind="prototype" file={second} />);
@ -181,26 +213,105 @@ describe('FileViewer manual edit regressions', () => {
);
clickManualTool('manual-edit-mode-toggle');
const baseSizeInput = await waitFor(() => {
const input = Array.from(document.querySelectorAll('.cc-row'))
.find((row) => row.textContent?.includes('Base size'))
?.querySelector('input') as HTMLInputElement | null;
if (!input) throw new Error('Base size input not found');
return input;
});
await hoverManualEditTarget();
const baseSizeInput = await findStyleInput('Size');
fireEvent.change(baseSizeInput, { target: { value: '18' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.getByText(/Could not save the edited file/)).toBeTruthy();
});
fireEvent.change(baseSizeInput, { target: { value: '19' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(screen.queryByText(/Could not save the edited file/)).toBeNull();
});
});
it('closes manual edit without saving when footer cancel is clicked', async () => {
const source = '<!doctype html><html><body><main data-od-id="hero">Hero</main></body></html>';
const fetchMock = vi.fn(async () =>
new Response(source, { status: 200, headers: { 'Content-Type': 'text/html' } }),
);
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml={source}
/>,
);
clickManualTool('manual-edit-mode-toggle');
await hoverManualEditTarget();
const baseSizeInput = await findStyleInput('Size');
fireEvent.change(baseSizeInput, { target: { value: '18' } });
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
expect(document.querySelector('.manual-edit-right')).toBeNull();
});
expect(fetchMock).not.toHaveBeenCalledWith(
'/api/projects/project-1/files',
expect.objectContaining({ method: 'POST' }),
);
});
it('closes manual edit after footer save succeeds', async () => {
const source = '<!doctype html><html><body><main data-od-id="hero">Hero</main></body></html>';
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);
if (url.includes('/api/projects/project-1/files') && init?.method === 'POST') {
return new Response(JSON.stringify({ file: htmlPreviewFile() }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(source, { status: 200, headers: { 'Content-Type': 'text/html' } });
});
vi.stubGlobal('fetch', fetchMock);
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml={source}
/>,
);
clickManualTool('manual-edit-mode-toggle');
await hoverManualEditTarget();
const baseSizeInput = await findStyleInput('Size');
fireEvent.change(baseSizeInput, { target: { value: '18' } });
fireEvent.click(screen.getByText('Save'));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(
'/api/projects/project-1/files',
expect.objectContaining({ method: 'POST' }),
);
expect(document.querySelector('.manual-edit-right')).toBeNull();
});
});
});
function heroTarget(): ManualEditTarget {
return {
id: 'hero',
kind: 'text',
label: 'Hero',
tagName: 'main',
className: '',
text: 'Hero',
rect: { x: 24, y: 24, width: 160, height: 48 },
fields: { text: 'Hero' },
attributes: { 'data-od-id': 'hero' },
styles: emptyManualEditStyles(),
isLayoutContainer: false,
outerHtml: '<main data-od-id="hero">Hero</main>',
};
}
function htmlPreviewFile(): ProjectFile {
return {
name: 'preview.html',

View file

@ -68,16 +68,6 @@ function deferredResponse() {
return { promise, resolve };
}
function srcDocActivationMessages(calls: readonly (readonly unknown[])[]) {
return calls
.map(([message]) => message)
.filter((message): message is { type: 'od:srcdoc-transport-activate'; html: string } => {
if (typeof message !== 'object' || message === null) return false;
const data = message as { type?: unknown; html?: unknown };
return data.type === 'od:srcdoc-transport-activate' && typeof data.html === 'string';
});
}
function clickAgentTool(testId: string) {
fireEvent.click(screen.getByTestId(testId));
}
@ -754,6 +744,7 @@ describe('FileViewer SVG artifacts', () => {
expect(markup).toContain('data-od-render-mode="srcdoc"');
expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="true"');
expect(markup).toContain('data-od-render-mode="url-load" data-od-active="false"');
expect(markup).not.toContain('data-od-lazy-srcdoc-transport');
expect(markup).toContain('sandbox="allow-scripts allow-downloads"');
});
@ -1314,8 +1305,8 @@ describe('FileViewer SVG artifacts', () => {
fireEvent.click(screen.getByRole('button', { name: /share/i }));
expect(await screen.findByRole('menuitem', { name: /Copy link · Vercel/i })).toBeTruthy();
const cloudflareCopy = await screen.findByRole('menuitem', { name: /Copy link · Cloudflare Pages/i });
expect(await screen.findByRole('menuitem', { name: /Copy Vercel link/i })).toBeTruthy();
const cloudflareCopy = await screen.findByRole('menuitem', { name: /Copy Cloudflare link/i });
fireEvent.click(cloudflareCopy);
expect(writeText).toHaveBeenCalledWith('https://cloudflare.pages.dev');
@ -1361,8 +1352,8 @@ describe('FileViewer SVG artifacts', () => {
fireEvent.click(screen.getByRole('button', { name: /share/i }));
expect(await screen.findByRole('menuitem', { name: /Copy link · Cloudflare Pages/i })).toBeTruthy();
expect(screen.queryByRole('menuitem', { name: /Copy link · Vercel/i })).toBeNull();
expect(await screen.findByRole('menuitem', { name: /Copy Cloudflare link/i })).toBeTruthy();
expect(screen.queryByRole('menuitem', { name: /Copy Vercel link/i })).toBeNull();
});
it('renders unsafe SVG source as escaped text instead of executable markup', () => {
@ -1447,6 +1438,8 @@ describe('FileViewer tweaks toolbar', () => {
const labels: Partial<Record<keyof Dict, string>> = {
'chat.tabComments': 'Comments',
'chat.comments.emptySaved': 'No saved comments.',
'chat.comments.targetText': 'Text',
'chat.comments.targetLink': 'Link',
'common.close': 'Close',
'preview.showSidebar': 'Show Comments',
'preview.hideSidebar': 'Hide Comments',
@ -1501,11 +1494,10 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByPlaceholderText('Add a note for this annotation')).toBeNull();
fireEvent.click(screen.getByTestId('screenshot-capture-toggle'));
expect(screen.getByPlaceholderText('Add a note for this annotation')).toBeTruthy();
expect(screen.queryByRole('status')).toBeNull();
expect(screen.getByTestId('screenshot-capture-toggle').getAttribute('aria-pressed')).toBe('true');
expect(screen.queryByPlaceholderText('Add a note for this annotation')).toBeNull();
expect(screen.getByRole('status').textContent).toContain('Copying screenshot');
expect(screen.getByTestId('screenshot-capture-toggle').getAttribute('aria-pressed')).toBe('false');
expect(screen.getByTestId('draw-overlay-toggle').getAttribute('aria-pressed')).toBe('false');
expect(screen.getByRole('button', { name: 'Send' })).toHaveProperty('disabled', false);
});
it('keeps the Draw bar open after queueing an annotation', () => {
@ -1527,7 +1519,7 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByPlaceholderText('Add a note for this annotation')).toBeNull();
});
it('keeps the preloaded selection bridge mounted while the Draw bar is open', async () => {
it('uses a materialized srcDoc bridge while the Draw bar is open', async () => {
render(
<FileViewer projectId="project-1" projectKind="prototype" file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
@ -1541,6 +1533,7 @@ describe('FileViewer tweaks toolbar', () => {
const activeFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
expect(activeFrame.getAttribute('data-od-render-mode')).toBe('srcdoc');
expect(activeFrame.srcdoc).toContain('data-od-selection-bridge');
expect(activeFrame.srcdoc).toContain('data-od-snapshot-bridge');
expect(activeFrame.srcdoc).not.toContain('data-od-lazy-srcdoc-transport');
return activeFrame;
});
@ -1622,6 +1615,57 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByText('Already sent to Claude')).toBeNull();
});
it('shows the open comment count beside the comments icon', () => {
const openComment: PreviewComment = {
id: 'comment-open',
projectId: 'project-1',
conversationId: 'conversation-1',
filePath: 'preview.html',
elementId: 'pin-open',
selector: '[data-od-pin="pin-open"]',
label: 'pin-open',
text: '',
htmlHint: '',
position: { x: 24, y: 32, width: 18, height: 18 },
note: 'Open comment',
status: 'open',
createdAt: Date.now(),
updatedAt: Date.now(),
};
const otherFileComment: PreviewComment = {
...openComment,
id: 'comment-other',
filePath: 'other.html',
};
const resolvedComment: PreviewComment = {
...openComment,
id: 'comment-resolved',
status: 'applying',
};
render(
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
previewComments={[openComment, otherFileComment, resolvedComment]}
/>,
);
const commentsButton = screen.getByTestId('comment-panel-toggle');
expect(commentsButton.textContent).toContain('1');
expect(commentsButton.getAttribute('aria-label')).toBe('Comments (1)');
expect(
screen.getByTestId('board-mode-toggle').compareDocumentPosition(screen.getByTestId('manual-edit-mode-toggle')) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
screen.getByTestId('screenshot-capture-toggle').compareDocumentPosition(commentsButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it('keeps comments and annotation picker mutually exclusive', () => {
const { container } = render(
<FileViewer
@ -2078,6 +2122,45 @@ describe('FileViewer tweaks toolbar', () => {
expect(screen.queryByTestId('comment-side-collapsed-rail')).toBeNull();
});
it('does not classify text labels containing a standalone article as links', () => {
const comment: PreviewComment = {
id: 'comment-plain-text',
projectId: 'project-1',
conversationId: 'conversation-1',
filePath: 'preview.html',
elementId: 'copy-1',
selector: '[data-od-id="copy-1"]',
label: 'Turn a brand brief into an editorial collage system.',
text: 'Turn a brand brief into an editorial collage system.',
htmlHint: '<p data-od-id="copy-1">',
position: { x: 16, y: 24, width: 320, height: 48 },
note: 'Make this copy tighter.',
status: 'open',
createdAt: Date.now(),
updatedAt: Date.now(),
};
render(
<CommentSidePanel
comments={[comment]}
selectedIds={new Set()}
activeCommentId={null}
collapsed={false}
onCollapsedChange={() => {}}
onClose={() => {}}
onToggleSelect={() => {}}
onClearSelection={() => {}}
onReply={() => {}}
onSendSelected={() => {}}
sending={false}
t={t}
/>,
);
expect(screen.getByText('1. Text')).toBeTruthy();
expect(screen.queryByText('Link')).toBeNull();
});
it('deletes selected comments when clear is clicked', async () => {
const removed: string[] = [];

View file

@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import type { CSSProperties } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Simulate } from 'react-dom/test-utils';
import { JSDOM } from 'jsdom';
@ -27,6 +28,8 @@ type OnInvalidStyle = (id: string, keys: Array<keyof ManualEditStyles>) => void;
type OnApplyPatch = (patch: ManualEditPatch, label: string) => void;
type OnError = (message: string) => void;
type OnClearSelection = () => void;
type OnSaveDraft = () => void;
type OnCancelDraft = () => void;
describe('ManualEditPanel', () => {
let dom: JSDOM;
@ -59,6 +62,30 @@ describe('ManualEditPanel', () => {
expect(host.textContent).not.toContain('Advanced');
});
it('shows a readable selected element name in the titlebar', () => {
renderPanel({
selectedTarget: {
...target,
id: 'path-0-0',
kind: 'container',
label: 'div.container.hero-split',
className: 'container hero-split',
text: 'Turn a brand brief into an editorial collage system.',
attributes: { 'data-od-source-path': 'path-0-0' },
},
});
expect(host.querySelector('.manual-edit-titlebar')?.textContent).toContain('Hero split');
expect(host.querySelector('.manual-edit-titlebar')?.textContent).not.toContain('div.container');
});
it('shows a drag handle for floating edit panels', () => {
renderPanel({ floatingStyle: { left: 20, top: 24, width: 320, height: 380 } });
expect(host.querySelector('.manual-edit-drag-handle')).not.toBeNull();
expect(host.querySelector('.manual-edit-drag-handle')?.getAttribute('aria-label')).toBe('Move edit panel');
});
it('allows returning from an element inspector to the page inspector', () => {
const onClearSelection = vi.fn();
renderPanel({ onClearSelection });
@ -73,6 +100,39 @@ describe('ManualEditPanel', () => {
expect(onClearSelection).toHaveBeenCalledTimes(1);
});
it('keeps inspector controls scrollable separately from footer actions', () => {
renderPanel();
const scrollRegion = host.querySelector('.manual-edit-scroll');
const footer = host.querySelector('.manual-edit-footer');
const deleteButton = host.querySelector('button[aria-label="Delete element"]');
expect(scrollRegion?.textContent).toContain('TYPOGRAPHY');
expect(scrollRegion?.contains(deleteButton)).toBe(false);
expect(footer?.contains(deleteButton)).toBe(true);
expect(footer?.textContent).toContain('Cancel');
expect(footer?.textContent).toContain('Save');
});
it('routes footer cancel and save actions', () => {
const onCancelDraft = vi.fn<OnCancelDraft>();
const onSaveDraft = vi.fn<OnSaveDraft>();
renderPanel({ onCancelDraft, onSaveDraft });
const footerButtons = Array.from(host.querySelectorAll('.manual-edit-footer button'));
const cancel = footerButtons.find((button) => button.textContent === 'Cancel') as HTMLButtonElement | undefined;
const save = footerButtons.find((button) => button.textContent === 'Save') as HTMLButtonElement | undefined;
if (!cancel || !save) throw new Error('Footer action buttons not found');
act(() => {
cancel.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
save.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onCancelDraft).toHaveBeenCalledTimes(1);
expect(onSaveDraft).toHaveBeenCalledTimes(1);
});
it('normalizes font stacks and writes a usable font-family value', () => {
const onDraftChange = vi.fn();
const onStyleChange = vi.fn();
@ -124,32 +184,34 @@ describe('ManualEditPanel', () => {
expect(sizeInput.value).toBe('32');
});
it('increments normal rows and quad cells with normalized values', () => {
it('increments text typography rows with normalized values', () => {
const onStyleChange = vi.fn();
renderPanel({
onStyleChange,
styles: {
...emptyManualEditStyles(),
fontSize: '32px',
opacity: '0.5',
paddingTop: '8px',
lineHeight: '1.4',
letterSpacing: '1px',
},
});
const sizeIncrease = host.querySelector('button[aria-label="Size increase"]') as HTMLButtonElement | null;
const opacityIncrease = host.querySelector('button[aria-label="Opacity increase"]') as HTMLButtonElement | null;
const paddingTopDecrease = host.querySelector('.cc-quad button[aria-label="T decrease"]') as HTMLButtonElement | null;
if (!sizeIncrease || !opacityIncrease || !paddingTopDecrease) throw new Error('Stepper button not found');
const lineIncrease = host.querySelector('button[aria-label="Line increase"]') as HTMLButtonElement | null;
const trackingDecrease = host.querySelector('button[aria-label="Tracking decrease"]') as HTMLButtonElement | null;
if (!sizeIncrease || !lineIncrease || !trackingDecrease) throw new Error('Stepper button not found');
act(() => {
sizeIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
opacityIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
paddingTopDecrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
lineIncrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
trackingDecrease.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));
});
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { fontSize: '33px' }, 'Style: Hero Title');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { opacity: '0.6' }, 'Style: Hero Title');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { paddingTop: '7px' }, 'Style: Hero Title');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { lineHeight: '1.5' }, 'Style: Hero Title');
expect(onStyleChange).toHaveBeenCalledWith('hero-title', { letterSpacing: '0px' }, 'Style: Hero Title');
expect(host.textContent).not.toContain('Opacity');
expect(host.textContent).not.toContain('Padding');
});
it('does not persist an unchanged target style when the inspector opens', () => {
@ -359,7 +421,7 @@ describe('ManualEditPanel', () => {
);
});
it('renders layout as inactive for non-layout single targets', () => {
it('hides layout controls for non-layout single targets', () => {
const onStyleChange = vi.fn();
renderPanel({
onStyleChange,
@ -370,15 +432,10 @@ describe('ManualEditPanel', () => {
},
});
const layoutSection = sectionByTitle('LAYOUT');
expect(layoutSection.classList.contains('cc-section-inactive')).toBe(true);
expect(layoutSection.textContent).toContain('Select a container or group to edit layout.');
const gapInput = layoutSection.querySelector('input') as HTMLInputElement | null;
const directionSelect = layoutSection.querySelector('select') as HTMLSelectElement | null;
if (!gapInput || !directionSelect) throw new Error('Layout controls not found');
expect(gapInput.disabled).toBe(true);
expect(directionSelect.disabled).toBe(true);
const layoutSection = Array.from(host.querySelectorAll('.cc-section')).find((section) => (
section.textContent?.includes('LAYOUT')
));
expect(layoutSection).toBeUndefined();
expect(normalizeManualEditStyles({ gap: '12', flexDirection: 'column' }, { layoutEnabled: false })).toEqual({
ok: true,
styles: {},
@ -441,10 +498,14 @@ describe('ManualEditPanel', () => {
onStyleChange = vi.fn<OnStyleChange>(),
onInvalidStyle = vi.fn<OnInvalidStyle>(),
onClearSelection = vi.fn<OnClearSelection>(),
onCancelDraft = vi.fn<OnCancelDraft>(),
onSaveDraft = vi.fn<OnSaveDraft>(),
attributesText = '{}',
selectedTarget = target,
styles = emptyManualEditStyles(),
pageStylesEnabled = true,
floatingStyle,
onFloatingPositionChange,
}: {
onDraftChange?: OnDraftChange;
onApplyPatch?: OnApplyPatch;
@ -452,10 +513,14 @@ describe('ManualEditPanel', () => {
onStyleChange?: OnStyleChange;
onInvalidStyle?: OnInvalidStyle;
onClearSelection?: OnClearSelection;
onCancelDraft?: OnCancelDraft;
onSaveDraft?: OnSaveDraft;
attributesText?: string;
selectedTarget?: ManualEditTarget | null;
styles?: ReturnType<typeof emptyManualEditStyles>;
pageStylesEnabled?: boolean;
floatingStyle?: CSSProperties;
onFloatingPositionChange?: (position: { left: number; top: number }) => void;
} = {}) {
const draft = {
...emptyManualEditDraft('<html></html>'),
@ -482,9 +547,12 @@ describe('ManualEditPanel', () => {
onApplyPatch={onApplyPatch}
onError={onError}
onClearSelection={onClearSelection}
onCancelDraft={vi.fn<() => void>()}
onCancelDraft={onCancelDraft}
onSaveDraft={onSaveDraft}
onUndo={vi.fn<() => void>()}
onRedo={vi.fn<() => void>()}
floatingStyle={floatingStyle}
onFloatingPositionChange={onFloatingPositionChange}
/>,
);
});

View file

@ -118,7 +118,7 @@ describe('PreviewDrawOverlay', () => {
</PreviewDrawOverlay>,
);
fireEvent.click(getByRole('button', { name: 'Close draw toolbar' }));
fireEvent.click(getByRole('button', { name: 'Close' }));
expect(onActiveChange).toHaveBeenCalledWith(false);
});

View file

@ -259,7 +259,32 @@ describe('manual edit bridge target normalization', () => {
expect(bridge).toContain('targets.push(targetFrom(nodes[i], false))');
expect(bridge).toContain("target: targetFrom(el, true)");
expect(bridge).toContain('if (!isSourceMappable(nodes[i])) continue;');
expect(bridge).toContain('if (isPrimaryTarget(el)) return el;');
expect(bridge).toContain('return el;');
expect(bridge).not.toContain('if (isPrimaryTarget(el)) return el;');
});
it('prefers the deepest source-mapped child over an annotated group on hover', async () => {
const posts: Array<{ type?: string; target?: { id: string; label?: string } }> = [];
const dom = new JSDOM(
`<main>
<section data-od-id="hero-group">
<span data-od-source-path="path-0-0-0">Small label</span>
</section>
</main>${buildManualEditBridge(true)}`,
{ runScripts: 'dangerously', url: 'http://localhost' },
);
const span = dom.window.document.querySelector('span')!;
dom.window.parent.postMessage = ((message: unknown) => {
posts.push(message as { type?: string; target?: { id: string; label?: string } });
}) as typeof dom.window.parent.postMessage;
span.dispatchEvent(new dom.window.Event('pointerover', { bubbles: true }));
const hover = posts.find((message) => message.type === 'od-edit-hover');
expect(hover?.target?.id).toBe('path-0-0-0');
expect(hover?.target?.label).toBe('Small label');
dom.window.close();
});
it('acks live preview style patches by id and version', () => {

View file

@ -62,6 +62,12 @@ function lastSlideState(parentPostMessage: ReturnType<typeof vi.fn>) {
return messages.at(-1);
}
function postSlide(win: ReturnType<typeof setupDeckBridge>['win'], action: 'next' | 'prev') {
win.dispatchEvent(new win.window.MessageEvent('message', {
data: { type: 'od:slide', action },
}));
}
describe('deck bridge — nested slide markup (#1530)', () => {
it('counts nested .slide elements through a fallback when no structured container matches', async () => {
// 8 slides nested two levels deep — none of `.deck > .slide`,
@ -99,4 +105,97 @@ describe('deck bridge — nested slide markup (#1530)', () => {
expect(state).toBeDefined();
expect(state.count).toBe(3);
});
it('advances transform-track decks that do not expose active classes or scroll state', async () => {
const { win, parentPostMessage } = setupDeckBridge(`
<style>
html, body { margin: 0; overflow: hidden; }
#deck { display: flex; width: 300vw; transform: translateX(0); }
.slide { flex: 0 0 100vw; width: 100vw; height: 100vh; }
</style>
<div id="deck">
<section class="slide">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
</div>
`);
const deck = win.document.getElementById('deck') as HTMLElement;
postSlide(win, 'next');
await new Promise<void>((resolve) => win.setTimeout(resolve, 350));
expect(deck.style.transform).toBe('translateX(-100vw)');
const state = lastSlideState(parentPostMessage);
expect(state).toMatchObject({ active: 1, count: 3 });
});
it('scrolls documentElement when body looks horizontally scrollable in a sandboxed Simple Deck', async () => {
const { win, parentPostMessage } = setupDeckBridge(`
<style>
html, body { margin: 0; height: 100%; }
body { display: flex; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; }
.slide { flex: 0 0 100vw; width: 100vw; height: 100vh; scroll-snap-align: start; }
</style>
<section class="slide">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
`);
Object.defineProperty(win, 'innerWidth', { configurable: true, value: 1000 });
Object.defineProperties(win.document.body, {
scrollWidth: { configurable: true, value: 3000 },
clientWidth: { configurable: true, value: 1000 },
});
Object.defineProperties(win.document.documentElement, {
scrollWidth: { configurable: true, value: 3000 },
clientWidth: { configurable: true, value: 1000 },
});
const bodyScrollTo = vi.fn();
const htmlScrollTo = vi.fn((options?: ScrollToOptions | number) => {
const left = typeof options === 'number' ? options : Number(options?.left || 0);
win.document.documentElement.scrollLeft = left;
});
win.document.body.scrollTo = bodyScrollTo;
win.document.documentElement.scrollTo = htmlScrollTo;
postSlide(win, 'next');
await new Promise<void>((resolve) => win.setTimeout(resolve, 450));
expect(bodyScrollTo).toHaveBeenCalledWith({ left: 1000, behavior: 'smooth' });
expect(htmlScrollTo).toHaveBeenCalledWith({ left: 1000, behavior: 'smooth' });
expect(lastSlideState(parentPostMessage)).toMatchObject({ active: 1, count: 3 });
});
it('updates Simple Deck direct progress fill when host navigation drives the slide', async () => {
const { win } = setupDeckBridge(`
<style>
html, body { margin: 0; height: 100%; }
body { display: flex; overflow-x: auto; overflow-y: hidden; scroll-snap-type: x mandatory; }
.slide { flex: 0 0 100vw; width: 100vw; height: 100vh; scroll-snap-align: start; }
.deck-progress { position: fixed; top: 0; left: 0; height: 3px; width: 0; }
</style>
<section class="slide">One</section>
<section class="slide">Two</section>
<section class="slide">Three</section>
<div class="deck-progress" id="deck-progress" aria-hidden></div>
`);
Object.defineProperty(win, 'innerWidth', { configurable: true, value: 1000 });
Object.defineProperties(win.document.body, {
scrollWidth: { configurable: true, value: 3000 },
clientWidth: { configurable: true, value: 1000 },
});
Object.defineProperties(win.document.documentElement, {
scrollWidth: { configurable: true, value: 3000 },
clientWidth: { configurable: true, value: 1000 },
});
win.document.body.scrollTo = vi.fn();
win.document.documentElement.scrollTo = vi.fn((options?: ScrollToOptions | number) => {
const left = typeof options === 'number' ? options : Number(options?.left || 0);
win.document.documentElement.scrollLeft = left;
});
postSlide(win, 'next');
await new Promise<void>((resolve) => win.setTimeout(resolve, 450));
expect((win.document.getElementById('deck-progress') as HTMLElement).style.width).toBe('66.66666666666666%');
});
});

View file

@ -37,6 +37,32 @@ describe('buildSrcdoc', () => {
expect(srcdoc).toContain('foreignObject');
});
it('renders snapshot SVGs through data URLs so canvas export stays origin-clean', () => {
const srcdoc = buildSrcdoc('<main style="color:red">Hero</main>');
expect(srcdoc).toContain('function encodedSvgDataUrl()');
expect(srcdoc).toContain('img.src = encodedSvgDataUrl();');
expect(srcdoc).not.toContain('createObjectURL');
expect(srcdoc).not.toContain('snapshot too large');
});
it('crops snapshots with an XHTML wrapper instead of moving foreignObject offscreen', () => {
const srcdoc = buildSrcdoc('<main style="color:red">Hero</main>');
expect(srcdoc).toContain('function scrollOffset()');
expect(srcdoc).toContain('left:\' + (-scroll.x) + \'px;top:\' + (-scroll.y) + \'px;');
expect(srcdoc).toContain('<foreignObject x="0" y="0"');
expect(srcdoc).not.toContain('<foreignObject x="\' + (-window.scrollX || 0)');
});
it('removes external stylesheet dependencies from snapshot clones before rasterizing', () => {
const srcdoc = buildSrcdoc('<link rel="stylesheet" href="https://fonts.example/app.css"><style>@import "https://fonts.example/css"; @font-face { font-family: Remote; src: url(remote.woff2); } main { color: red; }</style><main>Hero</main>');
expect(srcdoc).toContain('link[rel~="stylesheet"], link[rel~="preload"], link[rel~="preconnect"]');
expect(srcdoc).toContain('.replace(/@import[^;]+;/gi,');
expect(srcdoc).toContain('.replace(/@font-face\\s*\\{[^}]*\\}/gi,');
});
it('can guard preview iframes against load-time focus stealing', () => {
// This test would fail if injectPreviewFocusGuard were removed from
// buildSrcdoc — the guard script would be absent, and the assertions