mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
4abc08bb17
commit
831208b823
40 changed files with 2646 additions and 583 deletions
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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': 'انتظر انتهاء الدور الحالي.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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': 'منتظر پایان نوبت فعلی باشید.',
|
||||
|
|
|
|||
|
|
@ -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 n’a été trouvée.',
|
||||
'chat.inspect.editHint': 'Sélectionnez une cible de texte ou de style dans l’aperç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 l’aperç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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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...',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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': '現在のターンが終わるまでお待ちください。',
|
||||
|
|
|
|||
|
|
@ -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': '현재 작업이 끝날 때까지 기다려 주세요.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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': 'Дождитесь окончания текущего хода.',
|
||||
|
|
|
|||
|
|
@ -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': 'ต้องให้ทำระบบของปัจจุบันจนสำเร็จก่อน',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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': 'Чекайте, поки поточна черга завершиться.',
|
||||
|
|
|
|||
|
|
@ -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': '请等待当前任务完成。',
|
||||
|
|
|
|||
|
|
@ -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': '請等待當前任務完成。',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:')) {
|
||||
|
|
|
|||
|
|
@ -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, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
'*',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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%');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue