/** Full OpenPencil editor shared by native Tool details and plugin-owned surfaces. */ import { useCallback, useEffect, useRef, useState } from 'react' import type { PresentationGrant } from './index.js' import { claimEditor, confirmEditorClose, editorControlUrl, editorIframeUrlWithTheme, editorIframeUrlWithLocale, editorMessageFrom, editorOrigin, encodeEditorOutbound, type EditorColorScheme, type EditorInboundMessage, type EditorLocale, } from './editor-bridge.js' import { editorGrantForBoot, rememberEditorSuccessor } from './editor-successor.js' import { clearOpenPencilSelection, liveSelectionOf, publishOpenPencilSelection, } from './selection-store.js' import { startEditorSelectionPolling } from './selection-polling.js' import { captureManagedEditorRecovery, editorRecoveryCopy, editorRecoverySummaryOf, restoreManagedEditorRecovery, type EditorRecoverySummary, } from './editor-recovery.js' export type { EditorRecoverySummary } from './editor-recovery.js' export interface LaunchResponse { sessionId: string iframeUrl: string token: string saveUrl: string selectionUrl?: string recoveryUrl?: string recovery?: EditorRecoverySummary closeUrl: string docJson?: string /** Client-only marker: the persisted launch capability was renewed. */ renewed?: true } const DEFAULT_REFRESH_URL = '/_dsh/dsh-openpencil/editor/refresh' function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function launchResponseOf(value: unknown): LaunchResponse { if (!isRecord(value)) throw new Error('OpenPencil editor launch returned an invalid response') const fields = ['sessionId', 'iframeUrl', 'token', 'saveUrl', 'closeUrl'] as const for (const field of fields) { if (typeof value[field] !== 'string' || value[field].length === 0) { throw new Error(`OpenPencil editor launch omitted ${field}`) } } return { sessionId: value.sessionId as string, iframeUrl: value.iframeUrl as string, token: value.token as string, saveUrl: editorControlUrl(value.saveUrl as string), ...(typeof value.selectionUrl === 'string' && value.selectionUrl.length > 0 ? { selectionUrl: editorControlUrl(value.selectionUrl) } : {}), ...(typeof value.recoveryUrl === 'string' && value.recoveryUrl.length > 0 ? { recoveryUrl: editorControlUrl(value.recoveryUrl) } : {}), closeUrl: editorControlUrl(value.closeUrl as string), ...(editorRecoverySummaryOf(value.recovery) === undefined ? {} : { recovery: editorRecoverySummaryOf(value.recovery) }), ...(typeof value.docJson === 'string' ? { docJson: value.docJson } : {}), } } async function responseJson(response: Response, action: string): Promise { if (!response.ok) throw new Error(`${action} failed (${response.status})`) return response.json() } function refreshedLaunchUrlOf(value: unknown): string { if (!isRecord(value) || typeof value.launchUrl !== 'string' || value.launchUrl.length === 0) { throw new Error('OpenPencil editor refresh omitted launchUrl') } return editorControlUrl(value.launchUrl) } export interface EditorBootResult { launch: LaunchResponse documentJson: string } export interface EditorPanelCopy { save: string saving: string unsaved: string saved: string unavailable: string loading: string errorTitle: string pngFallback: string editorTitle: (title: string) => string editorTimeout: string editorBusy: string discard: string saveConflict: (serverVersion: number) => string syncConflict: (serverVersion: number) => string } const EDITOR_PANEL_COPY: Record = { 'zh-CN': { save: '保存', saving: '保存中…', unsaved: '未保存', saved: '已保存', unavailable: '当前结果无法使用可编辑的 OpenPencil 画布。', loading: '正在加载可编辑的 OpenPencil 画布…', errorTitle: 'OpenPencil 编辑器不可用', pngFallback: '打开 PNG 预览', editorTitle: title => `OpenPencil 编辑器:${title}`, editorTimeout: 'OpenPencil 编辑器未能及时就绪', editorBusy: '另一个 OpenPencil 编辑器仍有未保存的更改。', discard: 'OpenPencil 中有未保存的更改,确定关闭并放弃吗?', saveConflict: serverVersion => `OpenPencil 保存冲突(服务器版本 ${serverVersion})`, syncConflict: serverVersion => `源文件已在编辑器外部更改(服务器版本 ${serverVersion}),已停止保存。`, }, 'en-US': { save: 'Save', saving: 'Saving…', unsaved: 'Unsaved', saved: 'Saved', unavailable: 'Editable OpenPencil canvas is not available for this result.', loading: 'Loading editable OpenPencil canvas…', errorTitle: 'OpenPencil editor unavailable', pngFallback: 'Open PNG fallback', editorTitle: title => `OpenPencil editor: ${title}`, editorTimeout: 'OpenPencil editor did not become ready', editorBusy: 'Another OpenPencil editor still has unsaved changes.', discard: 'OpenPencil has unsaved changes. Close and discard them?', saveConflict: serverVersion => `OpenPencil save conflict (server v${serverVersion})`, syncConflict: serverVersion => `The source changed outside this editor (server v${serverVersion}). Save was stopped.`, }, } /** Chrome copy for the locale already resolved by the DSH host. */ export function editorPanelCopy(locale: EditorLocale): EditorPanelCopy { return EDITOR_PANEL_COPY[locale] } interface EditorBootOptions { signal?: AbortSignal fetcher?: typeof fetch sessionId?: string } interface EditorCloseOptions { fetcher?: typeof fetch dirty?: boolean keepalive?: boolean } function launchRequest(fetcher: typeof fetch, url: string, signal?: AbortSignal, sessionId?: string): Promise { return fetcher(editorControlUrl(url), { method: 'POST', credentials: 'same-origin', ...(sessionId === undefined ? {} : { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId }), }), ...(signal === undefined ? {} : { signal }), }) } /** * Launch one editor, renewing exactly once when a replayed launch capability * has expired. A refreshed capability is never persisted back into the Tool * block, and only same-origin control routes can receive document metadata. */ export async function launchManagedEditor( editor: NonNullable, document: NonNullable, options: EditorBootOptions = {}, ): Promise { const fetcher = options.fetcher ?? fetch let launchUrl = editor.launchUrl let renewed = false let response = await launchRequest(fetcher, launchUrl, options.signal, options.sessionId) if (response.status === 410) { if (document.path === undefined) { throw new Error('OpenPencil editor launch expired and cannot be refreshed without a source path') } const refreshUrl = editorControlUrl(editor.refreshUrl ?? DEFAULT_REFRESH_URL) const refreshResponse = await fetcher(refreshUrl, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ launchUrl: editor.launchUrl, sourcePath: document.path, documentUrl: document.url, }), ...(options.signal === undefined ? {} : { signal: options.signal }), }) launchUrl = refreshedLaunchUrlOf(await responseJson(refreshResponse, 'OpenPencil editor refresh')) renewed = true // One bounded retry only. A second 410 is surfaced by responseJson and // never loops back through refresh. response = await launchRequest(fetcher, launchUrl, options.signal, options.sessionId) } const launch = launchResponseOf(await responseJson(response, 'OpenPencil editor launch')) return renewed ? { ...launch, renewed: true } : launch } /** Prefer the daemon's current source; fetch the immutable snapshot only for old hosts. */ export async function prepareManagedEditor( editor: NonNullable, document: NonNullable, options: EditorBootOptions = {}, ): Promise { const fetcher = options.fetcher ?? fetch const launch = await launchManagedEditor(editor, document, { ...options, fetcher }) try { if (launch.docJson !== undefined) return { launch, documentJson: launch.docJson } if (editor.refreshUrl !== undefined || launch.renewed === true) { throw new Error('OpenPencil editor launch omitted current docJson') } const documentResponse = await fetcher(editorControlUrl(document.url), { credentials: 'same-origin', ...(options.signal === undefined ? {} : { signal: options.signal }), }) if (!documentResponse.ok) throw new Error(`OpenPencil document request failed (${documentResponse.status})`) return { launch, documentJson: await documentResponse.text() } } catch (error) { // The daemon already exists once launchManagedEditor returns. A cancelled // fallback document fetch or contract error must not strand that process. await closeManagedEditorLaunch(launch, { fetcher, keepalive: true }).catch(() => {}) throw error } } /** Close exactly the managed session returned by one launch response. */ export async function closeManagedEditorLaunch( launch: LaunchResponse, options: EditorCloseOptions = {}, ): Promise { const fetcher = options.fetcher ?? fetch const response = await fetcher(launch.closeUrl, { method: 'DELETE', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: launch.sessionId, dirty: options.dirty ?? false }), ...(options.keepalive === undefined ? {} : { keepalive: options.keepalive }), }) if (!response.ok) throw new Error(`OpenPencil editor close failed (${response.status})`) } /** * Mount-aware boot boundary. React may cancel an effect after its launch POST * has committed; release that precise returned session before ignoring it. */ export async function prepareManagedEditorForMount( editor: NonNullable, document: NonNullable, accept: () => boolean, options: EditorBootOptions = {}, ): Promise { const prepared = await prepareManagedEditor(editor, document, options) if (accept()) return prepared await closeManagedEditorLaunch(prepared.launch, { ...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }), keepalive: true, }) return undefined } const panelStyles: Record = { root: { height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', color: 'var(--dsw-alias-label-primary)', background: 'var(--dsw-alias-bg-base)', }, toolbar: { minHeight: 48, display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px 8px 12px', borderBottom: '1px solid var(--dsw-alias-border-l2)', }, title: { minWidth: 0, marginRight: 'auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: 14, lineHeight: '20px', fontWeight: 500 }, status: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', whiteSpace: 'nowrap' }, button: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 6, color: 'var(--dsw-alias-label-primary)', background: 'var(--dsw-alias-bg-layer-1)', minHeight: 28, padding: '4px 8px', cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, fontWeight: 'inherit', lineHeight: 1, }, stage: { position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden', background: 'var(--dsw-alias-bg-base)' }, iframe: { display: 'block', width: '100%', height: '100%', border: 0, background: 'var(--dsw-alias-bg-base)' }, overlay: { position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 10, padding: 24, textAlign: 'center', color: 'var(--dsw-alias-label-primary)', background: 'var(--dsw-alias-bg-base)', fontSize: 12, }, error: { color: 'var(--dsw-alias-state-error-primary)', maxWidth: 420, overflowWrap: 'anywhere' }, } export type EditorLifecyclePhase = 'launching' | 'loading' | 'ready' | 'saving' | 'error' export interface EditorLifecycleState { dirty: boolean phase: EditorLifecyclePhase } export interface EditorLifecycleController { /** Close through the guarded server capability while the React owner remains mounted. */ requestClose: () => Promise /** Ask the host to persist an opaque, source-scoped daemon recovery snapshot. */ captureRecovery: () => Promise /** Join a save the user already started; never starts a new source write. */ awaitExistingSave: () => Promise /** HMR-only escape hatch: leave the daemon for the server lifecycle when capture failed. */ retainServerSessionOnUnmount: () => void } export const INITIAL_EDITOR_LIFECYCLE_STATE: EditorLifecycleState = { dirty: false, phase: 'launching', } export type EditorUnmountDisposition = 'closed' | 'retained' export interface EditorUnmountState { /** Explicit retention armed after a failed workbench recovery capture. */ retainServerSession: boolean /** Latest client dirty signal. */ dirty: boolean /** False after a successful explicit close cleared the exact launch. */ hasLiveLaunch: boolean } export interface EditorInitRetryTimer { schedule: (callback: () => void, delayMs: number) => T cancel: (handle: T) => void } /** * Start the managed-editor init handshake immediately, then retry on a bounded * interval until the caller stops it after `op-bridge/ready`. * * This deliberately starts before the iframe `load` event. OpenPencil waits * briefly for this token before starting daemon-backed services (including the * account-status request), while a browser may delay `load` until module/Wasm * startup is already complete. Waiting for `load` can therefore make the first * account request run unauthenticated and hide the login button until the * editor's later health refresh. */ export function beginEditorInitRetry( send: () => void, onExhausted: () => void, timer: EditorInitRetryTimer, options: { intervalMs?: number; maxAttempts?: number } = {}, ): () => void { const intervalMs = options.intervalMs ?? 500 const maxAttempts = options.maxAttempts ?? 20 let attempts = 0 let handle: T | undefined let stopped = false const stop = (): void => { if (stopped) return stopped = true if (handle !== undefined) timer.cancel(handle) } const attempt = (): void => { if (stopped) return attempts += 1 send() if (attempts >= maxAttempts) { stop() onExhausted() } } attempt() if (!stopped) handle = timer.schedule(attempt, intervalMs) return stop } /** * Apply the unmount policy without letting React cleanup accidentally issue * DELETE. Dirty live launches are retained by default even when their native * Tool-details owner has no lifecycle controller. */ export function applyManagedEditorUnmountPolicy( state: EditorUnmountState, closeDaemon: () => void, ): EditorUnmountDisposition { if (state.retainServerSession || (state.dirty && state.hasLiveLaunch)) return 'retained' closeDaemon() return 'closed' } /** Editable panel. The daemon is created lazily only while this component is mounted. */ export function ManagedOpenPencilEditor({ grant, colorScheme, locale, sessionId, onTakeoverRequest, onLifecycleState, onLifecycleController, workbenchActions, allowTakeover = true, }: { grant: PresentationGrant colorScheme: EditorColorScheme locale: EditorLocale sessionId: string onTakeoverRequest?: (state: EditorLifecycleState) => boolean onLifecycleState?: (state: EditorLifecycleState) => void onLifecycleController?: (controller: EditorLifecycleController | undefined) => void workbenchActions?: React.ReactNode allowTakeover?: boolean }) { const iframeRef = useRef(null) const launchRef = useRef() const iframeSrcRef = useRef('') const originRef = useRef('') const docJsonRef = useRef('') const colorSchemeRef = useRef(colorScheme) colorSchemeRef.current = colorScheme const localeRef = useRef(locale) localeRef.current = locale const takeoverRequestRef = useRef(onTakeoverRequest) takeoverRequestRef.current = onTakeoverRequest const lifecycleStateRef = useRef(onLifecycleState) lifecycleStateRef.current = onLifecycleState const lifecycleControllerRef = useRef(onLifecycleController) lifecycleControllerRef.current = onLifecycleController const stopInitLoopRef = useRef<() => void>() const selectionPollStopRef = useRef<() => void>() const requestCounterRef = useRef(0) const saveWaitersRef = useRef(new Map) => void; reject: (error: Error) => void }>()) const [phase, setPhase] = useState('launching') const phaseRef = useRef('launching') const [failure, setFailure] = useState('') const [dirty, setDirty] = useState(false) const dirtyRef = useRef(false) const saveInFlightRef = useRef>() const closeDaemonRef = useRef<(dirty?: boolean) => Promise>() const retainServerSessionOnUnmountRef = useRef(false) const restoredRecoveryRef = useRef(false) const documentGrant = grant.document! const editorGrant = grant.editor! const publishLifecycle = useCallback(() => { lifecycleStateRef.current?.({ dirty: dirtyRef.current, phase: phaseRef.current }) }, []) const updatePhase = useCallback((next: EditorLifecyclePhase) => { phaseRef.current = next setPhase(next) publishLifecycle() }, [publishLifecycle]) const updateDirty = useCallback((next: boolean) => { dirtyRef.current = next setDirty(next) publishLifecycle() }, [publishLifecycle]) const post = useCallback((message: Parameters[0]) => { const frame = iframeRef.current?.contentWindow if (frame === null || frame === undefined || originRef.current === '') return frame.postMessage(encodeEditorOutbound(message), originRef.current) }, []) const save = useCallback(async (): Promise => { if (saveInFlightRef.current !== undefined) return saveInFlightRef.current const launch = launchRef.current if (launch === undefined || phaseRef.current === 'launching' || phaseRef.current === 'loading') return false if (!dirtyRef.current) return true const operation = (async (): Promise => { updatePhase('saving') setFailure('') const requestId = `dsh-save-${++requestCounterRef.current}` let snapshotTimer: ReturnType | undefined try { const snapshot = await Promise.race([ new Promise>((resolve, reject) => { saveWaitersRef.current.set(requestId, { resolve, reject }) post({ type: 'op-bridge/snapshot', purpose: 'save', requestId }) }), new Promise((_, reject) => { snapshotTimer = setTimeout(() => { reject(new Error('OpenPencil snapshot timed out')) }, 8_000) }), ]) const response = await fetch(launch.saveUrl, { method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId: launch.sessionId, docJson: snapshot.docJson, generation: snapshot.generation, revision: snapshot.revision, }), }) const saveResponse = await responseJson(response, 'OpenPencil save') rememberEditorSuccessor(editorGrant.launchUrl, saveResponse) post({ type: 'op-bridge/save-committed', generation: snapshot.generation, revision: snapshot.revision }) restoredRecoveryRef.current = false updateDirty(false) updatePhase('ready') return true } catch (error) { setFailure(error instanceof Error ? error.message : String(error)) updatePhase('error') return false } finally { if (snapshotTimer !== undefined) clearTimeout(snapshotTimer) saveWaitersRef.current.delete(requestId) } })() saveInFlightRef.current = operation try { return await operation } finally { if (saveInFlightRef.current === operation) saveInFlightRef.current = undefined } }, [editorGrant.launchUrl, post, updateDirty, updatePhase]) useEffect(() => { publishLifecycle() const controller: EditorLifecycleController = { requestClose: async () => { if (phaseRef.current === 'saving') return false const closeDaemon = closeDaemonRef.current if (closeDaemon === undefined) return false try { await closeDaemon(dirtyRef.current) return true } catch (error) { setFailure(error instanceof Error ? error.message : String(error)) updatePhase('error') return false } }, captureRecovery: async () => { const launch = launchRef.current if (launch?.recoveryUrl === undefined) return false try { await captureManagedEditorRecovery(launch) return true } catch { return false } }, awaitExistingSave: async () => { const pending = saveInFlightRef.current return pending === undefined ? true : pending }, retainServerSessionOnUnmount: () => { retainServerSessionOnUnmountRef.current = true }, } lifecycleControllerRef.current?.(controller) return () => { lifecycleControllerRef.current?.(undefined) } }, [publishLifecycle, updatePhase]) useEffect(() => { let cancelled = false const abort = new AbortController() const coordinatorToken = Symbol('openpencil-editor') const closeDaemon = async (dirtyAtClose = dirtyRef.current): Promise => { selectionPollStopRef.current?.() selectionPollStopRef.current = undefined clearOpenPencilSelection(sessionId, documentGrant.path) const launch = launchRef.current if (launch === undefined) return await closeManagedEditorLaunch(launch, { dirty: dirtyAtClose, keepalive: true }) if (launchRef.current === launch) launchRef.current = undefined } closeDaemonRef.current = closeDaemon const releaseEditor = claimEditor(coordinatorToken, () => { const takeoverRequest = takeoverRequestRef.current const lifecycle = { dirty: dirtyRef.current, phase: phaseRef.current } if (takeoverRequest !== undefined && !takeoverRequest(lifecycle)) return false if (phaseRef.current === 'saving') return false if (takeoverRequest === undefined && !confirmEditorClose( dirtyRef.current, () => window.confirm(editorPanelCopy(localeRef.current).discard), )) return false abort.abort() void closeDaemon(dirtyRef.current) return true }, { replace: allowTakeover }) if (releaseEditor === undefined) { setFailure(editorPanelCopy(localeRef.current).editorBusy) updatePhase('error') return () => { abort.abort() } } const boot = async (): Promise => { try { const bootGrant = editorGrantForBoot(editorGrant) const prepared = await prepareManagedEditorForMount(bootGrant, documentGrant, () => ( !cancelled && !abort.signal.aborted ), { sessionId, }) if (prepared === undefined) return const { launch } = prepared // Publish immediately: recovery restore performs another await and a // failed restore must remain reachable by cleanup instead of leaking // the already-created daemon. launchRef.current = launch let { documentJson } = prepared if (launch.recovery !== undefined) { const recoveryCopy = editorRecoveryCopy(localeRef.current) const message = launch.recovery.sourceChangedSinceCapture ? recoveryCopy.conflict(launch.recovery.sourceName) : recoveryCopy.available(launch.recovery.sourceName) if (window.confirm(message)) { documentJson = await restoreManagedEditorRecovery(launch, launch.recovery) restoredRecoveryRef.current = true } } const origin = editorOrigin(launch.iframeUrl) // Capture first-navigation host presentation exactly once. Later host // theme/locale changes travel over the bridge and never reload the iframe. iframeSrcRef.current = editorIframeUrlWithLocale( editorIframeUrlWithTheme(launch.iframeUrl, colorSchemeRef.current), localeRef.current, ) docJsonRef.current = documentJson originRef.current = origin updatePhase('loading') } catch (error) { // A recovery request may fail after launch has already created a // daemon. Close that exact session now; waiting for component unmount // would strand it behind an error surface. await closeDaemon(dirtyRef.current).catch(() => {}) if (cancelled || abort.signal.aborted) return setFailure(error instanceof Error ? error.message : String(error)) updatePhase('error') } } void boot() return () => { cancelled = true abort.abort() releaseEditor() stopInitLoopRef.current?.() stopInitLoopRef.current = undefined const disposed = new Error('OpenPencil editor closed') for (const waiter of saveWaitersRef.current.values()) waiter.reject(disposed) saveWaitersRef.current.clear() if (closeDaemonRef.current === closeDaemon) closeDaemonRef.current = undefined const disposition = applyManagedEditorUnmountPolicy({ retainServerSession: retainServerSessionOnUnmountRef.current, dirty: dirtyRef.current, hasLiveLaunch: launchRef.current !== undefined, }, () => { void closeDaemon().catch(() => {}) }) if (disposition === 'retained') { selectionPollStopRef.current?.() selectionPollStopRef.current = undefined clearOpenPencilSelection(sessionId, documentGrant.path) } } }, [allowTakeover, documentGrant.path, documentGrant.url, editorGrant.launchUrl, editorGrant.refreshUrl, updatePhase]) const startInitLoop = useCallback((): void => { const launch = launchRef.current if (launch === undefined) return stopInitLoopRef.current?.() stopInitLoopRef.current = beginEditorInitRetry( () => { post({ type: 'op-bridge/init', token: launch.token }) post({ type: 'op-bridge/theme', colorScheme: colorSchemeRef.current }) post({ type: 'op-bridge/locale', locale: localeRef.current }) }, () => { stopInitLoopRef.current = undefined setFailure(editorPanelCopy(localeRef.current).editorTimeout) updatePhase('error') }, { schedule: (callback, delayMs) => window.setInterval(callback, delayMs), cancel: handle => { window.clearInterval(handle) }, }, ) }, [post, updatePhase]) // Begin posting as soon as React commits the iframe. Tying this to `load` // is too late for OpenPencil's managed bootstrap: its Wasm shell may have // already fallen back to an unauthenticated first status request by then. useEffect(() => { if (phase !== 'loading' || launchRef.current === undefined || iframeRef.current === null) return startInitLoop() return () => { stopInitLoopRef.current?.() stopInitLoopRef.current = undefined } }, [phase, startInitLoop]) useEffect(() => { if (phase !== 'ready' && phase !== 'saving') return const selectionUrl = launchRef.current?.selectionUrl if (selectionUrl === undefined) return const stop = startEditorSelectionPolling({ url: selectionUrl, onValue: value => { if (!isRecord(value)) return const selection = liveSelectionOf(value.selection) if (selection !== undefined) publishOpenPencilSelection(sessionId, selection) }, onStop: () => { clearOpenPencilSelection(sessionId, documentGrant.path) }, }) selectionPollStopRef.current = stop return () => { if (selectionPollStopRef.current === stop) selectionPollStopRef.current = undefined stop() } }, [documentGrant.path, phase, sessionId]) useEffect(() => { const listener = (event: MessageEvent): void => { const message = editorMessageFrom(event, iframeRef.current?.contentWindow ?? null, originRef.current) if (message === undefined) return switch (message.type) { case 'op-bridge/ready': stopInitLoopRef.current?.() stopInitLoopRef.current = undefined post({ type: 'op-bridge/theme', colorScheme: colorSchemeRef.current }) post({ type: 'op-bridge/locale', locale: localeRef.current }) post({ type: 'op-bridge/open-document', json: docJsonRef.current }) break case 'op-bridge/opened': updatePhase('ready') if (restoredRecoveryRef.current) updateDirty(true) break case 'op-bridge/dirty-changed': updateDirty(restoredRecoveryRef.current || message.dirty) break case 'op-bridge/snapshot-result': saveWaitersRef.current.get(message.requestId)?.resolve(message) break case 'op-bridge/snapshot-conflict': saveWaitersRef.current.get(message.requestId)?.reject( new Error(editorPanelCopy(localeRef.current).saveConflict(message.serverVersion)), ) break case 'op-bridge/sync-conflict': setFailure(editorPanelCopy(localeRef.current).syncConflict(message.serverVersion)) updatePhase('error') break case 'op-shell/save': void save() break case 'op-shell/copy': void navigator.clipboard?.writeText(message.text).catch(() => {}) break case 'op-bridge/conflict-resolved': break } } window.addEventListener('message', listener) return () => { window.removeEventListener('message', listener) } }, [post, save, updateDirty, updatePhase]) useEffect(() => { post({ type: 'op-bridge/theme', colorScheme }) }, [colorScheme, post]) useEffect(() => { post({ type: 'op-bridge/locale', locale }) }, [locale, post]) useEffect(() => { const beforeUnload = (event: BeforeUnloadEvent): void => { if (!dirtyRef.current) return event.preventDefault() event.returnValue = '' } window.addEventListener('beforeunload', beforeUnload) return () => { window.removeEventListener('beforeunload', beforeUnload) } }, []) const title = documentGrant.path?.replaceAll('\\', '/').split('/').at(-1) ?? 'OpenPencil' const copy = editorPanelCopy(locale) return (
{title} {phase === 'saving' ? copy.saving : dirty ? copy.unsaved : phase === 'ready' ? copy.saved : ''} {workbenchActions}
{launchRef.current !== undefined ? (