返回 oh-my-ppt
PreviewIframe.tsx
根目录 / src / renderer / src / components / preview / PreviewIframe.tsx
1 import { useCallback, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react'
2 import { nanoid } from 'nanoid'
3 import {
4 buildEditModeCleanupScript,
5 buildEditModeInjectScript,
6 buildEditModeSetPreviewScaleScript,
7 buildInspectorCleanupScript,
8 buildInspectorInjectScript,
9 buildPresentationEditorRuntimeInjectScript,
10 EDIT_MODE_CONSOLE_PREFIX,
11 INSPECTOR_CONSOLE_PREFIX,
12 type EditableElementSnapshot,
13 type EditModeMovePayload,
14 type EditSnapPoints,
15 type EditSnapSettings,
16 type EditTextTarget,
17 type EditSelectionPayload,
18 type PresentationEditorOperation,
19 type PresentationEditorOperationResult,
20 type PresentationElementSnapshot
21 } from '@arcsin1/presentation-editor-runtime'
22 import { ipc } from '@renderer/lib/ipc'
23 import { buildSelectedElementRuntimeContext } from '@renderer/lib/presentation-element-context'
24 import {
25 normalizeEditModeLayoutIsland,
26 type EditModeLayoutIsland
27 } from '@renderer/lib/presentation-layout-island'
28 import type { InteractionMode } from '@renderer/store'
29 import type { SelectedElementRuntimeContext } from '@shared/generation'
30 import { requireSlideSize, type SlideSizePreset } from '@shared/slide-size'
31 import type { InsertChartSeries } from '../session-detail/workspace/insert-charts'
32
33 const PAGE_LAYOUT_AUDIT_SCRIPT = `
34 (() => {
35 const root = document.querySelector('.ppt-page-root[data-ppt-guard-root="1"]') || document.querySelector('.ppt-page-root');
36 const content = root && (root.querySelector(':scope > .ppt-page-fit-scope > .ppt-page-content') || root.querySelector('.ppt-page-content'));
37 if (!(root instanceof HTMLElement) || !(content instanceof HTMLElement)) return '';
38
39 const rootRect = root.getBoundingClientRect();
40 if (rootRect.width <= 0 || rootRect.height <= 0) return '';
41 const round = (value) => Math.round(value);
42 const compact = (value, limit) => String(value || '').replace(/\\s+/g, ' ').trim().slice(0, limit);
43 const isVisible = (element, rect) => {
44 const style = getComputedStyle(element);
45 return style.display !== 'none' && style.visibility !== 'hidden' && Number(style.opacity || 1) > 0 && rect.width > 2 && rect.height > 2;
46 };
47 const directText = (element) => Array.from(element.childNodes)
48 .filter((node) => node.nodeType === Node.TEXT_NODE)
49 .map((node) => node.textContent || '')
50 .join(' ')
51 .replace(/\\s+/g, ' ')
52 .trim();
53 const describe = (element) => {
54 const classes = String(element.className || '').split(/\\s+/).filter(Boolean).slice(0, 2).join('.');
55 const text = compact(directText(element) || element.getAttribute('aria-label') || '', 42);
56 return '<' + element.tagName.toLowerCase() + (classes ? '.' + classes : '') + '>' + (text ? ' “' + text + '”' : '');
57 };
58 const rectInCanvas = (rect) => ({
59 x: round(rect.left - rootRect.left),
60 y: round(rect.top - rootRect.top),
61 width: round(rect.width),
62 height: round(rect.height)
63 });
64 const elements = Array.from(content.querySelectorAll('*')).filter((element) => {
65 if (!(element instanceof HTMLElement)) return false;
66 return isVisible(element, element.getBoundingClientRect());
67 });
68 const issues = [];
69 const seen = new Set();
70 const addIssue = (kind, element, detail) => {
71 const key = kind + ':' + detail;
72 if (seen.has(key) || issues.length >= 12) return;
73 seen.add(key);
74 issues.push('[' + kind + '] ' + describe(element) + ': ' + detail);
75 };
76 for (const element of elements) {
77 const rect = element.getBoundingClientRect();
78 const text = directText(element);
79 const isLeafText = Boolean(text) && element.children.length === 0;
80 const isMedia = /^(CANVAS|IMG|VIDEO|SVG)$/.test(element.tagName);
81 if (isLeafText || isMedia) {
82 const overflowLeft = rootRect.left - rect.left;
83 const overflowTop = rootRect.top - rect.top;
84 const overflowRight = rect.right - rootRect.right;
85 const overflowBottom = rect.bottom - rootRect.bottom;
86 const overflow = Math.max(overflowLeft, overflowTop, overflowRight, overflowBottom);
87 if (overflow > 2) {
88 addIssue('canvas-overflow', element, 'extends ' + round(overflow) + 'px beyond the canvas at ' + JSON.stringify(rectInCanvas(rect)));
89 }
90 }
91 if (isLeafText && (element.scrollWidth > element.clientWidth + 2 || element.scrollHeight > element.clientHeight + 2)) {
92 const horizontal = Math.max(0, element.scrollWidth - element.clientWidth);
93 const vertical = Math.max(0, element.scrollHeight - element.clientHeight);
94 addIssue('text-overflow', element, 'text needs ' + horizontal + 'px more width and ' + vertical + 'px more height');
95 }
96 const style = getComputedStyle(element);
97 const clipsX = style.overflowX !== 'visible';
98 const clipsY = style.overflowY !== 'visible';
99 if ((clipsX && element.scrollWidth > element.clientWidth + 2) || (clipsY && element.scrollHeight > element.clientHeight + 2)) {
100 const horizontal = Math.max(0, element.scrollWidth - element.clientWidth);
101 const vertical = Math.max(0, element.scrollHeight - element.clientHeight);
102 addIssue('scroll-overflow', element, 'clipped container has ' + horizontal + 'px horizontal and ' + vertical + 'px vertical overflow');
103 }
104 }
105 const regions = elements
106 .filter((element) => {
107 const style = getComputedStyle(element);
108 return /^(HEADER|FOOTER|SECTION|MAIN|ARTICLE|ASIDE)$/.test(element.tagName) || (element.children.length >= 2 && (style.display === 'grid' || style.display === 'flex'));
109 })
110 .slice(0, 10)
111 .map((element) => {
112 const rect = rectInCanvas(element.getBoundingClientRect());
113 return describe(element) + ' at x=' + rect.x + ', y=' + rect.y + ', w=' + rect.width + ', h=' + rect.height;
114 });
115 return [
116 'Canvas: ' + round(rootRect.width) + 'px x ' + round(rootRect.height) + 'px.',
117 regions.length ? 'Key regions:' : '',
118 ...regions.map((region) => '- ' + region),
119 issues.length ? 'Measured defects:' : 'Measured defects: none.',
120 ...issues.map((issue) => '- ' + issue)
121 ].filter(Boolean).join('\\n');
122 })()
123 `
124
125 export interface PreviewIframeHandle {
126 reloadIgnoringCache: () => void
127 patchPageContent: (pageId: string, newHtml: string) => void
128 liveUpdateElement: (
129 selector: string,
130 patch: {
131 html?: string
132 text?: string
133 textTarget?: EditTextTarget
134 formula?: {
135 latex: string
136 html: string
137 displayMode: boolean
138 originalLatex?: string
139 }
140 chart?: {
141 type: string
142 title: string
143 labels: string[]
144 values: number[]
145 series: InsertChartSeries[]
146 primaryColor: string
147 accentColor: string
148 textColor: string
149 smooth: boolean
150 horizontal: boolean
151 stacked: boolean
152 areaFill: boolean
153 showPoints: boolean
154 showLegend: boolean
155 doughnutCutout: number
156 radarFill: boolean
157 configJson: string
158 }
159 style?: { color?: string; fontSize?: string; fontWeight?: string; textAlign?: string }
160 }
161 ) => void
162 applyElementProperties: (
163 selector: string,
164 patch: {
165 style?: {
166 zIndex?: number
167 opacity?: number
168 backgroundColor?: string
169 color?: string
170 fontSize?: string
171 fontWeight?: string
172 textAlign?: string
173 objectFit?: string
174 }
175 attrs?: {
176 alt?: string
177 poster?: string
178 controls?: boolean
179 muted?: boolean
180 loop?: boolean
181 autoplay?: boolean
182 playsInline?: boolean
183 preload?: string
184 }
185 }
186 ) => void
187 setElementLayout: (
188 selector: string,
189 layout: { x?: number; y?: number; width?: number; height?: number }
190 ) => void
191 restoreEditModeSelection: (selector: string) => Promise<boolean>
192 restoreInspectorSelection: (selector: string) => Promise<boolean>
193 clearEditModeSelection: () => void
194 hideElement: (selector: string) => void
195 showElement: (selector: string) => void
196 applyDragStyle: (
197 selector: string,
198 style: {
199 x: number
200 y: number
201 width?: number
202 height?: number
203 isAbsoluteMode?: boolean
204 }
205 ) => void
206 applyLayoutIsland: (layoutIsland: EditModeLayoutIsland) => void
207 applyZIndex: (selector: string, zIndex: number) => void
208 copyElement: (
209 selector: string,
210 newBlockId: string
211 ) => Promise<{ selector: string; htmlFragment: string } | null>
212 readElementHtml: (selector: string) => Promise<string>
213 readElementSnapshot: (selector: string) => Promise<EditableElementSnapshot | null>
214 inspectElement: (selector: string) => Promise<PresentationElementSnapshot | null>
215 applyElementOperations: (
216 selector: string,
217 operations: PresentationEditorOperation[]
218 ) => Promise<PresentationEditorOperationResult[]>
219 readElementLayout: (
220 selector: string
221 ) => Promise<{
222 isAbsoluteMode: boolean
223 x: number
224 y: number
225 width: number
226 height: number
227 visualX?: number
228 visualY?: number
229 layoutIsland?: EditModeLayoutIsland
230 } | null>
231 applyChildUpdates: (
232 selector: string,
233 childUpdates: Array<{ path: number[]; width?: number; height?: number }>
234 ) => void
235 injectElement: (
236 parentSelector: string,
237 htmlFragment: string,
238 insertIndex?: number,
239 selectAfterInsert?: boolean
240 ) => void
241 setEditSnapSettings: (settings: EditSnapSettings) => Promise<boolean>
242 readEditSnapPoints: () => Promise<EditSnapPoints>
243 readPageLayoutAudit: () => Promise<string | null>
244 }
245
246 export function isCurrentInspectorSelectionRequest(args: {
247 requestId: number
248 latestRequestId: number
249 isInspectorActive: boolean
250 selectionInteractionMode: InteractionMode
251 currentInteractionMode: InteractionMode
252 }): boolean {
253 return (
254 args.isInspectorActive &&
255 args.requestId === args.latestRequestId &&
256 args.selectionInteractionMode === args.currentInteractionMode
257 )
258 }
259
260 export const PreviewIframe = forwardRef<
261 PreviewIframeHandle,
262 {
263 html?: string
264 src?: string
265 title: string
266 htmlPath?: string
267 pageId?: string
268 inspecting?: boolean
269 inspectable?: boolean
270 editMode?: boolean
271 thumbnail?: boolean
272 interactionMode?: InteractionMode
273 slideSize: SlideSizePreset
274 onSelectorSelected?: (
275 selector: string,
276 label: string,
277 elementTag?: string,
278 elementText?: string,
279 selectedElementContext?: SelectedElementRuntimeContext | null
280 ) => void
281 onElementMoved?: (payload: EditModeMovePayload) => void
282 onElementSelected?: (payload: EditSelectionPayload) => void
283 onInspectExit?: () => void
284 onDidReload?: () => void
285 onDeleteRequest?: (selector: string) => void
286 }
287 >(function PreviewIframe(
288 {
289 src,
290 title,
291 htmlPath,
292 pageId,
293 inspecting = false,
294 inspectable = false,
295 editMode = false,
296 thumbnail = false,
297 interactionMode,
298 slideSize: slideSizeInput,
299 onSelectorSelected,
300 onElementMoved,
301 onElementSelected,
302 onInspectExit,
303 onDidReload,
304 onDeleteRequest
305 },
306 ref
307 ) {
308 const slideSize = requireSlideSize(slideSizeInput)
309 const containerRef = useRef<HTMLDivElement | null>(null)
310 const webviewRef = useRef<Electron.WebviewTag | null>(null)
311 const webviewReadyRef = useRef(false)
312 const inspectorInjectedRef = useRef(false)
313 const editModeInjectedRef = useRef(false)
314 const inspectorSelectionRequestRef = useRef(0)
315 const inspectorActiveRef = useRef(inspecting)
316 const previewScaleRef = useRef(1)
317 const [webviewElement, setWebviewElement] = useState<Electron.WebviewTag | null>(null)
318 const [webviewReady, setWebviewReady] = useState(false)
319 const [transform, setTransform] = useState('scale(1)')
320 const [previewScale, setPreviewScale] = useState(1)
321
322 useEffect(() => {
323 previewScaleRef.current = previewScale
324 }, [previewScale])
325
326 const resolvePageHtmlPath = (inputPath?: string, currentPageId?: string): string | undefined => {
327 if (!inputPath) return undefined
328 const isIndex = /[\\/]index\.html?$/i.test(inputPath)
329 if (!isIndex) return inputPath
330 if (!currentPageId) return undefined
331 return inputPath.replace(/index\.html?$/i, `${currentPageId}.html`)
332 }
333
334 const encodePathSegments = (filePath: string): string =>
335 filePath
336 .split('/')
337 .map((segment) => encodeURIComponent(segment))
338 .join('/')
339
340 const applyPreviewUrlParams = (inputUrl: string): string => {
341 const url = new URL(inputUrl)
342 // PreviewIframe already scales the logical slide canvas into its viewport.
343 // Disable page-level auto-fit to avoid double-scaling on specific pages.
344 url.searchParams.set('fit', 'off')
345 // Preview surfaces are static. Only the full-screen presentation URL enables motion.
346 url.searchParams.set('print', '1')
347 url.searchParams.set('pptPlayback', '0')
348 if (thumbnail) {
349 url.searchParams.set('thumbnail', '1')
350 if (pageId) url.searchParams.set('pageId', pageId)
351 }
352 return url.toString()
353 }
354
355 const toFileUrl = (absolutePath: string): string => {
356 const normalizedPath = absolutePath.replace(/\\/g, '/')
357 const fileUrl = /^[a-zA-Z]:\//.test(normalizedPath)
358 ? `file:///${normalizedPath.slice(0, 2)}${encodePathSegments(normalizedPath.slice(2))}`
359 : normalizedPath.startsWith('/')
360 ? `file://${encodePathSegments(normalizedPath)}`
361 : `file:///${encodePathSegments(normalizedPath)}`
362 return applyPreviewUrlParams(fileUrl)
363 }
364
365 const withPreviewParams = (inputUrl: string): string => {
366 return applyPreviewUrlParams(inputUrl)
367 }
368
369 // Always preview concrete page file (<pageId>.html). index.html is only for external full-deck preview.
370 const pageHtmlPath = resolvePageHtmlPath(htmlPath, pageId)
371 const webviewSrc = pageHtmlPath
372 ? toFileUrl(pageHtmlPath)
373 : src
374 ? withPreviewParams(src)
375 : undefined
376 const currentInteractionMode: InteractionMode =
377 interactionMode || (editMode ? 'edit' : inspecting ? 'ai-inspect' : 'preview')
378 const inspectorInteractionModeRef = useRef(currentInteractionMode)
379 inspectorActiveRef.current = inspecting
380 inspectorInteractionModeRef.current = currentInteractionMode
381 const pointerEnabled = inspectable
382
383 const ensureAnchoredAnchor = async (args: {
384 selector: string
385 elementTag?: string
386 elementText?: string
387 reason: 'inspect' | 'drag' | 'text-edit'
388 formula?: EditableElementSnapshot['formula']
389 }): Promise<{ selector: string; blockId?: string }> => {
390 if (!pageHtmlPath || !pageId) {
391 throw new Error('Cannot anchor element without page path and page id')
392 }
393 const existingBlockId = args.selector.match(/\[data-block-id="([^"]+)"\]/)?.[1]
394 if (existingBlockId) return { selector: args.selector, blockId: existingBlockId }
395 try {
396 const result = await ipc.ensureElementAnchor({
397 htmlPath: pageHtmlPath,
398 pageId,
399 selector: args.selector,
400 elementTag: args.elementTag,
401 elementText: args.elementText,
402 reason: args.reason,
403 formula: args.formula
404 })
405 if (result.changed && result.blockId) {
406 const webview = webviewRef.current
407 if (webview) {
408 safeExecuteJavaScript(
409 webview,
410 `(() => {
411 var __selector = ${JSON.stringify(args.selector)};
412 var __blockId = ${JSON.stringify(result.blockId)};
413 var __latex = ${JSON.stringify(args.formula?.latex || '')};
414 var __normalize = function(value) { return String(value || '').replace(/\\s+/g, ' ').trim(); };
415 var __nodes = [];
416 try { __nodes = Array.prototype.slice.call(document.querySelectorAll(__selector)); } catch (_error) {}
417 var __el = __nodes.length === 1 ? __nodes[0] : null;
418 if (!__el && __latex) {
419 var __formulaNodes = Array.prototype.slice.call(document.querySelectorAll('.katex'));
420 var __matches = __formulaNodes.filter(function(node) {
421 if (!(node instanceof Element) || node.getAttribute('data-block-id')) return false;
422 var annotation = node.querySelector('annotation[encoding="application/x-tex"]');
423 var latex = node.getAttribute('data-ppt-formula-latex') || (annotation ? annotation.textContent : '');
424 return __normalize(latex) === __normalize(__latex);
425 });
426 if (__matches.length === 1) __el = __matches[0];
427 }
428 if (__el instanceof Element) {
429 var __target = __el.classList.contains('katex-display') && !__el.classList.contains('katex')
430 ? (__el.querySelector('.katex') || __el)
431 : __el;
432 if (!__target.getAttribute('data-block-id')) __target.setAttribute('data-block-id', __blockId);
433 }
434 })();`
435 )
436 }
437 }
438 return { selector: result.selector || args.selector, blockId: result.blockId }
439 } catch {
440 throw new Error('Failed to anchor selected element')
441 }
442 }
443
444 const handleWebviewRef = useCallback((node: Electron.WebviewTag | null): void => {
445 inspectorSelectionRequestRef.current += 1
446 webviewReadyRef.current = false
447 inspectorInjectedRef.current = false
448 editModeInjectedRef.current = false
449 setWebviewReady(false)
450 webviewRef.current = node
451 setWebviewElement((prev) => (prev === node ? prev : node))
452 }, [])
453
454 const canExecuteJavaScript = (webview: Electron.WebviewTag): boolean => {
455 return webview.isConnected && webviewRef.current === webview && webviewReadyRef.current
456 }
457
458 const inspectPresentationElement = async (
459 webview: Electron.WebviewTag,
460 selector: string
461 ): Promise<PresentationElementSnapshot | null> => {
462 if (!canExecuteJavaScript(webview)) return null
463 try {
464 const result = await webview.executeJavaScript(
465 `(function(){` +
466 `var __el = document.querySelector(${JSON.stringify(selector)});` +
467 `if (!__el) return null;` +
468 `if (window.__pptEditModeInspectElement) return window.__pptEditModeInspectElement(${JSON.stringify(selector)});` +
469 `return window.__pptPresentationEditorRuntime ? window.__pptPresentationEditorRuntime.inspect(__el) : null;` +
470 `})()`
471 )
472 return (result as PresentationElementSnapshot | null) || null
473 } catch {
474 return null
475 }
476 }
477
478 const wrapSafeVoidScript = (label: string, script: string): string => `
479 (() => {
480 try {
481 ${script}
482 } catch (error) {
483 const message = error && (error.stack || error.message || String(error));
484 console.error("[PreviewIframe:${label}]", message || "Unknown script error");
485 }
486 })();
487 `
488
489 const safeExecuteJavaScript = (webview: Electron.WebviewTag, script: string): void => {
490 if (!canExecuteJavaScript(webview)) return
491 try {
492 webview.executeJavaScript(wrapSafeVoidScript('void', script)).catch(() => {})
493 } catch {
494 // executeJavaScript may throw synchronously before dom-ready
495 }
496 }
497
498 const safeExecuteHostScript = (
499 webview: Electron.WebviewTag,
500 label: string,
501 script: string
502 ): void => {
503 if (!canExecuteJavaScript(webview)) return
504 try {
505 webview.executeJavaScript(wrapSafeVoidScript(label, script)).catch(() => {})
506 } catch {
507 // executeJavaScript may throw synchronously before dom-ready
508 }
509 }
510
511 useImperativeHandle(
512 ref,
513 () => ({
514 reloadIgnoringCache(): void {
515 const wv = webviewRef.current
516 if (!wv) return
517 try {
518 wv.reloadIgnoringCache()
519 } catch {
520 // The webview can be detached while the session route changes.
521 }
522 },
523 patchPageContent(targetPageId: string, newHtml: string): void {
524 const wv = webviewRef.current
525 if (!wv) return
526 safeExecuteJavaScript(
527 wv,
528 `
529 var section = document.querySelector('[data-page-id="${targetPageId}"]');
530 if (section) {
531 section.innerHTML = ${JSON.stringify(newHtml)};
532 } else {
533 document.body.innerHTML = ${JSON.stringify(newHtml)};
534 }
535 `
536 )
537 },
538 liveUpdateElement(
539 selector: string,
540 patch: {
541 html?: string
542 text?: string
543 textTarget?: EditTextTarget
544 formula?: {
545 latex: string
546 html: string
547 displayMode: boolean
548 originalLatex?: string
549 }
550 chart?: {
551 type: string
552 title: string
553 labels: string[]
554 values: number[]
555 series: InsertChartSeries[]
556 primaryColor: string
557 accentColor: string
558 textColor: string
559 smooth: boolean
560 horizontal: boolean
561 stacked: boolean
562 areaFill: boolean
563 showPoints: boolean
564 showLegend: boolean
565 doughnutCutout: number
566 radarFill: boolean
567 configJson: string
568 }
569 style?: { color?: string; fontSize?: string; fontWeight?: string; textAlign?: string }
570 zIndex?: number
571 }
572 ): void {
573 const wv = webviewRef.current
574 if (!wv) return
575 safeExecuteJavaScript(
576 wv,
577 `if (window.__pptEditModeLiveUpdate) window.__pptEditModeLiveUpdate(${JSON.stringify(selector)}, ${JSON.stringify(patch)});`
578 )
579 },
580 applyElementProperties(
581 selector: string,
582 patch: {
583 style?: {
584 zIndex?: number
585 opacity?: number
586 backgroundColor?: string
587 color?: string
588 fontSize?: string
589 fontWeight?: string
590 textAlign?: string
591 objectFit?: string
592 }
593 attrs?: {
594 alt?: string
595 poster?: string
596 controls?: boolean
597 muted?: boolean
598 loop?: boolean
599 autoplay?: boolean
600 playsInline?: boolean
601 preload?: string
602 }
603 }
604 ): void {
605 const wv = webviewRef.current
606 if (!wv) return
607 safeExecuteJavaScript(
608 wv,
609 `if (window.__pptEditModeApplyProperties) window.__pptEditModeApplyProperties(${JSON.stringify(selector)}, ${JSON.stringify(patch)});`
610 )
611 },
612 setElementLayout(
613 selector: string,
614 layout: { x?: number; y?: number; width?: number; height?: number }
615 ): void {
616 const wv = webviewRef.current
617 if (!wv) return
618 safeExecuteJavaScript(
619 wv,
620 `if (window.__pptEditModeSetLayout) window.__pptEditModeSetLayout(${JSON.stringify(selector)}, ${JSON.stringify(layout)});`
621 )
622 },
623 async setEditSnapSettings(settings: EditSnapSettings): Promise<boolean> {
624 const wv = webviewRef.current
625 if (!wv || !canExecuteJavaScript(wv)) return false
626 try {
627 return Boolean(
628 await wv.executeJavaScript(
629 `(function(){` +
630 `if (!window.__pptEditModeSetSnapSettings) return false;` +
631 `window.__pptEditModeSetSnapSettings(${JSON.stringify(settings)});` +
632 `return true;` +
633 `})()`
634 )
635 )
636 } catch {
637 return false
638 }
639 },
640 async readEditSnapPoints(): Promise<EditSnapPoints> {
641 const wv = webviewRef.current
642 if (!wv || !canExecuteJavaScript(wv)) return { x: [], y: [] }
643 try {
644 const result = (await wv.executeJavaScript(
645 `(function(){` +
646 `try {` +
647 `return window.__pptEditModeReadSnapPoints ? window.__pptEditModeReadSnapPoints() : { x: [], y: [] };` +
648 `} catch (_error) { return { x: [], y: [] }; }` +
649 `})()`
650 )) as Partial<EditSnapPoints> | null
651 return {
652 x: Array.isArray(result?.x) ? result.x.filter(Number.isFinite) : [],
653 y: Array.isArray(result?.y) ? result.y.filter(Number.isFinite) : []
654 }
655 } catch {
656 return { x: [], y: [] }
657 }
658 },
659 async readPageLayoutAudit(): Promise<string | null> {
660 const wv = webviewRef.current
661 if (!wv || !canExecuteJavaScript(wv)) return null
662 try {
663 const report = await wv.executeJavaScript(PAGE_LAYOUT_AUDIT_SCRIPT)
664 if (typeof report !== 'string') return null
665 const normalized = report.trim().slice(0, 6000)
666 return normalized || null
667 } catch {
668 return null
669 }
670 },
671 async restoreEditModeSelection(selector: string): Promise<boolean> {
672 const wv = webviewRef.current
673 if (!wv) return false
674 try {
675 const result = await wv.executeJavaScript(
676 `(function() {
677 try {
678 if (window.__pptEditModeRestoreSelection) {
679 return window.__pptEditModeRestoreSelection(${JSON.stringify(selector)});
680 }
681 return false;
682 } catch (e) {
683 console.debug("[EditMode] restore script error", e);
684 return false;
685 }
686 })()`
687 )
688 return Boolean(result)
689 } catch {
690 return false
691 }
692 },
693 async restoreInspectorSelection(selector: string): Promise<boolean> {
694 const wv = webviewRef.current
695 if (!wv) return false
696 try {
697 const result = await wv.executeJavaScript(
698 `(function() {
699 try {
700 if (window.__pptInspectorRestoreSelection) {
701 return window.__pptInspectorRestoreSelection(${JSON.stringify(selector)});
702 }
703 return false;
704 } catch (e) {
705 console.debug("[Inspector] restore selection error", e);
706 return false;
707 }
708 })()`
709 )
710 return Boolean(result)
711 } catch {
712 return false
713 }
714 },
715 clearEditModeSelection(): void {
716 const wv = webviewRef.current
717 if (!wv) return
718 safeExecuteJavaScript(
719 wv,
720 `if (window.__pptEditModeClearSelection) window.__pptEditModeClearSelection();`
721 )
722 },
723 hideElement(selector: string): void {
724 const wv = webviewRef.current
725 if (!wv) return
726 safeExecuteJavaScript(
727 wv,
728 `(function(){` +
729 `var __el = document.querySelector(${JSON.stringify(selector)});` +
730 `if (!__el) return;` +
731 `__el.setAttribute('data-ppt-pending-delete', '1');` +
732 `if (__el.hasAttribute && __el.hasAttribute('data-ppt-art-text')) {` +
733 ` var __blockId = __el.getAttribute('data-block-id') || '';` +
734 ` var __style = __blockId ? Array.from(document.querySelectorAll('style[data-ppt-art-text-style]')).find(function(s){ return s.getAttribute('data-ppt-art-text-style') === __blockId; }) : null;` +
735 ` if (__style) { __style.setAttribute('data-ppt-pending-delete', '1'); __style.disabled = true; }` +
736 `}` +
737 `if (__el.tagName === 'STYLE') { __el.disabled = true; return; }` +
738 `__el.style.setProperty('display', 'none', 'important');` +
739 `})()`
740 )
741 },
742 showElement(selector: string): void {
743 const wv = webviewRef.current
744 if (!wv) return
745 safeExecuteJavaScript(
746 wv,
747 `(function(){` +
748 `var __el = document.querySelector(${JSON.stringify(selector)});` +
749 `if (!__el || __el.getAttribute('data-ppt-pending-delete') !== '1') return;` +
750 `if (__el.hasAttribute && __el.hasAttribute('data-ppt-art-text')) {` +
751 ` var __blockId = __el.getAttribute('data-block-id') || '';` +
752 ` var __style = __blockId ? Array.from(document.querySelectorAll('style[data-ppt-art-text-style]')).find(function(s){ return s.getAttribute('data-ppt-art-text-style') === __blockId; }) : null;` +
753 ` if (__style) { __style.disabled = false; __style.removeAttribute('data-ppt-pending-delete'); }` +
754 `}` +
755 `if (__el.tagName === 'STYLE') { __el.disabled = false; __el.removeAttribute('data-ppt-pending-delete'); return; }` +
756 `__el.style.removeProperty('display');` +
757 `__el.removeAttribute('data-ppt-pending-delete');` +
758 `})()`
759 )
760 },
761 applyDragStyle(
762 selector: string,
763 style: {
764 x: number
765 y: number
766 width?: number
767 height?: number
768 isAbsoluteMode?: boolean
769 }
770 ): void {
771 const wv = webviewRef.current
772 if (!wv) return
773 if (style.isAbsoluteMode) {
774 safeExecuteJavaScript(
775 wv,
776 `(function(){` +
777 `var __el = document.querySelector(${JSON.stringify(selector)}); if (!__el) return;` +
778 `__el.style.position = 'absolute';` +
779 `if (!__el.style.zIndex) __el.style.zIndex = '10';` +
780 `__el.style.left = ${JSON.stringify(style.x + 'px')};` +
781 `__el.style.top = ${JSON.stringify(style.y + 'px')};` +
782 `__el.style.translate = '';` +
783 `__el.style.removeProperty('--ppt-drag-x');` +
784 `__el.style.removeProperty('--ppt-drag-y');` +
785 `__el.setAttribute('data-ppt-layout-converted', '1');` +
786 (style.width != null ? `__el.style.width = ${JSON.stringify(style.width + 'px')};` : '') +
787 (style.height != null ? `__el.style.height = ${JSON.stringify(style.height + 'px')};` : '') +
788 `})()`
789 )
790 return
791 }
792 safeExecuteJavaScript(
793 wv,
794 `(function(){` +
795 `var __el = document.querySelector(${JSON.stringify(selector)}); if (!__el) return;` +
796 `var __pos = __el.style.position || getComputedStyle(__el).position;` +
797 `if (!__pos || __pos === 'static') __el.style.position = 'relative';` +
798 `if (!__el.style.zIndex) __el.style.zIndex = '10';` +
799 `__el.style.setProperty('--ppt-drag-x', ${JSON.stringify(style.x + 'px')});` +
800 `__el.style.setProperty('--ppt-drag-y', ${JSON.stringify(style.y + 'px')});` +
801 `__el.style.translate = 'var(--ppt-drag-x, 0px) var(--ppt-drag-y, 0px)';` +
802 (style.width != null ? `__el.style.width = ${JSON.stringify(style.width + 'px')};` : '') +
803 (style.height != null ? `__el.style.height = ${JSON.stringify(style.height + 'px')};` : '') +
804 `})()`
805 )
806 },
807 applyLayoutIsland(layoutIsland: EditModeLayoutIsland): void {
808 const wv = webviewRef.current
809 if (!wv) return
810 safeExecuteJavaScript(
811 wv,
812 `if (window.__pptEditModeApplyLayoutIsland) window.__pptEditModeApplyLayoutIsland(${JSON.stringify(layoutIsland)});`
813 )
814 },
815 applyZIndex(selector: string, zIndex: number): void {
816 const wv = webviewRef.current
817 if (!wv) return
818 safeExecuteJavaScript(
819 wv,
820 `(function(){` +
821 `var __el = document.querySelector(${JSON.stringify(selector)});` +
822 `if (!__el) return;` +
823 `var __position = window.getComputedStyle(__el).position;` +
824 `if (!__position || __position === "static") __el.style.setProperty("position", "relative", "important");` +
825 `__el.style.setProperty("z-index", String(${zIndex}), "important");` +
826 `})()`
827 )
828 },
829 async copyElement(
830 selector: string,
831 newBlockId: string
832 ): Promise<{ selector: string; htmlFragment: string } | null> {
833 const wv = webviewRef.current
834 if (!wv || !canExecuteJavaScript(wv)) return null
835 const scope = selector.match(/\[data-page-id="([^"]+)"\]/)?.[1] || ''
836 const root = scope ? `body[data-page-id="${scope}"] [data-ppt-guard-root="1"]` : 'body'
837 const newSelector = scope
838 ? `body[data-page-id="${scope}"] [data-block-id="${newBlockId}"]`
839 : `[data-block-id="${newBlockId}"]`
840 try {
841 // Pre-generate child block IDs with nanoid (same pattern as host code)
842 const childIds = Array.from({ length: 20 }, () => 'select-arcsin1-' + nanoid(8))
843 const copyResult = (await wv.executeJavaScript(
844 `(function(){` +
845 `var __src = document.querySelector(${JSON.stringify(selector)});` +
846 `if (!__src) return null;` +
847 `var __root = document.querySelector(${JSON.stringify(root)});` +
848 `if (!__root) return null;` +
849 `var __clone = __src.cloneNode(true);` +
850 `var __childIds = ${JSON.stringify(childIds)};` +
851 `var __oldBlockId = __src.getAttribute("data-block-id") || "";` +
852 `var __styleClone = null;` +
853 `var __styleHtml = "";` +
854 `__clone.setAttribute("data-block-id", ${JSON.stringify(newBlockId)});` +
855 `__clone.querySelectorAll("[data-block-id]").forEach(function(c,i){if(__childIds[i])c.setAttribute("data-block-id",__childIds[i]);});` +
856 `__clone.classList.remove("arcsin1-presentation-editor-selected","arcsin1-presentation-editor-hover");` +
857 `__clone.removeAttribute("data-arcsin1-presentation-editor-selected");` +
858 `__clone.removeAttribute("data-arcsin1-presentation-editor-hover");` +
859 `if (__src.hasAttribute("data-ppt-art-text") && __oldBlockId) {` +
860 ` var __style = Array.from(document.querySelectorAll("style[data-ppt-art-text-style]")).find(function(s){ return s.getAttribute("data-ppt-art-text-style") === __oldBlockId; });` +
861 ` if (__style) {` +
862 ` __styleClone = __style.cloneNode(true);` +
863 ` __styleClone.setAttribute("data-ppt-art-text-style", ${JSON.stringify(newBlockId)});` +
864 ` __styleClone.textContent = String(__styleClone.textContent || "").split(__oldBlockId).join(${JSON.stringify(newBlockId)});` +
865 ` __styleClone.disabled = false;` +
866 ` __styleClone.removeAttribute("data-ppt-pending-delete");` +
867 ` __styleHtml = __styleClone.outerHTML;` +
868 ` __root.appendChild(__styleClone);` +
869 ` }` +
870 `}` +
871 `var __rect = __src.getBoundingClientRect();` +
872 `var __pos = __src.style.position || getComputedStyle(__src).position;` +
873 `if (__pos === "absolute" || __src.hasAttribute("data-ppt-layout-converted")) {` +
874 ` __clone.style.left = (parseFloat(__src.style.left||"0")+40)+"px";` +
875 ` __clone.style.top = (parseFloat(__src.style.top||"0")+40)+"px";` +
876 ` var __z = parseInt(__src.style.zIndex||"10")||10;` +
877 ` __clone.style.zIndex = String(__z+1);` +
878 `} else {` +
879 ` __clone.style.position = "absolute";` +
880 ` __clone.style.left = (__rect.left+40)+"px";` +
881 ` __clone.style.top = (__rect.top+40)+"px";` +
882 ` __clone.style.width = __rect.width+"px";` +
883 ` __clone.style.height = __rect.height+"px";` +
884 ` __clone.style.zIndex = "20";` +
885 `}` +
886 `__clone.removeAttribute("data-ppt-layout-converted");` +
887 `__clone.removeAttribute("data-ppt-last-vp-x");` +
888 `__clone.removeAttribute("data-ppt-last-vp-y");` +
889 `var __htmlFragment = __styleHtml + __clone.outerHTML;` +
890 `__root.appendChild(__clone);` +
891 `return { selector: ${JSON.stringify(newSelector)}, htmlFragment: __htmlFragment };` +
892 `})()`
893 )) as { selector?: string; htmlFragment?: string } | null
894 if (!copyResult?.selector || !copyResult.htmlFragment) return null
895 return { selector: copyResult.selector, htmlFragment: copyResult.htmlFragment }
896 } catch {
897 return null
898 }
899 },
900 async readElementHtml(selector: string): Promise<string> {
901 const wv = webviewRef.current
902 if (!wv || !canExecuteJavaScript(wv)) return ''
903 try {
904 return (await wv.executeJavaScript(
905 `(function(){` +
906 `var __el = document.querySelector(${JSON.stringify(selector)});` +
907 `if (!__el) return '';` +
908 `if (__el.hasAttribute && __el.hasAttribute('data-ppt-art-text')) {` +
909 ` var __blockId = __el.getAttribute('data-block-id') || '';` +
910 ` var __style = __blockId ? Array.from(document.querySelectorAll('style[data-ppt-art-text-style]')).find(function(s){ return s.getAttribute('data-ppt-art-text-style') === __blockId; }) : null;` +
911 ` return (__style ? __style.outerHTML : '') + __el.outerHTML;` +
912 `}` +
913 `return __el.outerHTML || '';` +
914 `})()`
915 )) || ''
916 } catch {
917 return ''
918 }
919 },
920 async readElementSnapshot(selector: string): Promise<EditableElementSnapshot | null> {
921 const wv = webviewRef.current
922 if (!wv || !canExecuteJavaScript(wv)) return null
923 try {
924 return (
925 (await wv.executeJavaScript(
926 `window.__pptEditModeReadSnapshot ? window.__pptEditModeReadSnapshot(${JSON.stringify(selector)}) : null`
927 )) || null
928 )
929 } catch {
930 return null
931 }
932 },
933 async inspectElement(selector: string): Promise<PresentationElementSnapshot | null> {
934 const wv = webviewRef.current
935 return wv ? inspectPresentationElement(wv, selector) : null
936 },
937 async applyElementOperations(
938 selector: string,
939 operations: PresentationEditorOperation[]
940 ): Promise<PresentationEditorOperationResult[]> {
941 const wv = webviewRef.current
942 if (!wv || !canExecuteJavaScript(wv) || operations.length === 0) return []
943 try {
944 const result = await wv.executeJavaScript(
945 `window.__pptEditModeApplyOperations ? window.__pptEditModeApplyOperations(${JSON.stringify(selector)}, ${JSON.stringify(operations)}) : []`
946 )
947 return Array.isArray(result) ? (result as PresentationEditorOperationResult[]) : []
948 } catch {
949 return []
950 }
951 },
952 async readElementLayout(
953 selector: string
954 ): Promise<{
955 isAbsoluteMode: boolean
956 x: number
957 y: number
958 width: number
959 height: number
960 visualX?: number
961 visualY?: number
962 layoutIsland?: EditModeLayoutIsland
963 } | null> {
964 const wv = webviewRef.current
965 if (!wv || !canExecuteJavaScript(wv)) return null
966 try {
967 const layout = (await wv.executeJavaScript(
968 `window.__pptEditModeReadLayout ? window.__pptEditModeReadLayout(${JSON.stringify(selector)}) : null`
969 )) as {
970 isAbsoluteMode: boolean
971 x: number
972 y: number
973 width: number
974 height: number
975 visualX?: number
976 visualY?: number
977 layoutIsland?: unknown
978 } | null
979 if (!layout) return null
980 return {
981 ...layout,
982 layoutIsland: normalizeEditModeLayoutIsland(layout.layoutIsland)
983 }
984 } catch {
985 return null
986 }
987 },
988 applyChildUpdates(
989 selector: string,
990 childUpdates: Array<{ path: number[]; width?: number; height?: number }>
991 ): void {
992 const wv = webviewRef.current
993 if (!wv || childUpdates.length === 0) return
994 const updatesJs = childUpdates
995 .map(
996 (u) =>
997 `{path:${JSON.stringify(u.path)},width:${u.width != null ? u.width : 'null'},height:${u.height != null ? u.height : 'null'}}`
998 )
999 .join(',')
1000 safeExecuteJavaScript(
1001 wv,
1002 `(function(){` +
1003 `var __parent = document.querySelector(${JSON.stringify(selector)}); if (!__parent) return;` +
1004 `var __ups = [${updatesJs}];` +
1005 `for (var __i = 0; __i < __ups.length; __i++) {` +
1006 ` var __u = __ups[__i]; var __c = __parent;` +
1007 ` for (var __j = 0; __j < __u.path.length; __j++) { __c = __c.children[__u.path[__j]]; if (!__c) break; }` +
1008 ` if (!__c) continue;` +
1009 ` if (__u.width !== null) __c.style.width = __u.width + 'px';` +
1010 ` if (__u.height !== null) __c.style.height = __u.height + 'px';` +
1011 `}` +
1012 `if (window.PPT && typeof window.PPT.resizeCharts === "function") { try { window.PPT.resizeCharts(__parent); } catch(__e) {} }` +
1013 `})()`
1014 )
1015 },
1016 injectElement(
1017 parentSelector: string,
1018 htmlFragment: string,
1019 insertIndex = -1,
1020 selectAfterInsert = true
1021 ): void {
1022 const wv = webviewRef.current
1023 if (!wv) return
1024 safeExecuteJavaScript(
1025 wv,
1026 `(function(){` +
1027 `var __parentSelector = ${JSON.stringify(parentSelector)};` +
1028 `var __html = ${JSON.stringify(htmlFragment)};` +
1029 `var __insertIndex = ${JSON.stringify(insertIndex)};` +
1030 `var __selectAfterInsert = ${JSON.stringify(selectAfterInsert)};` +
1031 `if (window.__pptEditModeInjectElement) { window.__pptEditModeInjectElement(__parentSelector, __html, __insertIndex, __selectAfterInsert); return; }` +
1032 `var __parent = document.querySelector(__parentSelector); if (!__parent) return;` +
1033 `var __template = document.createElement("template"); __template.innerHTML = __html;` +
1034 `var __nodes = Array.from(__template.content.children); if (__nodes.length === 0) return;` +
1035 `var __existingBlock = null;` +
1036 `for (var __k = 0; __k < __nodes.length; __k++) {` +
1037 ` var __blockId = __nodes[__k] instanceof Element ? __nodes[__k].getAttribute("data-block-id") : "";` +
1038 ` if (__blockId && document.querySelector('[data-block-id="' + __blockId.replace(/"/g, '\\\\"') + '"]')) { __existingBlock = __blockId; break; }` +
1039 `}` +
1040 `if (__existingBlock) return;` +
1041 `var __anchor = Number.isInteger(__insertIndex) && __insertIndex >= 0 && __insertIndex < __parent.children.length ? __parent.children[__insertIndex] : null;` +
1042 `__nodes.forEach(function(__node){ if (__anchor) __parent.insertBefore(__node, __anchor); else __parent.appendChild(__node); });` +
1043 `__nodes.forEach(function(__node){ if (!(__node instanceof Element)) return; var __scripts = []; if (__node.matches('script[data-ppt-generated-chart-script="1"]')) __scripts.push(__node); __node.querySelectorAll('script[data-ppt-generated-chart-script="1"]').forEach(function(__script){ __scripts.push(__script); }); __scripts.forEach(function(__script){ try { new Function(__script.textContent || "")(); } catch(__e) {} }); });` +
1044 `})()`
1045 )
1046 }
1047 }),
1048 []
1049 )
1050
1051 useEffect(() => {
1052 const webview = webviewElement
1053 if (!webview) return
1054
1055 webviewReadyRef.current = false
1056 setWebviewReady(false)
1057
1058 const markReady = (): void => {
1059 if (webviewRef.current === webview) {
1060 webviewReadyRef.current = true
1061 setWebviewReady(true)
1062 }
1063 }
1064 const handleStartLoading = (): void => {
1065 if (webviewRef.current === webview) {
1066 inspectorSelectionRequestRef.current += 1
1067 webviewReadyRef.current = false
1068 setWebviewReady(false)
1069 }
1070 }
1071
1072 webview.addEventListener('dom-ready', markReady as EventListener)
1073 webview.addEventListener('did-start-loading', handleStartLoading as EventListener)
1074
1075 return () => {
1076 webview.removeEventListener('dom-ready', markReady as EventListener)
1077 webview.removeEventListener('did-start-loading', handleStartLoading as EventListener)
1078 if (webviewRef.current === webview) {
1079 webviewReadyRef.current = false
1080 setWebviewReady(false)
1081 }
1082 }
1083 }, [webviewElement])
1084
1085 // Selection overlay effect: handles AI inspect and animation-select.
1086 useEffect(() => {
1087 const webview = webviewElement
1088 if (!webview || !inspectable || !webviewReady) return
1089
1090 const runInspectorLifecycle = (): void => {
1091 if (inspecting) {
1092 safeExecuteHostScript(
1093 webview,
1094 'presentation-editor-runtime-inject',
1095 buildPresentationEditorRuntimeInjectScript({
1096 rootSelector: '[data-ppt-guard-root="1"], .ppt-page-root',
1097 interaction: false
1098 })
1099 )
1100 safeExecuteHostScript(
1101 webview,
1102 'inspector-inject',
1103 buildInspectorInjectScript({ mode: currentInteractionMode === 'animation-select' ? 'animation-select' : 'inspect' })
1104 )
1105 inspectorInjectedRef.current = true
1106 } else {
1107 if (!inspectorInjectedRef.current) return
1108 safeExecuteHostScript(webview, 'inspector-cleanup', buildInspectorCleanupScript())
1109 inspectorInjectedRef.current = false
1110 }
1111 }
1112
1113 runInspectorLifecycle()
1114
1115 return () => {
1116 inspectorSelectionRequestRef.current += 1
1117 if (!inspectorInjectedRef.current) return
1118 safeExecuteHostScript(webview, 'inspector-cleanup', buildInspectorCleanupScript())
1119 inspectorInjectedRef.current = false
1120 }
1121 }, [inspectable, inspecting, currentInteractionMode, webviewReady, webviewSrc, webviewElement])
1122
1123 // Unified edit mode effect: handles click-to-select, drag, and resize.
1124 // Use ref for onDidReload to avoid re-running effect on every parent re-render.
1125 const onDidReloadRef = useRef(onDidReload)
1126 onDidReloadRef.current = onDidReload
1127
1128 useEffect(() => {
1129 const webview = webviewElement
1130 if (!webview || !inspectable || !webviewReady) return
1131
1132 const runEditModeLifecycle = (): void => {
1133 if (editMode) {
1134 safeExecuteHostScript(
1135 webview,
1136 'edit-inject',
1137 buildEditModeInjectScript(previewScaleRef.current)
1138 )
1139 editModeInjectedRef.current = true
1140 } else {
1141 if (!editModeInjectedRef.current) return
1142 safeExecuteHostScript(webview, 'edit-cleanup', buildEditModeCleanupScript())
1143 editModeInjectedRef.current = false
1144 }
1145 }
1146
1147 runEditModeLifecycle()
1148 if (editMode) onDidReloadRef.current?.()
1149
1150 return () => {
1151 if (!editModeInjectedRef.current) return
1152 safeExecuteHostScript(webview, 'edit-cleanup', buildEditModeCleanupScript())
1153 editModeInjectedRef.current = false
1154 }
1155 }, [inspectable, editMode, webviewReady, webviewSrc, webviewElement])
1156
1157 useEffect(() => {
1158 const webview = webviewElement
1159 if (!webview || !inspectable || !editMode || !webviewReady) return
1160 safeExecuteHostScript(
1161 webview,
1162 'edit-set-preview-scale',
1163 buildEditModeSetPreviewScaleScript(previewScale)
1164 )
1165 }, [editMode, inspectable, previewScale, webviewReady, webviewElement])
1166
1167 // Console message router: inspector + unified edit mode
1168 // Use refs for callback props to avoid re-registering listener on every parent re-render
1169 const onSelectorSelectedRef = useRef(onSelectorSelected)
1170 onSelectorSelectedRef.current = onSelectorSelected
1171 const onElementMovedRef = useRef(onElementMoved)
1172 onElementMovedRef.current = onElementMoved
1173 // Serialize 'moved' events per webview: each event awaits ensureAnchoredAnchor
1174 // before dispatching handleMoved. Without serialization, a slow anchor (first
1175 // edit on an unanchored element, or any IPC scheduling jitter) can let a later
1176 // 'moved' resolve before an earlier one, so a stale drag's x/y (or null
1177 // width/height) overwrites a fresh resize. The promise chain guarantees
1178 // emission order === dispatch order.
1179 const movedChainRef = useRef<Promise<unknown>>(Promise.resolve())
1180 const onElementSelectedRef = useRef(onElementSelected)
1181 onElementSelectedRef.current = onElementSelected
1182 const onInspectExitRef = useRef(onInspectExit)
1183 onInspectExitRef.current = onInspectExit
1184 const onDeleteRequestRef = useRef(onDeleteRequest)
1185 onDeleteRequestRef.current = onDeleteRequest
1186 useEffect(() => {
1187 const webview = webviewElement
1188 if (!webview || !inspectable) return
1189
1190 const handleConsoleMessage = (event: Event): void => {
1191 const payloadText = (event as { message?: unknown }).message
1192 if (typeof payloadText !== 'string') {
1193 return
1194 }
1195 if (payloadText.startsWith('[PreviewIframe:')) {
1196 console.error(payloadText)
1197 return
1198 }
1199 const isInspectorMessage = payloadText.startsWith(INSPECTOR_CONSOLE_PREFIX)
1200 const isEditModeMessage = payloadText.startsWith(EDIT_MODE_CONSOLE_PREFIX)
1201 if (!isInspectorMessage && !isEditModeMessage) return
1202
1203 const prefixLength = isInspectorMessage
1204 ? INSPECTOR_CONSOLE_PREFIX.length
1205 : EDIT_MODE_CONSOLE_PREFIX.length
1206 const raw = payloadText.slice(prefixLength).trim()
1207 if (!raw) return
1208 try {
1209 const parsed = JSON.parse(raw) as {
1210 type?: string
1211 mode?: 'inspect' | 'text-edit' | 'animation-select'
1212 selector?: string
1213 blockId?: string
1214 label?: string
1215 elementTag?: string
1216 elementText?: string
1217 formula?: EditableElementSnapshot['formula']
1218 kind?: EditSelectionPayload['kind']
1219 capabilities?: EditSelectionPayload['capabilities']
1220 snapshot?: EditSelectionPayload['snapshot']
1221 isText?: boolean
1222 layoutMode?: EditModeMovePayload['layoutMode']
1223 x?: number
1224 y?: number
1225 deltaX?: number
1226 deltaY?: number
1227 visualX?: number
1228 visualY?: number
1229 width?: number
1230 height?: number
1231 layoutIsland?: unknown
1232 childUpdates?: Array<{
1233 path: number[]
1234 width?: number
1235 height?: number
1236 }>
1237 text?: string
1238 html?: string
1239 textTarget?: EditTextTarget
1240 style?: EditSelectionPayload['style']
1241 bounds?: EditSelectionPayload['bounds']
1242 translateX?: number
1243 translateY?: number
1244 zIndex?: number
1245 editability?: EditSelectionPayload['editability']
1246 }
1247
1248 // Inspector / animation-select: element selected
1249 if (isInspectorMessage && parsed.type === 'selected' && parsed.selector) {
1250 const selectedSelector = parsed.selector
1251 const requestId = ++inspectorSelectionRequestRef.current
1252 const selectionInteractionMode = inspectorInteractionModeRef.current
1253 if (parsed.mode === 'animation-select' && parsed.formula) {
1254 void (async () => {
1255 const anchor = await ensureAnchoredAnchor({
1256 selector: selectedSelector,
1257 elementTag: parsed.elementTag,
1258 elementText: parsed.elementText,
1259 reason: 'inspect',
1260 formula: parsed.formula
1261 })
1262 if (
1263 webviewRef.current !== webview ||
1264 !isCurrentInspectorSelectionRequest({
1265 requestId,
1266 latestRequestId: inspectorSelectionRequestRef.current,
1267 isInspectorActive: inspectorActiveRef.current,
1268 selectionInteractionMode,
1269 currentInteractionMode: inspectorInteractionModeRef.current
1270 })
1271 ) {
1272 return
1273 }
1274 onSelectorSelectedRef.current?.(
1275 anchor.selector,
1276 anchor.selector,
1277 parsed.elementTag,
1278 parsed.elementText
1279 )
1280 })().catch(() => {})
1281 return
1282 }
1283 void (async () => {
1284 const snapshot = await inspectPresentationElement(webview, selectedSelector)
1285 if (
1286 webviewRef.current !== webview ||
1287 !isCurrentInspectorSelectionRequest({
1288 requestId,
1289 latestRequestId: inspectorSelectionRequestRef.current,
1290 isInspectorActive: inspectorActiveRef.current,
1291 selectionInteractionMode,
1292 currentInteractionMode: inspectorInteractionModeRef.current
1293 })
1294 ) {
1295 return
1296 }
1297 onSelectorSelectedRef.current?.(
1298 selectedSelector,
1299 parsed.label || selectedSelector,
1300 parsed.elementTag,
1301 parsed.elementText,
1302 snapshot ? buildSelectedElementRuntimeContext(snapshot) : null
1303 )
1304 })().catch(() => {})
1305 return
1306 }
1307
1308 // Edit mode: element selected (click)
1309 if (isEditModeMessage && parsed.type === 'selected' && parsed.selector) {
1310 void (async () => {
1311 const anchor = await ensureAnchoredAnchor({
1312 selector: parsed.selector || '',
1313 elementTag: parsed.elementTag,
1314 elementText: parsed.elementText,
1315 reason: 'drag',
1316 formula: parsed.snapshot?.formula
1317 })
1318 if (webviewRef.current !== webview) return
1319 const textTarget =
1320 parsed.textTarget && parsed.textTarget.parentSelector === parsed.selector
1321 ? { ...parsed.textTarget, parentSelector: anchor.selector }
1322 : parsed.textTarget
1323 onElementSelectedRef.current?.({
1324 selector: anchor.selector,
1325 blockId: anchor.blockId || parsed.blockId,
1326 label: anchor.selector,
1327 elementTag: parsed.elementTag || '',
1328 elementText: parsed.elementText || '',
1329 kind: parsed.kind,
1330 capabilities: parsed.capabilities,
1331 snapshot: parsed.snapshot
1332 ? {
1333 ...parsed.snapshot,
1334 selector: anchor.selector,
1335 blockId: anchor.blockId || parsed.snapshot.blockId || parsed.blockId
1336 }
1337 : parsed.snapshot,
1338 isText: Boolean(parsed.isText),
1339 text: typeof parsed.text === 'string' ? parsed.text : '',
1340 html: typeof parsed.html === 'string' ? parsed.html : '',
1341 textTarget,
1342 style: parsed.style || {},
1343 bounds: parsed.bounds,
1344 translateX: Number(parsed.translateX || 0),
1345 translateY: Number(parsed.translateY || 0),
1346 zIndex: typeof parsed.zIndex === 'number' ? parsed.zIndex : undefined,
1347 editability: parsed.editability || undefined
1348 })
1349 })().catch(() => {})
1350 return
1351 }
1352
1353 // Edit mode: pre-anchor request
1354 if (isEditModeMessage && parsed.type === 'pre-anchor' && parsed.selector) {
1355 void (async () => {
1356 let anchorResult: { selector: string; blockId?: string }
1357 try {
1358 anchorResult = await ensureAnchoredAnchor({
1359 selector: parsed.selector || '',
1360 elementTag: parsed.elementTag,
1361 reason: 'drag',
1362 formula: parsed.snapshot?.formula
1363 })
1364 } catch {
1365 return
1366 }
1367 if (webviewRef.current !== webview) return
1368 const wv = webviewRef.current
1369 if (wv) {
1370 safeExecuteJavaScript(
1371 wv,
1372 `if (window.__pptResolveEditModeAnchor) window.__pptResolveEditModeAnchor(${JSON.stringify(anchorResult)});`
1373 )
1374 }
1375 })().catch(() => {})
1376 return
1377 }
1378
1379 // Edit mode: element moved/resized.
1380 // Serialized via movedChainRef: each event must finish ensureAnchoredAnchor
1381 // → handleMoved before the next one starts, so emission order === dispatch
1382 // order. Without this, a stale 'moved' (e.g. a drag whose anchor IPC was
1383 // slow) can resolve after a fresh resize and clobber the resize's x/y or
1384 // null-out its width/height in upsertDragEdit.
1385 if (isEditModeMessage && parsed.type === 'moved' && parsed.selector) {
1386 movedChainRef.current = movedChainRef.current
1387 .catch(() => {})
1388 .then(() =>
1389 (async () => {
1390 const anchor = await ensureAnchoredAnchor({
1391 selector: parsed.selector || '',
1392 elementTag: parsed.elementTag,
1393 reason: 'drag',
1394 formula: parsed.snapshot?.formula
1395 })
1396 if (webviewRef.current !== webview) return
1397 onElementMovedRef.current?.({
1398 selector: anchor.selector,
1399 blockId: anchor.blockId || parsed.blockId,
1400 label: anchor.selector,
1401 elementTag: parsed.elementTag || '',
1402 layoutMode: parsed.layoutMode,
1403 x: Number(parsed.x || 0),
1404 y: Number(parsed.y || 0),
1405 deltaX: Number(parsed.deltaX || 0),
1406 deltaY: Number(parsed.deltaY || 0),
1407 visualX: parsed.visualX === undefined ? undefined : Number(parsed.visualX),
1408 visualY: parsed.visualY === undefined ? undefined : Number(parsed.visualY),
1409 width: parsed.width === undefined ? undefined : Number(parsed.width),
1410 height: parsed.height === undefined ? undefined : Number(parsed.height),
1411 layoutIsland: normalizeEditModeLayoutIsland(parsed.layoutIsland),
1412 childUpdates: Array.isArray(parsed.childUpdates)
1413 ? parsed.childUpdates
1414 .map((item) => ({
1415 path: Array.isArray(item.path)
1416 ? item.path
1417 .map((value) => Number(value))
1418 .filter((value) => Number.isInteger(value) && value >= 0)
1419 : [],
1420 width: item.width === undefined ? undefined : Number(item.width),
1421 height: item.height === undefined ? undefined : Number(item.height)
1422 }))
1423 .filter(
1424 (item) =>
1425 item.path.length > 0 &&
1426 (item.width !== undefined || item.height !== undefined)
1427 )
1428 : undefined
1429 })
1430 })()
1431 )
1432 .catch(() => {})
1433 return
1434 }
1435
1436 // Exit from either mode
1437 if (parsed.type === 'exit') {
1438 onInspectExitRef.current?.()
1439 }
1440
1441 // Edit mode: keyboard delete request
1442 if (isEditModeMessage && parsed.type === 'delete-request' && parsed.selector) {
1443 onDeleteRequestRef.current?.(parsed.selector)
1444 }
1445 } catch {
1446 // ignore parse error
1447 }
1448 }
1449
1450 webview.addEventListener('console-message', handleConsoleMessage as EventListener)
1451 return () => {
1452 webview.removeEventListener('console-message', handleConsoleMessage as EventListener)
1453 }
1454 }, [
1455 inspectable,
1456 pageHtmlPath,
1457 pageId,
1458 webviewElement
1459 ])
1460
1461 useEffect(() => {
1462 const el = containerRef.current
1463 if (!el) return
1464
1465 const updateScale = (): void => {
1466 const { width, height } = el.getBoundingClientRect()
1467 const nextScaleRaw = Math.min(width / slideSize.width, height / slideSize.height)
1468 const nextScale = Number.isFinite(nextScaleRaw) && nextScaleRaw > 0 ? nextScaleRaw : 1
1469 const offsetX = Math.max(0, (width - slideSize.width * nextScale) / 2)
1470 const offsetY = Math.max(0, (height - slideSize.height * nextScale) / 2)
1471 setPreviewScale(nextScale)
1472 setTransform(`translate(${offsetX}px, ${offsetY}px) scale(${nextScale})`)
1473 }
1474
1475 updateScale()
1476 const observer = new ResizeObserver(updateScale)
1477 observer.observe(el)
1478 return () => observer.disconnect()
1479 }, [slideSize.height, slideSize.width])
1480
1481 return (
1482 <div
1483 ref={containerRef}
1484 className="relative h-full w-full overflow-hidden rounded-[inherit] bg-[#f5f1e8]"
1485 >
1486 {webviewSrc ? (
1487 <webview
1488 ref={handleWebviewRef}
1489 src={webviewSrc}
1490 tabIndex={thumbnail ? -1 : 0}
1491 title={title}
1492 className={`absolute left-0 top-0 origin-top-left ${
1493 pointerEnabled ? 'pointer-events-auto' : 'pointer-events-none'
1494 } ${editMode ? 'cursor-move' : inspecting ? 'cursor-crosshair' : ''}`}
1495 style={{ width: slideSize.width, height: slideSize.height, transform }}
1496 />
1497 ) : null}
1498 </div>
1499 )
1500 })
1501
1501 lines Plain Text