Add renderer ready state to global application state

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2021-06-18 20:42:17 +02:00
parent 2dad1d565e
commit cfb2de8909
12 changed files with 144 additions and 52 deletions

View file

@ -10,6 +10,7 @@ import { ShowIf } from '../../../common/show-if/show-if'
import { DocumentInfoLine } from './document-info-line' import { DocumentInfoLine } from './document-info-line'
import { UnitalicBoldText } from './unitalic-bold-text' import { UnitalicBoldText } from './unitalic-bold-text'
import { useIFrameEditorToRendererCommunicator } from '../../render-context/iframe-editor-to-renderer-communicator-context-provider' 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 * Creates a new info line for the document information dialog that holds the
@ -19,17 +20,23 @@ export const DocumentInfoLineWordCount: React.FC = () => {
useTranslation() useTranslation()
const iframeEditorToRendererCommunicator = useIFrameEditorToRendererCommunicator() const iframeEditorToRendererCommunicator = useIFrameEditorToRendererCommunicator()
const [wordCount, setWordCount] = useState<number | null>(null) const [wordCount, setWordCount] = useState<number | null>(null)
const rendererReady = useApplicationState((state) => state.editorConfig.rendererReady)
useEffect(() => { useEffect(() => {
iframeEditorToRendererCommunicator?.onWordCountCalculated((words) => { iframeEditorToRendererCommunicator.onWordCountCalculated((words) => {
setWordCount(words) setWordCount(words)
}) })
iframeEditorToRendererCommunicator?.sendGetWordCount()
return () => { return () => {
iframeEditorToRendererCommunicator?.onWordCountCalculated(undefined) iframeEditorToRendererCommunicator.onWordCountCalculated(undefined)
} }
}, [iframeEditorToRendererCommunicator, setWordCount]) }, [iframeEditorToRendererCommunicator, setWordCount])
useEffect(() => {
if (rendererReady) {
iframeEditorToRendererCommunicator.sendGetWordCount()
}
}, [iframeEditorToRendererCommunicator, rendererReady])
return ( return (
<DocumentInfoLine icon={'align-left'} size={'2x'}> <DocumentInfoLine icon={'align-left'} size={'2x'}>
<ShowIf condition={wordCount === null}> <ShowIf condition={wordCount === null}>

View file

@ -31,7 +31,7 @@ export const IframeRendererToEditorCommunicatorContextProvider: React.FC = ({ ch
const editorOrigin = useSelector((state: ApplicationState) => state.config.iframeCommunication.editorOrigin) const editorOrigin = useSelector((state: ApplicationState) => state.config.iframeCommunication.editorOrigin)
const currentIFrameCommunicator = useMemo<IframeRendererToEditorCommunicator>(() => { const currentIFrameCommunicator = useMemo<IframeRendererToEditorCommunicator>(() => {
const newCommunicator = new IframeRendererToEditorCommunicator() const newCommunicator = new IframeRendererToEditorCommunicator()
newCommunicator.setOtherSide(window.parent, editorOrigin) newCommunicator.setMessageTarget(window.parent, editorOrigin)
return newCommunicator return newCommunicator
}, [editorOrigin]) }, [editorOrigin])

View file

@ -19,12 +19,12 @@ export const useOnIframeLoad = (
return useCallback(() => { return useCallback(() => {
const frame = frameReference.current const frame = frameReference.current
if (!frame || !frame.contentWindow) { if (!frame || !frame.contentWindow) {
iframeCommunicator.unsetOtherSide() iframeCommunicator.unsetMessageTarget()
return return
} }
if (sendToRenderPage.current) { if (sendToRenderPage.current) {
iframeCommunicator.setOtherSide(frame.contentWindow, rendererOrigin) iframeCommunicator.setMessageTarget(frame.contentWindow, rendererOrigin)
sendToRenderPage.current = false sendToRenderPage.current = false
return return
} else { } else {

View file

@ -7,6 +7,7 @@ import equal from 'fast-deep-equal'
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react' import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { useApplicationState } from '../../../hooks/common/use-application-state' import { useApplicationState } from '../../../hooks/common/use-application-state'
import { useIsDarkModeActivated } from '../../../hooks/common/use-is-dark-mode-activated' import { useIsDarkModeActivated } from '../../../hooks/common/use-is-dark-mode-activated'
import { setRendererReady } from '../../../redux/editor/methods'
import { isTestMode } from '../../../utils/test-modes' import { isTestMode } from '../../../utils/test-modes'
import { RendererProps } from '../../render-page/markdown-document' import { RendererProps } from '../../render-page/markdown-document'
import { ImageDetails, RendererType } from '../../render-page/rendering-message' 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' import { ShowOnPropChangeImageLightbox } from './show-on-prop-change-image-lightbox'
export interface RenderIframeProps extends RendererProps { export interface RenderIframeProps extends RendererProps {
onRendererReadyChange?: (rendererReady: boolean) => void
rendererType: RendererType rendererType: RendererType
forcedDarkMode?: boolean forcedDarkMode?: boolean
frameClasses?: string frameClasses?: string
@ -31,13 +31,11 @@ export const RenderIframe: React.FC<RenderIframeProps> = ({
onScroll, onScroll,
onMakeScrollSource, onMakeScrollSource,
frameClasses, frameClasses,
onRendererReadyChange,
rendererType, rendererType,
forcedDarkMode forcedDarkMode
}) => { }) => {
const savedDarkMode = useIsDarkModeActivated() const savedDarkMode = useIsDarkModeActivated()
const darkMode = forcedDarkMode ?? savedDarkMode const darkMode = forcedDarkMode ?? savedDarkMode
const [rendererReady, setRendererReady] = useState<boolean>(false)
const [lightboxDetails, setLightboxDetails] = useState<ImageDetails | undefined>(undefined) const [lightboxDetails, setLightboxDetails] = useState<ImageDetails | undefined>(undefined)
const frameReference = useRef<HTMLIFrameElement>(null) const frameReference = useRef<HTMLIFrameElement>(null)
@ -54,29 +52,51 @@ export const RenderIframe: React.FC<RenderIframeProps> = ({
) )
const [frameHeight, setFrameHeight] = useState<number>(0) const [frameHeight, setFrameHeight] = useState<number>(0)
useEffect(() => { const rendererReady = useApplicationState((state) => state.editorConfig.rendererReady)
onRendererReadyChange?.(rendererReady)
}, [onRendererReadyChange, rendererReady])
useEffect(() => () => iframeCommunicator.unregisterEventListener(), [iframeCommunicator])
useEffect( useEffect(
() => iframeCommunicator.onFirstHeadingChange(onFirstHeadingChange), () => () => {
[iframeCommunicator, onFirstHeadingChange] iframeCommunicator.unregisterEventListener()
setRendererReady(false)
},
[iframeCommunicator]
) )
useEffect(
() => iframeCommunicator.onFrontmatterChange(onFrontmatterChange), useEffect(() => {
[iframeCommunicator, onFrontmatterChange] iframeCommunicator.onFirstHeadingChange(onFirstHeadingChange)
) return () => iframeCommunicator.onFirstHeadingChange(undefined)
useEffect(() => iframeCommunicator.onSetScrollState(onScroll), [iframeCommunicator, onScroll]) }, [iframeCommunicator, onFirstHeadingChange])
useEffect(
() => iframeCommunicator.onSetScrollSourceToRenderer(onMakeScrollSource), useEffect(() => {
[iframeCommunicator, onMakeScrollSource] iframeCommunicator.onFrontmatterChange(onFrontmatterChange)
) return () => iframeCommunicator.onFrontmatterChange(undefined)
useEffect( }, [iframeCommunicator, onFrontmatterChange])
() => iframeCommunicator.onTaskCheckboxChange(onTaskCheckedChange),
[iframeCommunicator, onTaskCheckedChange] useEffect(() => {
) iframeCommunicator.onSetScrollState(onScroll)
useEffect(() => iframeCommunicator.onImageClicked(setLightboxDetails), [iframeCommunicator]) 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(() => { useEffect(() => {
iframeCommunicator.onRendererReady(() => { iframeCommunicator.onRendererReady(() => {
iframeCommunicator.sendSetBaseConfiguration({ iframeCommunicator.sendSetBaseConfiguration({
@ -85,8 +105,8 @@ export const RenderIframe: React.FC<RenderIframeProps> = ({
}) })
setRendererReady(true) setRendererReady(true)
}) })
}, [darkMode, rendererType, iframeCommunicator, rendererReady, scrollState]) return () => iframeCommunicator.onRendererReady(undefined)
useEffect(() => iframeCommunicator.onHeightChange(setFrameHeight), [iframeCommunicator]) }, [iframeCommunicator, rendererType])
useEffect(() => { useEffect(() => {
if (rendererReady) { if (rendererReady) {

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import React, { useState } from 'react' import React from 'react'
import { Trans } from 'react-i18next' import { Trans } from 'react-i18next'
import { Branding } from '../common/branding/branding' import { Branding } from '../common/branding/branding'
import { import {
@ -20,10 +20,11 @@ import { ShowIf } from '../common/show-if/show-if'
import { RendererType } from '../render-page/rendering-message' import { RendererType } from '../render-page/rendering-message'
import { WaitSpinner } from '../common/wait-spinner/wait-spinner' import { WaitSpinner } from '../common/wait-spinner/wait-spinner'
import { IframeEditorToRendererCommunicatorContextProvider } from '../editor-page/render-context/iframe-editor-to-renderer-communicator-context-provider' 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 = () => { export const IntroPage: React.FC = () => {
const introPageContent = useIntroPageContent() const introPageContent = useIntroPageContent()
const [rendererReady, setRendererReady] = useState<boolean>(true) const rendererReady = useApplicationState((state) => state.editorConfig.rendererReady)
return ( return (
<IframeEditorToRendererCommunicatorContextProvider> <IframeEditorToRendererCommunicatorContextProvider>
@ -45,7 +46,6 @@ export const IntroPage: React.FC = () => {
<RenderIframe <RenderIframe
frameClasses={'w-100 overflow-y-hidden'} frameClasses={'w-100 overflow-y-hidden'}
markdownContent={introPageContent as string} markdownContent={introPageContent as string}
onRendererReadyChange={setRendererReady}
rendererType={RendererType.INTRO} rendererType={RendererType.INTRO}
forcedDarkMode={true} forcedDarkMode={true}
/> />

View file

@ -4,38 +4,74 @@
* SPDX-License-Identifier: AGPL-3.0-only * 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<SEND, RECEIVE> { export abstract class IframeCommunicator<SEND, RECEIVE> {
protected otherSide?: Window private messageTarget?: Window
protected otherOrigin?: string private targetOrigin?: string
private communicationEnabled: boolean
constructor() { constructor() {
window.addEventListener('message', this.handleEvent.bind(this)) window.addEventListener('message', this.handleEvent.bind(this))
this.communicationEnabled = false
} }
public unregisterEventListener(): void { public unregisterEventListener(): void {
window.removeEventListener('message', this.handleEvent.bind(this)) window.removeEventListener('message', this.handleEvent.bind(this))
} }
public setOtherSide(otherSide: Window, otherOrigin: string): void { /**
this.otherSide = otherSide * Sets the target for message sending.
this.otherOrigin = otherOrigin * 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 * Unsets the message target. Should be used if the old target isn't available anymore.
this.otherOrigin = undefined */
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 { protected sendMessageToOtherSide(message: SEND): void {
if (this.otherSide === undefined || this.otherOrigin === undefined) { if (this.messageTarget === undefined || this.targetOrigin === undefined) {
console.error("Can't send message because otherSide is null", message) throw new IframeCommunicatorSendingError(`Other side is not set.\nMessage was: ${JSON.stringify(message)}`)
return
} }
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<RECEIVE>): void protected abstract handleEvent(event: MessageEvent<RECEIVE>): void

View file

@ -106,6 +106,7 @@ export class IframeEditorToRendererCommunicator extends IframeCommunicator<
const renderMessage = event.data const renderMessage = event.data
switch (renderMessage.type) { switch (renderMessage.type) {
case RenderIframeMessageType.RENDERER_READY: case RenderIframeMessageType.RENDERER_READY:
this.enableCommunication()
this.onRendererReadyHandler?.() this.onRendererReadyHandler?.()
return false return false
case RenderIframeMessageType.SET_SCROLL_SOURCE_TO_RENDERER: case RenderIframeMessageType.SET_SCROLL_SOURCE_TO_RENDERER:

View file

@ -38,7 +38,7 @@ export const IframeMarkdownRenderer: React.FC = () => {
useEffect(() => iframeCommunicator.onSetDarkMode(setDarkMode), [iframeCommunicator]) useEffect(() => iframeCommunicator.onSetDarkMode(setDarkMode), [iframeCommunicator])
useEffect(() => iframeCommunicator.onSetScrollState(setScrollState), [iframeCommunicator, scrollState]) useEffect(() => iframeCommunicator.onSetScrollState(setScrollState), [iframeCommunicator, scrollState])
useEffect( useEffect(
() => iframeCommunicator?.onGetWordCount(countWordsInRenderedDocument), () => iframeCommunicator.onGetWordCount(countWordsInRenderedDocument),
[iframeCommunicator, countWordsInRenderedDocument] [iframeCommunicator, countWordsInRenderedDocument]
) )

View file

@ -46,6 +46,7 @@ export class IframeRendererToEditorCommunicator extends IframeCommunicator<
} }
public sendRendererReady(): void { public sendRendererReady(): void {
this.enableCommunication()
this.sendMessageToOtherSide({ this.sendMessageToOtherSide({
type: RenderIframeMessageType.RENDERER_READY type: RenderIframeMessageType.RENDERER_READY
}) })

View file

@ -14,7 +14,8 @@ import {
SetEditorLigaturesAction, SetEditorLigaturesAction,
SetEditorPreferencesAction, SetEditorPreferencesAction,
SetEditorSmartPasteAction, SetEditorSmartPasteAction,
SetEditorSyncScrollAction SetEditorSyncScrollAction,
SetRendererReadyAction
} from './types' } from './types'
export const loadFromLocalStorage = (): EditorConfig | undefined => { export const loadFromLocalStorage = (): EditorConfig | undefined => {
@ -46,6 +47,19 @@ export const setEditorMode = (editorMode: EditorMode): void => {
store.dispatch(action) 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 => { export const setEditorSyncScroll = (syncScroll: boolean): void => {
const action: SetEditorSyncScrollAction = { const action: SetEditorSyncScrollAction = {
type: EditorConfigActionType.SET_SYNC_SCROLL, type: EditorConfigActionType.SET_SYNC_SCROLL,

View file

@ -15,7 +15,8 @@ import {
SetEditorLigaturesAction, SetEditorLigaturesAction,
SetEditorPreferencesAction, SetEditorPreferencesAction,
SetEditorSmartPasteAction, SetEditorSmartPasteAction,
SetEditorSyncScrollAction SetEditorSyncScrollAction,
SetRendererReadyAction
} from './types' } from './types'
const initialState: EditorConfig = { const initialState: EditorConfig = {
@ -23,6 +24,7 @@ const initialState: EditorConfig = {
ligatures: true, ligatures: true,
syncScroll: true, syncScroll: true,
smartPaste: true, smartPaste: true,
rendererReady: false,
preferences: { preferences: {
theme: 'one-dark', theme: 'one-dark',
keyMap: 'sublime', keyMap: 'sublime',
@ -55,6 +57,11 @@ export const EditorConfigReducer: Reducer<EditorConfig, EditorConfigActions> = (
} }
saveToLocalStorage(newState) saveToLocalStorage(newState)
return newState return newState
case EditorConfigActionType.SET_RENDERER_READY:
return {
...state,
rendererReady: (action as SetRendererReadyAction).rendererReady
}
case EditorConfigActionType.SET_LIGATURES: case EditorConfigActionType.SET_LIGATURES:
newState = { newState = {
...state, ...state,

View file

@ -13,7 +13,8 @@ export enum EditorConfigActionType {
SET_SYNC_SCROLL = 'editor/syncScroll/set', SET_SYNC_SCROLL = 'editor/syncScroll/set',
MERGE_EDITOR_PREFERENCES = 'editor/preferences/merge', MERGE_EDITOR_PREFERENCES = 'editor/preferences/merge',
SET_LIGATURES = 'editor/preferences/setLigatures', 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 { export interface EditorConfig {
@ -21,6 +22,7 @@ export interface EditorConfig {
syncScroll: boolean syncScroll: boolean
ligatures: boolean ligatures: boolean
smartPaste: boolean smartPaste: boolean
rendererReady: boolean
preferences: EditorConfiguration preferences: EditorConfiguration
} }
@ -28,6 +30,10 @@ export interface EditorConfigActions extends Action<EditorConfigActionType> {
type: EditorConfigActionType type: EditorConfigActionType
} }
export interface SetRendererReadyAction extends EditorConfigActions {
rendererReady: boolean
}
export interface SetEditorSyncScrollAction extends EditorConfigActions { export interface SetEditorSyncScrollAction extends EditorConfigActions {
syncScroll: boolean syncScroll: boolean
} }