Lock editor until yCollab extension is loaded (#2136)

* Lock editor until yCollab extension is loaded

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-06-19 20:02:51 +02:00 committed by GitHub
parent 70dc2ac09b
commit cf892a11a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 141 additions and 52 deletions

View file

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
@ -13,8 +13,17 @@ declare namespace Cypress {
Cypress.Commands.add('setCodemirrorContent', (content: string) => {
const line = content.split('\n').find((value) => value !== '')
cy.get('.cm-editor').click().get('.cm-content').fill(content)
cy.getByCypressId('editor-pane')
.should('have.attr', 'data-cypress-editor-ready', 'true')
.get('.cm-editor')
.click()
.get('.cm-content')
.fill(content)
if (line) {
cy.get('.cm-editor').find('.cm-line').should('contain.text', line)
cy.getByCypressId('editor-pane')
.should('have.attr', 'data-cypress-editor-ready', 'true')
.get('.cm-editor')
.find('.cm-line')
.should('contain.text', line)
}
})

View file

@ -32,7 +32,10 @@ import { useYDoc } from './hooks/yjs/use-y-doc'
import { useAwareness } from './hooks/yjs/use-awareness'
import { useWebsocketConnection } from './hooks/yjs/use-websocket-connection'
import { useBindYTextToRedux } from './hooks/yjs/use-bind-y-text-to-redux'
import { useInsertInitialNoteContentIntoEditorInMockMode } from './hooks/yjs/use-insert-initial-note-content-into-editor-in-mock-mode'
import { useInsertNoteContentIntoYTextInMockModeEffect } from './hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect'
import { useOnFirstEditorUpdateExtension } from './hooks/yjs/use-on-first-editor-update-extension'
import { useIsConnectionSynced } from './hooks/yjs/use-is-connection-synced'
import { useMarkdownContentYText } from './hooks/yjs/use-markdown-content-y-text'
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
@ -57,13 +60,14 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
const yDoc = useYDoc()
const awareness = useAwareness(yDoc)
const yText = useMemo(() => yDoc.getText('markdownContent'), [yDoc])
useWebsocketConnection(yDoc, awareness)
const yText = useMarkdownContentYText(yDoc)
const websocketConnection = useWebsocketConnection(yDoc, awareness)
const connectionSynced = useIsConnectionSynced(websocketConnection)
useBindYTextToRedux(yText)
const yjsExtension = useCodeMirrorYjsExtension(yText, awareness)
const mockContentExtension = useInsertInitialNoteContentIntoEditorInMockMode(yText)
const [firstEditorUpdateExtension, firstUpdateHappened] = useOnFirstEditorUpdateExtension()
useInsertNoteContentIntoYTextInMockModeEffect(firstUpdateHappened, websocketConnection)
const extensions = useMemo(
() => [
@ -79,7 +83,7 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
cursorActivityExtension,
updateViewContext,
yjsExtension,
...(mockContentExtension ? [mockContentExtension] : [])
firstEditorUpdateExtension
],
[
editorScrollExtension,
@ -88,7 +92,7 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
cursorActivityExtension,
updateViewContext,
yjsExtension,
mockContentExtension
firstEditorUpdateExtension
]
)
@ -107,10 +111,11 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
onTouchStart={onMakeScrollSource}
onMouseEnter={onMakeScrollSource}
{...cypressId('editor-pane')}
{...cypressAttribute('editor-ready', String(codeMirrorRef !== undefined))}>
{...cypressAttribute('editor-ready', String(firstUpdateHappened && connectionSynced))}>
<MaxLengthWarning />
<ToolBar />
<ReactCodeMirror
editable={firstUpdateHappened && connectionSynced}
placeholder={t('editor.placeholder')}
extensions={extensions}
width={'100%'}

View file

@ -7,6 +7,7 @@
import { YDocMessageTransporter } from '@hedgedoc/realtime'
import type { Doc } from 'yjs'
import type { Awareness } from 'y-protocols/awareness'
import { MARKDOWN_CONTENT_CHANNEL_NAME } from './use-markdown-content-y-text'
/**
* A mocked connection that doesn't send or receive any data and is instantly ready.
@ -16,7 +17,17 @@ export class MockConnection extends YDocMessageTransporter {
super(doc, awareness)
this.onOpen()
this.emit('ready')
this.markAsSynced()
}
/**
* Simulates a complete sync from the server by inserting the given content at position 0 of the editor yText channel.
*
* @param content The content to insert
*/
public simulateFirstSync(content: string): void {
const yText = this.doc.getText(MARKDOWN_CONTENT_CHANNEL_NAME)
yText.insert(0, content)
super.markAsSynced()
}
disconnect(): void {

View file

@ -1,40 +0,0 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect, useMemo, useState } from 'react'
import { isMockMode } from '../../../../../utils/test-modes'
import { getGlobalState } from '../../../../../redux'
import type { YText } from 'yjs/dist/src/types/YText'
import type { Extension } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
/**
* When in mock mode this hook inserts the current markdown content into the given yText to write it into the editor.
* This happens only one time because after that the editor writes it changes into the yText which writes it into the redux.
*
* Usually the CodeMirror gets its content from yjs sync via websocket. But in mock mode this connection isn't available.
* That's why this hook inserts the current markdown content, that is currently saved in the global application state
* and was saved there by the {@link NoteLoadingBoundary note loading boundary}, into the y-text to write it into the codemirror.
* This has to be done AFTER the CodeMirror sync extension (yCollab) has been loaded because the extension reacts only to updates of the yText
* and doesn't write the existing content into the editor when being loaded.
*
* @param yText The yText in which the content should be inserted
*/
export const useInsertInitialNoteContentIntoEditorInMockMode = (yText: YText): Extension | undefined => {
const [firstUpdateHappened, setFirstUpdateHappened] = useState<boolean>(false)
useEffect(() => {
if (firstUpdateHappened) {
yText.insert(0, getGlobalState().noteDetails.markdownContent.plain)
}
}, [firstUpdateHappened, yText])
return useMemo(() => {
return isMockMode && !firstUpdateHappened
? EditorView.updateListener.of(() => setFirstUpdateHappened(true))
: undefined
}, [firstUpdateHappened])
}

View file

@ -0,0 +1,35 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect } from 'react'
import { isMockMode } from '../../../../../utils/test-modes'
import { getGlobalState } from '../../../../../redux'
import type { YDocMessageTransporter } from '@hedgedoc/realtime'
import { MockConnection } from './mock-connection'
/**
* When in mock mode this effect inserts the current markdown content into the yDoc of the given connection to simulate a sync from the server.
* This should happen only one time because after that the editor writes its changes into the yText which writes it into the redux.
*
* Usually the CodeMirror gets its content from yjs sync via websocket. But in mock mode this connection isn't available.
* That's why this hook inserts the current markdown content, that is currently saved in the global application state
* and was saved there by the {@link NoteLoadingBoundary note loading boundary}, into the y-text to write it into the codemirror.
* This has to be done AFTER the CodeMirror sync extension (yCollab) has been loaded because the extension reacts only to updates of the yText
* and doesn't write the existing content into the editor when being loaded.
*
* @param connection The connection into whose yDoc the content should be written
* @param firstUpdateHappened Defines if the first update already happened
*/
export const useInsertNoteContentIntoYTextInMockModeEffect = (
firstUpdateHappened: boolean,
connection: YDocMessageTransporter
): void => {
useEffect(() => {
if (firstUpdateHappened && isMockMode && connection instanceof MockConnection) {
connection.simulateFirstSync(getGlobalState().noteDetails.markdownContent.plain)
}
}, [firstUpdateHappened, connection])
}

View file

@ -0,0 +1,28 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useEffect, useState } from 'react'
import type { YDocMessageTransporter } from '@hedgedoc/realtime'
/**
* Checks if the given message transporter has received at least one full synchronisation.
*
* @param connection The connection whose sync status should be checked
*/
export const useIsConnectionSynced = (connection: YDocMessageTransporter): boolean => {
const [editorEnabled, setEditorEnabled] = useState<boolean>(false)
useEffect(() => {
const enableEditor = () => setEditorEnabled(true)
const disableEditor = () => setEditorEnabled(false)
connection.on('synced', enableEditor).on('disconnected', disableEditor)
return () => {
connection.off('synced', enableEditor).off('disconnected', disableEditor)
}
}, [connection])
return editorEnabled
}

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Doc } from 'yjs'
import { useMemo } from 'react'
import type { YText } from 'yjs/dist/src/types/YText'
export const MARKDOWN_CONTENT_CHANNEL_NAME = 'markdownContent'
/**
* Extracts the y-text channel that saves the markdown content from the given yDoc.
*
* @param yDoc The yjs document from which the yText should be extracted
* @return the extracted yText channel
*/
export const useMarkdownContentYText = (yDoc: Doc): YText => {
return useMemo(() => yDoc.getText(MARKDOWN_CONTENT_CHANNEL_NAME), [yDoc])
}

View file

@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo, useState } from 'react'
import { EditorView } from '@codemirror/view'
import type { Extension } from '@codemirror/state'
/**
* Provides an extension that checks when the code mirror, that loads the extension, has its first update.
*
* @return [Extension, boolean] The extension that listens for editor updates and a boolean that defines if the first update already happened
*/
export const useOnFirstEditorUpdateExtension = (): [Extension, boolean] => {
const [firstUpdateHappened, setFirstUpdateHappened] = useState<boolean>(false)
const extension = useMemo(() => EditorView.updateListener.of(() => setFirstUpdateHappened(true)), [])
return [extension, firstUpdateHappened]
}