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