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