diff --git a/src/components/editor-page/document-bar/document-info/document-info-line-word-count.tsx b/src/components/editor-page/document-bar/document-info/document-info-line-word-count.tsx index 46735a738..48a127fb5 100644 --- a/src/components/editor-page/document-bar/document-info/document-info-line-word-count.tsx +++ b/src/components/editor-page/document-bar/document-info/document-info-line-word-count.tsx @@ -10,6 +10,7 @@ import { ShowIf } from '../../../common/show-if/show-if' import { DocumentInfoLine } from './document-info-line' import { UnitalicBoldText } from './unitalic-bold-text' import { useIFrameEditorToRendererCommunicator } from '../../render-context/iframe-editor-to-renderer-communicator-context-provider' +import { useApplicationState } from '../../../../hooks/common/use-application-state' /** * Creates a new info line for the document information dialog that holds the @@ -19,17 +20,23 @@ export const DocumentInfoLineWordCount: React.FC = () => { useTranslation() const iframeEditorToRendererCommunicator = useIFrameEditorToRendererCommunicator() const [wordCount, setWordCount] = useState(null) + const rendererReady = useApplicationState((state) => state.editorConfig.rendererReady) useEffect(() => { - iframeEditorToRendererCommunicator?.onWordCountCalculated((words) => { + iframeEditorToRendererCommunicator.onWordCountCalculated((words) => { setWordCount(words) }) - iframeEditorToRendererCommunicator?.sendGetWordCount() return () => { - iframeEditorToRendererCommunicator?.onWordCountCalculated(undefined) + iframeEditorToRendererCommunicator.onWordCountCalculated(undefined) } }, [iframeEditorToRendererCommunicator, setWordCount]) + useEffect(() => { + if (rendererReady) { + iframeEditorToRendererCommunicator.sendGetWordCount() + } + }, [iframeEditorToRendererCommunicator, rendererReady]) + return ( diff --git a/src/components/editor-page/render-context/iframe-renderer-to-editor-communicator-context-provider.tsx b/src/components/editor-page/render-context/iframe-renderer-to-editor-communicator-context-provider.tsx index 0033ab55e..2d03fb544 100644 --- a/src/components/editor-page/render-context/iframe-renderer-to-editor-communicator-context-provider.tsx +++ b/src/components/editor-page/render-context/iframe-renderer-to-editor-communicator-context-provider.tsx @@ -31,7 +31,7 @@ export const IframeRendererToEditorCommunicatorContextProvider: React.FC = ({ ch const editorOrigin = useSelector((state: ApplicationState) => state.config.iframeCommunication.editorOrigin) const currentIFrameCommunicator = useMemo(() => { const newCommunicator = new IframeRendererToEditorCommunicator() - newCommunicator.setOtherSide(window.parent, editorOrigin) + newCommunicator.setMessageTarget(window.parent, editorOrigin) return newCommunicator }, [editorOrigin]) diff --git a/src/components/editor-page/renderer-pane/hooks/use-on-iframe-load.ts b/src/components/editor-page/renderer-pane/hooks/use-on-iframe-load.ts index 2af724262..b70af9603 100644 --- a/src/components/editor-page/renderer-pane/hooks/use-on-iframe-load.ts +++ b/src/components/editor-page/renderer-pane/hooks/use-on-iframe-load.ts @@ -19,12 +19,12 @@ export const useOnIframeLoad = ( return useCallback(() => { const frame = frameReference.current if (!frame || !frame.contentWindow) { - iframeCommunicator.unsetOtherSide() + iframeCommunicator.unsetMessageTarget() return } if (sendToRenderPage.current) { - iframeCommunicator.setOtherSide(frame.contentWindow, rendererOrigin) + iframeCommunicator.setMessageTarget(frame.contentWindow, rendererOrigin) sendToRenderPage.current = false return } else { diff --git a/src/components/editor-page/renderer-pane/render-iframe.tsx b/src/components/editor-page/renderer-pane/render-iframe.tsx index d1dd5da3a..ff7f5c6c1 100644 --- a/src/components/editor-page/renderer-pane/render-iframe.tsx +++ b/src/components/editor-page/renderer-pane/render-iframe.tsx @@ -7,6 +7,7 @@ import equal from 'fast-deep-equal' import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react' import { useApplicationState } from '../../../hooks/common/use-application-state' import { useIsDarkModeActivated } from '../../../hooks/common/use-is-dark-mode-activated' +import { setRendererReady } from '../../../redux/editor/methods' import { isTestMode } from '../../../utils/test-modes' import { RendererProps } from '../../render-page/markdown-document' import { ImageDetails, RendererType } from '../../render-page/rendering-message' @@ -16,7 +17,6 @@ import { useOnIframeLoad } from './hooks/use-on-iframe-load' import { ShowOnPropChangeImageLightbox } from './show-on-prop-change-image-lightbox' export interface RenderIframeProps extends RendererProps { - onRendererReadyChange?: (rendererReady: boolean) => void rendererType: RendererType forcedDarkMode?: boolean frameClasses?: string @@ -31,13 +31,11 @@ export const RenderIframe: React.FC = ({ onScroll, onMakeScrollSource, frameClasses, - onRendererReadyChange, rendererType, forcedDarkMode }) => { const savedDarkMode = useIsDarkModeActivated() const darkMode = forcedDarkMode ?? savedDarkMode - const [rendererReady, setRendererReady] = useState(false) const [lightboxDetails, setLightboxDetails] = useState(undefined) const frameReference = useRef(null) @@ -54,29 +52,51 @@ export const RenderIframe: React.FC = ({ ) const [frameHeight, setFrameHeight] = useState(0) - useEffect(() => { - onRendererReadyChange?.(rendererReady) - }, [onRendererReadyChange, rendererReady]) + const rendererReady = useApplicationState((state) => state.editorConfig.rendererReady) - useEffect(() => () => iframeCommunicator.unregisterEventListener(), [iframeCommunicator]) useEffect( - () => iframeCommunicator.onFirstHeadingChange(onFirstHeadingChange), - [iframeCommunicator, onFirstHeadingChange] + () => () => { + iframeCommunicator.unregisterEventListener() + setRendererReady(false) + }, + [iframeCommunicator] ) - useEffect( - () => iframeCommunicator.onFrontmatterChange(onFrontmatterChange), - [iframeCommunicator, onFrontmatterChange] - ) - useEffect(() => iframeCommunicator.onSetScrollState(onScroll), [iframeCommunicator, onScroll]) - useEffect( - () => iframeCommunicator.onSetScrollSourceToRenderer(onMakeScrollSource), - [iframeCommunicator, onMakeScrollSource] - ) - useEffect( - () => iframeCommunicator.onTaskCheckboxChange(onTaskCheckedChange), - [iframeCommunicator, onTaskCheckedChange] - ) - useEffect(() => iframeCommunicator.onImageClicked(setLightboxDetails), [iframeCommunicator]) + + useEffect(() => { + iframeCommunicator.onFirstHeadingChange(onFirstHeadingChange) + return () => iframeCommunicator.onFirstHeadingChange(undefined) + }, [iframeCommunicator, onFirstHeadingChange]) + + useEffect(() => { + iframeCommunicator.onFrontmatterChange(onFrontmatterChange) + return () => iframeCommunicator.onFrontmatterChange(undefined) + }, [iframeCommunicator, onFrontmatterChange]) + + useEffect(() => { + iframeCommunicator.onSetScrollState(onScroll) + return () => iframeCommunicator.onSetScrollState(undefined) + }, [iframeCommunicator, onScroll]) + + useEffect(() => { + iframeCommunicator.onSetScrollSourceToRenderer(onMakeScrollSource) + return () => iframeCommunicator.onSetScrollSourceToRenderer(undefined) + }, [iframeCommunicator, onMakeScrollSource]) + + useEffect(() => { + iframeCommunicator.onTaskCheckboxChange(onTaskCheckedChange) + return () => iframeCommunicator.onTaskCheckboxChange(undefined) + }, [iframeCommunicator, onTaskCheckedChange]) + + useEffect(() => { + iframeCommunicator.onImageClicked(setLightboxDetails) + return () => iframeCommunicator.onImageClicked(undefined) + }, [iframeCommunicator]) + + useEffect(() => { + iframeCommunicator.onHeightChange(setFrameHeight) + return () => iframeCommunicator.onHeightChange(undefined) + }, [iframeCommunicator]) + useEffect(() => { iframeCommunicator.onRendererReady(() => { iframeCommunicator.sendSetBaseConfiguration({ @@ -85,8 +105,8 @@ export const RenderIframe: React.FC = ({ }) setRendererReady(true) }) - }, [darkMode, rendererType, iframeCommunicator, rendererReady, scrollState]) - useEffect(() => iframeCommunicator.onHeightChange(setFrameHeight), [iframeCommunicator]) + return () => iframeCommunicator.onRendererReady(undefined) + }, [iframeCommunicator, rendererType]) useEffect(() => { if (rendererReady) { diff --git a/src/components/intro-page/intro-page.tsx b/src/components/intro-page/intro-page.tsx index b4f92dc0f..4016c2071 100644 --- a/src/components/intro-page/intro-page.tsx +++ b/src/components/intro-page/intro-page.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import React, { useState } from 'react' +import React from 'react' import { Trans } from 'react-i18next' import { Branding } from '../common/branding/branding' import { @@ -20,10 +20,11 @@ import { ShowIf } from '../common/show-if/show-if' import { RendererType } from '../render-page/rendering-message' import { WaitSpinner } from '../common/wait-spinner/wait-spinner' import { IframeEditorToRendererCommunicatorContextProvider } from '../editor-page/render-context/iframe-editor-to-renderer-communicator-context-provider' +import { useApplicationState } from '../../hooks/common/use-application-state' export const IntroPage: React.FC = () => { const introPageContent = useIntroPageContent() - const [rendererReady, setRendererReady] = useState(true) + const rendererReady = useApplicationState((state) => state.editorConfig.rendererReady) return ( @@ -45,7 +46,6 @@ export const IntroPage: React.FC = () => { diff --git a/src/components/render-page/iframe-communicator.ts b/src/components/render-page/iframe-communicator.ts index 8494395de..f2ba00647 100644 --- a/src/components/render-page/iframe-communicator.ts +++ b/src/components/render-page/iframe-communicator.ts @@ -4,38 +4,74 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +/** + * Error that will be thrown if a message couldn't be sent. + */ +export class IframeCommunicatorSendingError extends Error {} + +/** + * Base class for communication between renderer and editor. + */ export abstract class IframeCommunicator { - protected otherSide?: Window - protected otherOrigin?: string + private messageTarget?: Window + private targetOrigin?: string + private communicationEnabled: boolean constructor() { window.addEventListener('message', this.handleEvent.bind(this)) + this.communicationEnabled = false } public unregisterEventListener(): void { window.removeEventListener('message', this.handleEvent.bind(this)) } - public setOtherSide(otherSide: Window, otherOrigin: string): void { - this.otherSide = otherSide - this.otherOrigin = otherOrigin + /** + * Sets the target for message sending. + * Messages can be sent as soon as the communication is enabled. + * + * @see enableCommunication + * @param otherSide The target {@link Window} that should receive the messages. + * @param otherOrigin The origin from the URL of the target. If this isn't correct then the message sending will produce CORS errors. + */ + public setMessageTarget(otherSide: Window, otherOrigin: string): void { + this.messageTarget = otherSide + this.targetOrigin = otherOrigin + this.communicationEnabled = false } - public unsetOtherSide(): void { - this.otherSide = undefined - this.otherOrigin = undefined + /** + * Unsets the message target. Should be used if the old target isn't available anymore. + */ + public unsetMessageTarget(): void { + this.messageTarget = undefined + this.targetOrigin = undefined + this.communicationEnabled = false } - public getOtherSide(): Window | undefined { - return this.otherSide + /** + * Enables the message communication. + * Should be called as soon as the other sides is ready to receive messages. + */ + protected enableCommunication(): void { + this.communicationEnabled = true } + /** + * Sends a message to the message target. + * + * @param message The message to send. + */ protected sendMessageToOtherSide(message: SEND): void { - if (this.otherSide === undefined || this.otherOrigin === undefined) { - console.error("Can't send message because otherSide is null", message) - return + if (this.messageTarget === undefined || this.targetOrigin === undefined) { + throw new IframeCommunicatorSendingError(`Other side is not set.\nMessage was: ${JSON.stringify(message)}`) } - this.otherSide.postMessage(message, this.otherOrigin) + if (!this.communicationEnabled) { + throw new IframeCommunicatorSendingError( + `Communication isn't enabled. Maybe the other side is not ready?\nMessage was: ${JSON.stringify(message)}` + ) + } + this.messageTarget.postMessage(message, this.targetOrigin) } protected abstract handleEvent(event: MessageEvent): void diff --git a/src/components/render-page/iframe-editor-to-renderer-communicator.ts b/src/components/render-page/iframe-editor-to-renderer-communicator.ts index da0bea983..efd1c1a01 100644 --- a/src/components/render-page/iframe-editor-to-renderer-communicator.ts +++ b/src/components/render-page/iframe-editor-to-renderer-communicator.ts @@ -106,6 +106,7 @@ export class IframeEditorToRendererCommunicator extends IframeCommunicator< const renderMessage = event.data switch (renderMessage.type) { case RenderIframeMessageType.RENDERER_READY: + this.enableCommunication() this.onRendererReadyHandler?.() return false case RenderIframeMessageType.SET_SCROLL_SOURCE_TO_RENDERER: diff --git a/src/components/render-page/iframe-markdown-renderer.tsx b/src/components/render-page/iframe-markdown-renderer.tsx index f51f38cc7..14692d2da 100644 --- a/src/components/render-page/iframe-markdown-renderer.tsx +++ b/src/components/render-page/iframe-markdown-renderer.tsx @@ -38,7 +38,7 @@ export const IframeMarkdownRenderer: React.FC = () => { useEffect(() => iframeCommunicator.onSetDarkMode(setDarkMode), [iframeCommunicator]) useEffect(() => iframeCommunicator.onSetScrollState(setScrollState), [iframeCommunicator, scrollState]) useEffect( - () => iframeCommunicator?.onGetWordCount(countWordsInRenderedDocument), + () => iframeCommunicator.onGetWordCount(countWordsInRenderedDocument), [iframeCommunicator, countWordsInRenderedDocument] ) diff --git a/src/components/render-page/iframe-renderer-to-editor-communicator.ts b/src/components/render-page/iframe-renderer-to-editor-communicator.ts index c513092af..6a9c83e85 100644 --- a/src/components/render-page/iframe-renderer-to-editor-communicator.ts +++ b/src/components/render-page/iframe-renderer-to-editor-communicator.ts @@ -46,6 +46,7 @@ export class IframeRendererToEditorCommunicator extends IframeCommunicator< } public sendRendererReady(): void { + this.enableCommunication() this.sendMessageToOtherSide({ type: RenderIframeMessageType.RENDERER_READY }) diff --git a/src/redux/editor/methods.ts b/src/redux/editor/methods.ts index c3a0bcb80..73a3c5876 100644 --- a/src/redux/editor/methods.ts +++ b/src/redux/editor/methods.ts @@ -14,7 +14,8 @@ import { SetEditorLigaturesAction, SetEditorPreferencesAction, SetEditorSmartPasteAction, - SetEditorSyncScrollAction + SetEditorSyncScrollAction, + SetRendererReadyAction } from './types' export const loadFromLocalStorage = (): EditorConfig | undefined => { @@ -46,6 +47,19 @@ export const setEditorMode = (editorMode: EditorMode): void => { store.dispatch(action) } +/** + * Dispatches a global application state change for the "renderer ready" state. + * + * @param rendererReady The new renderer ready state. + */ +export const setRendererReady = (rendererReady: boolean): void => { + const action: SetRendererReadyAction = { + type: EditorConfigActionType.SET_RENDERER_READY, + rendererReady + } + store.dispatch(action) +} + export const setEditorSyncScroll = (syncScroll: boolean): void => { const action: SetEditorSyncScrollAction = { type: EditorConfigActionType.SET_SYNC_SCROLL, diff --git a/src/redux/editor/reducers.ts b/src/redux/editor/reducers.ts index ddabc908a..e3d5bc39e 100644 --- a/src/redux/editor/reducers.ts +++ b/src/redux/editor/reducers.ts @@ -15,7 +15,8 @@ import { SetEditorLigaturesAction, SetEditorPreferencesAction, SetEditorSmartPasteAction, - SetEditorSyncScrollAction + SetEditorSyncScrollAction, + SetRendererReadyAction } from './types' const initialState: EditorConfig = { @@ -23,6 +24,7 @@ const initialState: EditorConfig = { ligatures: true, syncScroll: true, smartPaste: true, + rendererReady: false, preferences: { theme: 'one-dark', keyMap: 'sublime', @@ -55,6 +57,11 @@ export const EditorConfigReducer: Reducer = ( } saveToLocalStorage(newState) return newState + case EditorConfigActionType.SET_RENDERER_READY: + return { + ...state, + rendererReady: (action as SetRendererReadyAction).rendererReady + } case EditorConfigActionType.SET_LIGATURES: newState = { ...state, diff --git a/src/redux/editor/types.ts b/src/redux/editor/types.ts index e0d5528b4..032995151 100644 --- a/src/redux/editor/types.ts +++ b/src/redux/editor/types.ts @@ -13,7 +13,8 @@ export enum EditorConfigActionType { SET_SYNC_SCROLL = 'editor/syncScroll/set', MERGE_EDITOR_PREFERENCES = 'editor/preferences/merge', SET_LIGATURES = 'editor/preferences/setLigatures', - SET_SMART_PASTE = 'editor/preferences/setSmartPaste' + SET_SMART_PASTE = 'editor/preferences/setSmartPaste', + SET_RENDERER_READY = 'editor/rendererReady/set' } export interface EditorConfig { @@ -21,6 +22,7 @@ export interface EditorConfig { syncScroll: boolean ligatures: boolean smartPaste: boolean + rendererReady: boolean preferences: EditorConfiguration } @@ -28,6 +30,10 @@ export interface EditorConfigActions extends Action { type: EditorConfigActionType } +export interface SetRendererReadyAction extends EditorConfigActions { + rendererReady: boolean +} + export interface SetEditorSyncScrollAction extends EditorConfigActions { syncScroll: boolean }