mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-16 16:14:43 -04:00
refactor: reimplement realtime-communication
This commit refactors a lot of things that are not easy to separate. It replaces the binary protocol of y-protocols with json. It introduces event based message processing. It implements our own code mirror plugins for synchronisation of content and remote cursors Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
67cf1432b2
commit
3a06f84af1
110 changed files with 3920 additions and 2201 deletions
|
@ -20,6 +20,7 @@ import { useUpdateLocalHistoryEntry } from './hooks/use-update-local-history-ent
|
|||
import { Sidebar } from './sidebar/sidebar'
|
||||
import { Splitter } from './splitter/splitter'
|
||||
import type { DualScrollState, ScrollState } from './synced-scroll/scroll-props'
|
||||
import { RealtimeConnectionModal } from './websocket-connection-modal/realtime-connection-modal'
|
||||
import equal from 'fast-deep-equal'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
@ -79,7 +80,6 @@ export const EditorPageContent: React.FC = () => {
|
|||
)
|
||||
|
||||
useApplyDarkMode()
|
||||
|
||||
useUpdateLocalHistoryEntry()
|
||||
|
||||
const setRendererToScrollSource = useCallback(() => {
|
||||
|
@ -129,6 +129,7 @@ export const EditorPageContent: React.FC = () => {
|
|||
<CommunicatorImageLightbox />
|
||||
<HeadMetaProperties />
|
||||
<MotdModal />
|
||||
<RealtimeConnectionModal />
|
||||
<div className={'d-flex flex-column vh-100'}>
|
||||
<AppBar mode={AppBarMode.EDITOR} />
|
||||
<div className={'flex-fill d-flex h-100 w-100 overflow-hidden flex-row'}>
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { ChangeSpec, Transaction } from '@codemirror/state'
|
||||
import { Annotation } from '@codemirror/state'
|
||||
import type { EditorView, PluginValue } from '@codemirror/view'
|
||||
import type { ViewUpdate } from '@codemirror/view'
|
||||
import type { Text as YText } from 'yjs'
|
||||
import type { Transaction as YTransaction, YTextEvent } from 'yjs'
|
||||
|
||||
const syncAnnotation = Annotation.define()
|
||||
|
||||
/**
|
||||
* Synchronizes the content of a codemirror with a {@link YText y.js text channel}.
|
||||
*/
|
||||
export class YTextSyncViewPlugin implements PluginValue {
|
||||
private readonly observer: YTextSyncViewPlugin['onYTextUpdate']
|
||||
private firstUpdate = true
|
||||
|
||||
constructor(private view: EditorView, private readonly yText: YText, pluginLoaded: () => void) {
|
||||
this.observer = this.onYTextUpdate.bind(this)
|
||||
this.yText.observe(this.observer)
|
||||
pluginLoaded()
|
||||
}
|
||||
|
||||
private onYTextUpdate(event: YTextEvent, transaction: YTransaction): void {
|
||||
if (transaction.origin === this) {
|
||||
return
|
||||
}
|
||||
this.view.dispatch({ changes: this.calculateChanges(event), annotations: [syncAnnotation.of(this)] })
|
||||
}
|
||||
|
||||
private calculateChanges(event: YTextEvent): ChangeSpec[] {
|
||||
const [changes] = event.delta.reduce(
|
||||
([changes, position], delta) => {
|
||||
if (delta.insert !== undefined && typeof delta.insert === 'string') {
|
||||
return [[...changes, { from: position, to: position, insert: delta.insert }], position]
|
||||
} else if (delta.delete !== undefined) {
|
||||
return [[...changes, { from: position, to: position + delta.delete, insert: '' }], position + delta.delete]
|
||||
} else if (delta.retain !== undefined) {
|
||||
return [changes, position + delta.retain]
|
||||
} else {
|
||||
return [changes, position]
|
||||
}
|
||||
},
|
||||
[[], 0] as [ChangeSpec[], number]
|
||||
)
|
||||
return this.addDeleteAllChanges(changes)
|
||||
}
|
||||
|
||||
private addDeleteAllChanges(changes: ChangeSpec[]): ChangeSpec[] {
|
||||
if (this.firstUpdate) {
|
||||
this.firstUpdate = false
|
||||
return [{ from: 0, to: this.view.state.doc.length, insert: '' }, ...changes]
|
||||
} else {
|
||||
return changes
|
||||
}
|
||||
}
|
||||
|
||||
public update(update: ViewUpdate): void {
|
||||
if (!update.docChanged) {
|
||||
return
|
||||
}
|
||||
update.transactions
|
||||
.filter((transaction) => transaction.annotation(syncAnnotation) !== this)
|
||||
.forEach((transaction) => this.applyTransaction(transaction))
|
||||
}
|
||||
|
||||
private applyTransaction(transaction: Transaction): void {
|
||||
this.yText.doc?.transact(() => {
|
||||
let positionAdjustment = 0
|
||||
transaction.changes.iterChanges((fromA, toA, fromB, toB, insert) => {
|
||||
const insertText = insert.sliceString(0, insert.length, '\n')
|
||||
if (fromA !== toA) {
|
||||
this.yText.delete(fromA + positionAdjustment, toA - fromA)
|
||||
}
|
||||
if (insertText.length > 0) {
|
||||
this.yText.insert(fromA + positionAdjustment, insertText)
|
||||
}
|
||||
positionAdjustment += insertText.length - (toA - fromA)
|
||||
})
|
||||
}, this)
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.yText.unobserve(this.observer)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import styles from './cursor-colors.module.scss'
|
||||
|
||||
export const createCursorCssClass = (styleIndex: number): string => {
|
||||
return styles[`cursor-${Math.max(Math.min(styleIndex, 7), 0)}`]
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.cursor-0 {
|
||||
--color: #780c0c;
|
||||
}
|
||||
|
||||
.cursor-1 {
|
||||
--color: #ff1111;
|
||||
}
|
||||
|
||||
.cursor-2 {
|
||||
--color: #1149ff;
|
||||
}
|
||||
|
||||
.cursor-3 {
|
||||
--color: #11ff39;
|
||||
}
|
||||
|
||||
.cursor-4 {
|
||||
--color: #cb11ff;
|
||||
}
|
||||
|
||||
.cursor-5 {
|
||||
--color: #ffff00;
|
||||
}
|
||||
|
||||
.cursor-6 {
|
||||
--color: #00fff2;
|
||||
}
|
||||
|
||||
.cursor-7 {
|
||||
--color: #ff8000;
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { createCursorCssClass } from './create-cursor-css-class'
|
||||
import { RemoteCursorMarker } from './remote-cursor-marker'
|
||||
import styles from './style.module.scss'
|
||||
import type { Extension, Transaction } from '@codemirror/state'
|
||||
import { EditorSelection, StateEffect, StateField } from '@codemirror/state'
|
||||
import type { ViewUpdate } from '@codemirror/view'
|
||||
import { layer, RectangleMarker } from '@codemirror/view'
|
||||
import { Optional } from '@mrdrogdrog/optional'
|
||||
import equal from 'fast-deep-equal'
|
||||
|
||||
export interface RemoteCursor {
|
||||
displayName: string
|
||||
from: number
|
||||
to?: number
|
||||
styleIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to provide a new set of {@link RemoteCursor remote cursors} to a codemirror state.
|
||||
*/
|
||||
export const remoteCursorUpdateEffect = StateEffect.define<RemoteCursor[]>()
|
||||
|
||||
/**
|
||||
* Saves the currently visible {@link RemoteCursor remote cursors}
|
||||
* and saves new cursors if a transaction with an {@link remoteCursorUpdateEffect update effect} has been dispatched.
|
||||
*/
|
||||
export const remoteCursorStateField = StateField.define<RemoteCursor[]>({
|
||||
compare(a: RemoteCursor[], b: RemoteCursor[]): boolean {
|
||||
return equal(a, b)
|
||||
},
|
||||
create(): RemoteCursor[] {
|
||||
return []
|
||||
},
|
||||
update(currentValue: RemoteCursor[], transaction: Transaction): RemoteCursor[] {
|
||||
return Optional.ofNullable(transaction.effects.find((effect) => effect.is(remoteCursorUpdateEffect)))
|
||||
.map((remoteCursor) => remoteCursor.value as RemoteCursor[])
|
||||
.orElse(currentValue)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Checks if the given {@link ViewUpdate view update} should trigger a rerender of remote cursor components.
|
||||
* @param update The update to check
|
||||
*/
|
||||
const isRemoteCursorUpdate = (update: ViewUpdate): boolean => {
|
||||
const effect = update.transactions
|
||||
.flatMap((transaction) => transaction.effects)
|
||||
.filter((effect) => effect.is(remoteCursorUpdateEffect))
|
||||
|
||||
return update.docChanged || update.viewportChanged || effect.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the codemirror extension that renders the remote cursor selection layer.
|
||||
* @return The created codemirror extension
|
||||
*/
|
||||
export const createCursorLayer = (): Extension =>
|
||||
layer({
|
||||
above: true,
|
||||
class: styles.cursorLayer,
|
||||
update: isRemoteCursorUpdate,
|
||||
markers: (view) => {
|
||||
return view.state.field(remoteCursorStateField).flatMap((remoteCursor) => {
|
||||
const selectionRange = EditorSelection.cursor(remoteCursor.from)
|
||||
return RemoteCursorMarker.createCursor(view, selectionRange, remoteCursor.displayName, remoteCursor.styleIndex)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Creates the codemirror extension that renders the blinking remote cursor layer.
|
||||
* @return The created codemirror extension
|
||||
*/
|
||||
export const createSelectionLayer = (): Extension =>
|
||||
layer({
|
||||
above: false,
|
||||
class: styles.selectionLayer,
|
||||
update: isRemoteCursorUpdate,
|
||||
markers: (view) => {
|
||||
return view.state
|
||||
.field(remoteCursorStateField)
|
||||
.filter((remoteCursor) => remoteCursor.to !== undefined && remoteCursor.from !== remoteCursor.to)
|
||||
.flatMap((remoteCursor) => {
|
||||
const selectionRange = EditorSelection.range(remoteCursor.from, remoteCursor.to as number)
|
||||
return RectangleMarker.forRange(
|
||||
view,
|
||||
`${styles.cursor} ${createCursorCssClass(remoteCursor.styleIndex)}`,
|
||||
selectionRange
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { RemoteCursor } from './cursor-layers-extensions'
|
||||
import { remoteCursorUpdateEffect } from './cursor-layers-extensions'
|
||||
import type { EditorView, PluginValue } from '@codemirror/view'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { MessageType } from '@hedgedoc/commons'
|
||||
import type { Listener } from 'eventemitter2'
|
||||
|
||||
/**
|
||||
* Listens for remote cursor state messages from the backend and dispatches them into the codemirror.
|
||||
*/
|
||||
export class ReceiveRemoteCursorViewPlugin implements PluginValue {
|
||||
private readonly listener: Listener
|
||||
|
||||
constructor(view: EditorView, messageTransporter: MessageTransporter) {
|
||||
this.listener = messageTransporter.on(
|
||||
MessageType.REALTIME_USER_STATE_SET,
|
||||
({ payload }) => {
|
||||
const cursors: RemoteCursor[] = payload.map((user) => ({
|
||||
from: user.cursor.from,
|
||||
to: user.cursor.to,
|
||||
displayName: user.displayName,
|
||||
styleIndex: user.styleIndex
|
||||
}))
|
||||
view.dispatch({
|
||||
effects: [remoteCursorUpdateEffect.of(cursors)]
|
||||
})
|
||||
},
|
||||
{ objectify: true }
|
||||
) as Listener
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.listener.off()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { createCursorCssClass } from './create-cursor-css-class'
|
||||
import styles from './style.module.scss'
|
||||
import type { SelectionRange } from '@codemirror/state'
|
||||
import type { LayerMarker, EditorView, Rect } from '@codemirror/view'
|
||||
import { Direction } from '@codemirror/view'
|
||||
|
||||
/**
|
||||
* Renders a blinking cursor to indicate the cursor of another user.
|
||||
*/
|
||||
export class RemoteCursorMarker implements LayerMarker {
|
||||
constructor(
|
||||
private left: number,
|
||||
private top: number,
|
||||
private height: number,
|
||||
private name: string,
|
||||
private styleIndex: number
|
||||
) {}
|
||||
|
||||
draw(): HTMLElement {
|
||||
const elt = document.createElement('div')
|
||||
this.adjust(elt)
|
||||
return elt
|
||||
}
|
||||
|
||||
update(elt: HTMLElement): boolean {
|
||||
this.adjust(elt)
|
||||
return true
|
||||
}
|
||||
|
||||
adjust(element: HTMLElement) {
|
||||
element.style.left = `${this.left}px`
|
||||
element.style.top = `${this.top}px`
|
||||
element.style.height = `${this.height}px`
|
||||
element.style.setProperty('--name', `"${this.name}"`)
|
||||
element.className = `${styles.cursor} ${createCursorCssClass(this.styleIndex)}`
|
||||
}
|
||||
|
||||
eq(other: RemoteCursorMarker): boolean {
|
||||
return (
|
||||
this.left === other.left && this.top === other.top && this.height === other.height && this.name === other.name
|
||||
)
|
||||
}
|
||||
|
||||
public static createCursor(
|
||||
view: EditorView,
|
||||
position: SelectionRange,
|
||||
displayName: string,
|
||||
styleIndex: number
|
||||
): RemoteCursorMarker[] {
|
||||
const absolutePosition = this.calculateAbsoluteCursorPosition(position, view)
|
||||
if (!absolutePosition || styleIndex < 0) {
|
||||
return []
|
||||
}
|
||||
const rect = view.scrollDOM.getBoundingClientRect()
|
||||
const left = view.textDirection == Direction.LTR ? rect.left : rect.right - view.scrollDOM.clientWidth
|
||||
const baseLeft = left - view.scrollDOM.scrollLeft
|
||||
const baseTop = rect.top - view.scrollDOM.scrollTop
|
||||
return [
|
||||
new RemoteCursorMarker(
|
||||
absolutePosition.left - baseLeft,
|
||||
absolutePosition.top - baseTop,
|
||||
absolutePosition.bottom - absolutePosition.top,
|
||||
displayName,
|
||||
styleIndex
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private static calculateAbsoluteCursorPosition(position: SelectionRange, view: EditorView): Rect | null {
|
||||
const cappedPositionHead = Math.max(0, Math.min(view.state.doc.length, position.head))
|
||||
return view.coordsAtPos(cappedPositionHead, position.assoc || 1)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { SelectionRange } from '@codemirror/state'
|
||||
import type { EditorView, PluginValue, ViewUpdate } from '@codemirror/view'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { MessageType } from '@hedgedoc/commons'
|
||||
import type { Listener } from 'eventemitter2'
|
||||
|
||||
/**
|
||||
* Sends the main cursor of a codemirror to the backend using a given {@link MessageTransporter}.
|
||||
*/
|
||||
export class SendCursorViewPlugin implements PluginValue {
|
||||
private lastCursor: SelectionRange | undefined
|
||||
private listener: Listener
|
||||
|
||||
constructor(private view: EditorView, private messageTransporter: MessageTransporter) {
|
||||
this.listener = messageTransporter.doAsSoonAsReady(() => {
|
||||
this.sendCursor(this.lastCursor)
|
||||
})
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.listener.off()
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.selectionSet && !update.focusChanged && !update.docChanged) {
|
||||
return
|
||||
}
|
||||
this.sendCursor(update.state.selection.main)
|
||||
}
|
||||
|
||||
private sendCursor(currentCursor: SelectionRange | undefined) {
|
||||
if (
|
||||
!this.messageTransporter.isReady() ||
|
||||
currentCursor === undefined ||
|
||||
(this.lastCursor?.to === currentCursor?.to && this.lastCursor?.from === currentCursor?.from)
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.lastCursor = currentCursor
|
||||
this.messageTransporter.sendMessage({
|
||||
type: MessageType.REALTIME_USER_SINGLE_UPDATE,
|
||||
payload: {
|
||||
from: currentCursor.from ?? 0,
|
||||
to: currentCursor?.to
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*!
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
.cursorLayer {
|
||||
--color: #868686;
|
||||
.cursor {
|
||||
border-left: 2px solid var(--color);
|
||||
box-sizing: content-box;
|
||||
&:hover {
|
||||
&:before {
|
||||
opacity: 1
|
||||
}
|
||||
}
|
||||
&:before {
|
||||
content: var(--name);
|
||||
font-size: 0.8em;
|
||||
background: var(--color);
|
||||
position: absolute;
|
||||
top: -1.2em;
|
||||
right: 2px;
|
||||
color: white;
|
||||
padding: 2px 5px;
|
||||
height: 20px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.selectionLayer {
|
||||
--color: #868686;
|
||||
.cursor {
|
||||
background-color: var(--color);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
|
@ -4,31 +4,30 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { useBaseUrl, ORIGIN } from '../../../hooks/common/use-base-url'
|
||||
import { ORIGIN, useBaseUrl } from '../../../hooks/common/use-base-url'
|
||||
import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state'
|
||||
import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute'
|
||||
import { findLanguageByCodeBlockName } from '../../markdown-renderer/extensions/base/code-block-markdown-extension/find-language-by-code-block-name'
|
||||
import type { ScrollProps } from '../synced-scroll/scroll-props'
|
||||
import styles from './extended-codemirror/codemirror.module.scss'
|
||||
import { useCodeMirrorFileInsertExtension } from './hooks/code-mirror-extensions/use-code-mirror-file-insert-extension'
|
||||
import { useCodeMirrorScrollWatchExtension } from './hooks/code-mirror-extensions/use-code-mirror-scroll-watch-extension'
|
||||
import { useCodeMirrorSpellCheckExtension } from './hooks/code-mirror-extensions/use-code-mirror-spell-check-extension'
|
||||
import { useCodeMirrorFileInsertExtension } from './hooks/codemirror-extensions/use-code-mirror-file-insert-extension'
|
||||
import { useCodeMirrorRemoteCursorsExtension } from './hooks/codemirror-extensions/use-code-mirror-remote-cursor-extensions'
|
||||
import { useCodeMirrorScrollWatchExtension } from './hooks/codemirror-extensions/use-code-mirror-scroll-watch-extension'
|
||||
import { useCodeMirrorSpellCheckExtension } from './hooks/codemirror-extensions/use-code-mirror-spell-check-extension'
|
||||
import { useOnImageUploadFromRenderer } from './hooks/image-upload-from-renderer/use-on-image-upload-from-renderer'
|
||||
import { useCodeMirrorTablePasteExtension } from './hooks/table-paste/use-code-mirror-table-paste-extension'
|
||||
import { useApplyScrollState } from './hooks/use-apply-scroll-state'
|
||||
import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback'
|
||||
import { useUpdateCodeMirrorReference } from './hooks/use-update-code-mirror-reference'
|
||||
import { useAwareness } from './hooks/yjs/use-awareness'
|
||||
import { useBindYTextToRedux } from './hooks/yjs/use-bind-y-text-to-redux'
|
||||
import { useCodeMirrorYjsExtension } from './hooks/yjs/use-code-mirror-yjs-extension'
|
||||
import { useInsertNoteContentIntoYTextInMockModeEffect } from './hooks/yjs/use-insert-note-content-into-y-text-in-mock-mode-effect'
|
||||
import { useIsConnectionSynced } from './hooks/yjs/use-is-connection-synced'
|
||||
import { useMarkdownContentYText } from './hooks/yjs/use-markdown-content-y-text'
|
||||
import { useOnFirstEditorUpdateExtension } from './hooks/yjs/use-on-first-editor-update-extension'
|
||||
import { useOnMetadataUpdated } from './hooks/yjs/use-on-metadata-updated'
|
||||
import { useOnNoteDeleted } from './hooks/yjs/use-on-note-deleted'
|
||||
import { useWebsocketConnection } from './hooks/yjs/use-websocket-connection'
|
||||
import { useRealtimeConnection } from './hooks/yjs/use-realtime-connection'
|
||||
import { useReceiveRealtimeUsers } from './hooks/yjs/use-receive-realtime-users'
|
||||
import { useYDoc } from './hooks/yjs/use-y-doc'
|
||||
import { useYDocSyncClientAdapter } from './hooks/yjs/use-y-doc-sync-client-adapter'
|
||||
import { useLinter } from './linter/linter'
|
||||
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
|
||||
import { StatusBar } from './status-bar/status-bar'
|
||||
|
@ -40,9 +39,11 @@ import { lintGutter } from '@codemirror/lint'
|
|||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import ReactCodeMirror from '@uiw/react-codemirror'
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type EditorPaneProps = ScrollProps
|
||||
|
||||
/**
|
||||
* Renders the text editor pane of the editor.
|
||||
* The used editor is {@link ReactCodeMirror code mirror}.
|
||||
|
@ -52,41 +53,41 @@ import { useTranslation } from 'react-i18next'
|
|||
* @param onMakeScrollSource The callback to request to become the scroll source.
|
||||
* @external {ReactCodeMirror} https://npmjs.com/@uiw/react-codemirror
|
||||
*/
|
||||
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
|
||||
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
|
||||
|
||||
export const EditorPane: React.FC<EditorPaneProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
|
||||
useApplyScrollState(scrollState)
|
||||
|
||||
const messageTransporter = useRealtimeConnection()
|
||||
const yDoc = useYDoc(messageTransporter)
|
||||
const yText = useMarkdownContentYText(yDoc)
|
||||
const editorScrollExtension = useCodeMirrorScrollWatchExtension(onScroll)
|
||||
const tablePasteExtensions = useCodeMirrorTablePasteExtension()
|
||||
const fileInsertExtension = useCodeMirrorFileInsertExtension()
|
||||
const spellCheckExtension = useCodeMirrorSpellCheckExtension()
|
||||
const cursorActivityExtension = useCursorActivityCallback()
|
||||
|
||||
const updateViewContextExtension = useUpdateCodeMirrorReference()
|
||||
|
||||
const yDoc = useYDoc()
|
||||
const awareness = useAwareness(yDoc)
|
||||
const yText = useMarkdownContentYText(yDoc)
|
||||
const websocketConnection = useWebsocketConnection(yDoc, awareness)
|
||||
const connectionSynced = useIsConnectionSynced(websocketConnection)
|
||||
useBindYTextToRedux(yText)
|
||||
useOnMetadataUpdated(websocketConnection)
|
||||
useOnNoteDeleted(websocketConnection)
|
||||
const remoteCursorsExtension = useCodeMirrorRemoteCursorsExtension(messageTransporter)
|
||||
|
||||
const yjsExtension = useCodeMirrorYjsExtension(yText, awareness)
|
||||
const [firstEditorUpdateExtension, firstUpdateHappened] = useOnFirstEditorUpdateExtension()
|
||||
useInsertNoteContentIntoYTextInMockModeEffect(firstUpdateHappened, websocketConnection)
|
||||
const linter = useLinter()
|
||||
const linterExtension = useLinter()
|
||||
|
||||
const syncAdapter = useYDocSyncClientAdapter(messageTransporter, yDoc)
|
||||
const yjsExtension = useCodeMirrorYjsExtension(yText, syncAdapter)
|
||||
|
||||
useOnMetadataUpdated(messageTransporter)
|
||||
useOnNoteDeleted(messageTransporter)
|
||||
|
||||
useBindYTextToRedux(yText)
|
||||
useReceiveRealtimeUsers(messageTransporter)
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
linter,
|
||||
linterExtension,
|
||||
lintGutter(),
|
||||
markdown({
|
||||
base: markdownLanguage,
|
||||
codeLanguages: (input) => findLanguageByCodeBlockName(languages, input)
|
||||
}),
|
||||
remoteCursorsExtension,
|
||||
EditorView.lineWrapping,
|
||||
editorScrollExtension,
|
||||
tablePasteExtensions,
|
||||
|
@ -95,34 +96,40 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
|||
cursorActivityExtension,
|
||||
updateViewContextExtension,
|
||||
yjsExtension,
|
||||
firstEditorUpdateExtension,
|
||||
spellCheckExtension
|
||||
],
|
||||
[
|
||||
linter,
|
||||
linterExtension,
|
||||
remoteCursorsExtension,
|
||||
editorScrollExtension,
|
||||
tablePasteExtensions,
|
||||
fileInsertExtension,
|
||||
cursorActivityExtension,
|
||||
updateViewContextExtension,
|
||||
yjsExtension,
|
||||
firstEditorUpdateExtension,
|
||||
spellCheckExtension
|
||||
]
|
||||
)
|
||||
|
||||
useOnImageUploadFromRenderer()
|
||||
|
||||
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
|
||||
const codeMirrorClassName = useMemo(
|
||||
() => `overflow-hidden ${styles.extendedCodemirror} h-100 ${ligaturesEnabled ? '' : styles['no-ligatures']}`,
|
||||
[ligaturesEnabled]
|
||||
)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const darkModeActivated = useDarkModeState()
|
||||
|
||||
const editorOrigin = useBaseUrl(ORIGIN.EDITOR)
|
||||
const isSynced = useApplicationState((state) => state.realtimeStatus.isSynced)
|
||||
|
||||
useEffect(() => {
|
||||
const listener = messageTransporter.doAsSoonAsConnected(() => messageTransporter.sendReady())
|
||||
return () => {
|
||||
listener.off()
|
||||
}
|
||||
}, [messageTransporter])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
@ -130,11 +137,11 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
|||
onTouchStart={onMakeScrollSource}
|
||||
onMouseEnter={onMakeScrollSource}
|
||||
{...cypressId('editor-pane')}
|
||||
{...cypressAttribute('editor-ready', String(firstUpdateHappened && connectionSynced))}>
|
||||
{...cypressAttribute('editor-ready', String(updateViewContextExtension !== null && isSynced))}>
|
||||
<MaxLengthWarning />
|
||||
<ToolBar />
|
||||
<ReactCodeMirror
|
||||
editable={firstUpdateHappened && connectionSynced}
|
||||
editable={updateViewContextExtension !== null && isSynced}
|
||||
placeholder={t('editor.placeholder', { host: editorOrigin }) ?? ''}
|
||||
extensions={extensions}
|
||||
width={'100%'}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
createCursorLayer,
|
||||
createSelectionLayer,
|
||||
remoteCursorStateField
|
||||
} from '../../codemirror-extensions/remote-cursors/cursor-layers-extensions'
|
||||
import { ReceiveRemoteCursorViewPlugin } from '../../codemirror-extensions/remote-cursors/receive-remote-cursor-view-plugin'
|
||||
import { SendCursorViewPlugin } from '../../codemirror-extensions/remote-cursors/send-cursor-view-plugin'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { ViewPlugin } from '@codemirror/view'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Bundles all extensions that are needed for the remote cursor display.
|
||||
* @return The created codemirror extensions
|
||||
*/
|
||||
export const useCodeMirrorRemoteCursorsExtension = (messageTransporter: MessageTransporter): Extension =>
|
||||
useMemo(
|
||||
() => [
|
||||
remoteCursorStateField.extension,
|
||||
createCursorLayer(),
|
||||
createSelectionLayer(),
|
||||
ViewPlugin.define((view) => new ReceiveRemoteCursorViewPlugin(view, messageTransporter)),
|
||||
ViewPlugin.define((view) => new SendCursorViewPlugin(view, messageTransporter))
|
||||
],
|
||||
[messageTransporter]
|
||||
)
|
|
@ -1,38 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MARKDOWN_CONTENT_CHANNEL_NAME, YDocMessageTransporter } from '@hedgedoc/commons'
|
||||
import type { Awareness } from 'y-protocols/awareness'
|
||||
import type { Doc } from 'yjs'
|
||||
|
||||
/**
|
||||
* A mocked connection that doesn't send or receive any data and is instantly ready.
|
||||
*/
|
||||
export class MockConnection extends YDocMessageTransporter {
|
||||
constructor(doc: Doc, awareness: Awareness) {
|
||||
super(doc, awareness)
|
||||
this.onOpen()
|
||||
this.emit('ready')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
//Intentionally left empty because this is a mocked connection
|
||||
}
|
||||
|
||||
send(): void {
|
||||
//Intentionally left empty because this is a mocked connection
|
||||
}
|
||||
}
|
|
@ -1,89 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { addOnlineUser, removeOnlineUser } from '../../../../../redux/realtime/methods'
|
||||
import { ActiveIndicatorStatus } from '../../../../../redux/realtime/types'
|
||||
import { Logger } from '../../../../../utils/logger'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Awareness } from 'y-protocols/awareness'
|
||||
import type { Doc } from 'yjs'
|
||||
|
||||
const ownAwarenessClientId = -1
|
||||
|
||||
interface UserAwarenessState {
|
||||
user: {
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: [mrdrogdrog] move this code to the server for the initial color setting.
|
||||
const userColors = [
|
||||
{ color: '#30bced', light: '#30bced33' },
|
||||
{ color: '#6eeb83', light: '#6eeb8333' },
|
||||
{ color: '#ffbc42', light: '#ffbc4233' },
|
||||
{ color: '#ecd444', light: '#ecd44433' },
|
||||
{ color: '#ee6352', light: '#ee635233' },
|
||||
{ color: '#9ac2c9', light: '#9ac2c933' },
|
||||
{ color: '#8acb88', light: '#8acb8833' },
|
||||
{ color: '#1be7ff', light: '#1be7ff33' }
|
||||
]
|
||||
|
||||
const logger = new Logger('useAwareness')
|
||||
|
||||
/**
|
||||
* Creates an {@link Awareness awareness}, sets the own values (like name, color, etc.) for other clients and writes state changes into the global application state.
|
||||
*
|
||||
* @param yDoc The {@link Doc yjs document} that handles the communication.
|
||||
* @return The created {@link Awareness awareness}
|
||||
*/
|
||||
export const useAwareness = (yDoc: Doc): Awareness => {
|
||||
const ownUsername = useApplicationState((state) => state.user?.username)
|
||||
const awareness = useMemo(() => new Awareness(yDoc), [yDoc])
|
||||
|
||||
useEffect(() => {
|
||||
const userColor = userColors[Math.floor(Math.random() * 8)]
|
||||
if (ownUsername !== undefined) {
|
||||
awareness.setLocalStateField('user', {
|
||||
name: ownUsername,
|
||||
color: userColor.color,
|
||||
colorLight: userColor.light
|
||||
})
|
||||
addOnlineUser(ownAwarenessClientId, {
|
||||
active: ActiveIndicatorStatus.ACTIVE,
|
||||
color: userColor.color,
|
||||
username: ownUsername
|
||||
})
|
||||
}
|
||||
|
||||
const awarenessCallback = ({ added, removed }: { added: number[]; removed: number[] }): void => {
|
||||
added.forEach((addedId) => {
|
||||
const state = awareness.getStates().get(addedId) as UserAwarenessState | undefined
|
||||
if (!state) {
|
||||
logger.debug('Could not find state for user')
|
||||
return
|
||||
}
|
||||
logger.debug(`added awareness ${addedId}`, state.user)
|
||||
addOnlineUser(addedId, {
|
||||
active: ActiveIndicatorStatus.ACTIVE,
|
||||
color: state.user.color,
|
||||
username: state.user.name
|
||||
})
|
||||
})
|
||||
removed.forEach((removedId) => {
|
||||
logger.debug(`remove awareness ${removedId}`)
|
||||
removeOnlineUser(removedId)
|
||||
})
|
||||
}
|
||||
awareness.on('change', awarenessCallback)
|
||||
|
||||
return () => {
|
||||
awareness.off('change', awarenessCallback)
|
||||
removeOnlineUser(ownAwarenessClientId)
|
||||
}
|
||||
}, [awareness, ownUsername])
|
||||
return awareness
|
||||
}
|
|
@ -12,8 +12,11 @@ import type { YText } from 'yjs/dist/src/types/YText'
|
|||
*
|
||||
* @param yText The source text
|
||||
*/
|
||||
export const useBindYTextToRedux = (yText: YText): void => {
|
||||
export const useBindYTextToRedux = (yText: YText | undefined): void => {
|
||||
useEffect(() => {
|
||||
if (!yText) {
|
||||
return
|
||||
}
|
||||
const yTextCallback = () => setNoteContent(yText.toString())
|
||||
yText.observe(yTextCallback)
|
||||
return () => yText.unobserve(yTextCallback)
|
||||
|
|
|
@ -3,19 +3,35 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { YTextSyncViewPlugin } from '../../codemirror-extensions/document-sync/y-text-sync-view-plugin'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { useMemo } from 'react'
|
||||
import { yCollab } from 'y-codemirror.next'
|
||||
import type { Awareness } from 'y-protocols/awareness'
|
||||
import type { YText } from 'yjs/dist/src/types/YText'
|
||||
import { ViewPlugin } from '@codemirror/view'
|
||||
import type { YDocSyncClientAdapter } from '@hedgedoc/commons'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { Text as YText } from 'yjs'
|
||||
|
||||
/**
|
||||
* Creates a {@link Extension code mirror extension} that synchronizes an editor with the given {@link YText ytext} and {@link Awareness awareness}.
|
||||
* Creates a {@link Extension code mirror extension} that synchronizes an editor with the given {@link YText ytext}.
|
||||
*
|
||||
* @param yText The source and target for the editor content
|
||||
* @param awareness Contains cursor positions and names from other clients that will be shown
|
||||
* @param syncAdapter The sync adapter that processes the communication for content synchronisation.
|
||||
* @return the created extension
|
||||
*/
|
||||
export const useCodeMirrorYjsExtension = (yText: YText, awareness: Awareness): Extension => {
|
||||
return useMemo(() => yCollab(yText, awareness), [awareness, yText])
|
||||
export const useCodeMirrorYjsExtension = (yText: YText | undefined, syncAdapter: YDocSyncClientAdapter): Extension => {
|
||||
const [editorReady, setEditorReady] = useState(false)
|
||||
const synchronized = useApplicationState((state) => state.realtimeStatus.isSynced)
|
||||
const connected = useApplicationState((state) => state.realtimeStatus.isConnected)
|
||||
|
||||
useEffect(() => {
|
||||
if (editorReady && connected && !synchronized && yText) {
|
||||
syncAdapter.requestDocumentState()
|
||||
}
|
||||
}, [connected, editorReady, syncAdapter, synchronized, yText])
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
yText ? [ViewPlugin.define((view) => new YTextSyncViewPlugin(view, yText, () => setEditorReady(true)))] : [],
|
||||
[yText]
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { getGlobalState } from '../../../../../redux'
|
||||
import { isMockMode } from '../../../../../utils/test-modes'
|
||||
import { MockConnection } from './mock-connection'
|
||||
import type { YDocMessageTransporter } from '@hedgedoc/commons'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* 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])
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { YDocMessageTransporter } from '@hedgedoc/commons'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Checks if the given message transporter has received at least one full synchronisation.
|
||||
*
|
||||
* @param connection The connection whose sync status should be checked
|
||||
* @return If at least one full synchronisation is occurred.
|
||||
*/
|
||||
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)
|
||||
connection.on('disconnected', disableEditor)
|
||||
return () => {
|
||||
connection.off('synced', enableEditor)
|
||||
connection.off('disconnected', disableEditor)
|
||||
}
|
||||
}, [connection])
|
||||
|
||||
return editorEnabled
|
||||
}
|
|
@ -3,9 +3,8 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { MARKDOWN_CONTENT_CHANNEL_NAME } from '@hedgedoc/commons'
|
||||
import type { RealtimeDoc } from '@hedgedoc/commons'
|
||||
import { useMemo } from 'react'
|
||||
import type { Doc } from 'yjs'
|
||||
import type { Text as YText } from 'yjs'
|
||||
|
||||
/**
|
||||
|
@ -14,6 +13,6 @@ import type { Text as YText } from 'yjs'
|
|||
* @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])
|
||||
export const useMarkdownContentYText = (yDoc: RealtimeDoc | undefined): YText | undefined => {
|
||||
return useMemo(() => yDoc?.getMarkdownContentChannel(), [yDoc])
|
||||
}
|
||||
|
|
|
@ -1,19 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Provides an extension that checks when the code mirror, that loads the extension, has its first update.
|
||||
*
|
||||
* @return 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]
|
||||
}
|
|
@ -4,24 +4,23 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { updateMetadata } from '../../../../../redux/note-details/methods'
|
||||
import type { YDocMessageTransporter } from '@hedgedoc/commons'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { MessageType } from '@hedgedoc/commons'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import type { Listener } from 'eventemitter2'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Hook that updates the metadata if the server announced an update of the metadata.
|
||||
*
|
||||
* @param websocketConnection The websocket connection that emits the metadata changed event
|
||||
*/
|
||||
export const useOnMetadataUpdated = (websocketConnection: YDocMessageTransporter): void => {
|
||||
const updateMetadataHandler = useCallback(async () => {
|
||||
await updateMetadata()
|
||||
}, [])
|
||||
|
||||
export const useOnMetadataUpdated = (websocketConnection: MessageTransporter): void => {
|
||||
useEffect(() => {
|
||||
websocketConnection.on(String(MessageType.METADATA_UPDATED), () => void updateMetadataHandler())
|
||||
const listener = websocketConnection.on(MessageType.METADATA_UPDATED, () => void updateMetadata(), {
|
||||
objectify: true
|
||||
}) as Listener
|
||||
return () => {
|
||||
websocketConnection.off(String(MessageType.METADATA_UPDATED), () => void updateMetadataHandler())
|
||||
listener.off()
|
||||
}
|
||||
})
|
||||
}, [websocketConnection])
|
||||
}
|
||||
|
|
|
@ -6,8 +6,9 @@
|
|||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { Logger } from '../../../../../utils/logger'
|
||||
import { useUiNotifications } from '../../../../notifications/ui-notification-boundary'
|
||||
import type { YDocMessageTransporter } from '@hedgedoc/commons'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { MessageType } from '@hedgedoc/commons'
|
||||
import type { Listener } from 'eventemitter2'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
|
||||
|
@ -18,7 +19,7 @@ const logger = new Logger('UseOnNoteDeleted')
|
|||
*
|
||||
* @param websocketConnection The websocket connection that emits the deletion event
|
||||
*/
|
||||
export const useOnNoteDeleted = (websocketConnection: YDocMessageTransporter): void => {
|
||||
export const useOnNoteDeleted = (websocketConnection: MessageTransporter): void => {
|
||||
const router = useRouter()
|
||||
const noteTitle = useApplicationState((state) => state.noteDetails.title)
|
||||
const { dispatchUiNotification } = useUiNotifications()
|
||||
|
@ -35,9 +36,11 @@ export const useOnNoteDeleted = (websocketConnection: YDocMessageTransporter): v
|
|||
}, [router, noteTitle, dispatchUiNotification])
|
||||
|
||||
useEffect(() => {
|
||||
websocketConnection.on(String(MessageType.DOCUMENT_DELETED), noteDeletedHandler)
|
||||
const listener = websocketConnection.on(MessageType.DOCUMENT_DELETED, noteDeletedHandler, {
|
||||
objectify: true
|
||||
}) as Listener
|
||||
return () => {
|
||||
websocketConnection.off(String(MessageType.DOCUMENT_DELETED), noteDeletedHandler)
|
||||
listener.off()
|
||||
}
|
||||
})
|
||||
}, [noteDeletedHandler, websocketConnection])
|
||||
}
|
||||
|
|
|
@ -0,0 +1,110 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { getGlobalState } from '../../../../../redux'
|
||||
import { setRealtimeConnectionState } from '../../../../../redux/realtime/methods'
|
||||
import { Logger } from '../../../../../utils/logger'
|
||||
import { isMockMode } from '../../../../../utils/test-modes'
|
||||
import { useWebsocketUrl } from './use-websocket-url'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { MockedBackendMessageTransporter, WebsocketTransporter } from '@hedgedoc/commons'
|
||||
import type { Listener } from 'eventemitter2'
|
||||
import WebSocket from 'isomorphic-ws'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
const logger = new Logger('websocket connection')
|
||||
const WEBSOCKET_RECONNECT_INTERVAL = 3000
|
||||
|
||||
/**
|
||||
* Creates a {@link WebsocketTransporter websocket message transporter} that handles the realtime communication with the backend.
|
||||
*
|
||||
* @return the created connection handler
|
||||
*/
|
||||
export const useRealtimeConnection = (): MessageTransporter => {
|
||||
const websocketUrl = useWebsocketUrl()
|
||||
const messageTransporter = useMemo(() => {
|
||||
if (isMockMode) {
|
||||
logger.debug('Creating Loopback connection...')
|
||||
return new MockedBackendMessageTransporter(getGlobalState().noteDetails.markdownContent.plain)
|
||||
} else {
|
||||
logger.debug('Creating Websocket connection...')
|
||||
return new WebsocketTransporter()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const establishWebsocketConnection = useCallback(() => {
|
||||
if (messageTransporter instanceof WebsocketTransporter && websocketUrl) {
|
||||
logger.debug(`Connecting to ${websocketUrl.toString()}`)
|
||||
const socket = new WebSocket(websocketUrl)
|
||||
socket.addEventListener('error', () => {
|
||||
setTimeout(() => {
|
||||
establishWebsocketConnection()
|
||||
}, WEBSOCKET_RECONNECT_INTERVAL)
|
||||
})
|
||||
socket.addEventListener('open', () => {
|
||||
messageTransporter.setWebsocket(socket)
|
||||
})
|
||||
}
|
||||
}, [messageTransporter, websocketUrl])
|
||||
|
||||
const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected)
|
||||
const firstConnect = useRef(true)
|
||||
|
||||
const reconnectTimeout = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
return
|
||||
}
|
||||
if (firstConnect.current) {
|
||||
establishWebsocketConnection()
|
||||
firstConnect.current = false
|
||||
} else {
|
||||
reconnectTimeout.current = window.setTimeout(() => {
|
||||
establishWebsocketConnection()
|
||||
}, WEBSOCKET_RECONNECT_INTERVAL)
|
||||
}
|
||||
}, [establishWebsocketConnection, isConnected, messageTransporter])
|
||||
|
||||
useEffect(() => {
|
||||
const readyListener = messageTransporter.doAsSoonAsReady(() => {
|
||||
const timerId = reconnectTimeout.current
|
||||
if (timerId !== undefined) {
|
||||
window.clearTimeout(timerId)
|
||||
}
|
||||
reconnectTimeout.current = undefined
|
||||
})
|
||||
|
||||
messageTransporter.on('connected', () => logger.debug(`Connected`))
|
||||
messageTransporter.on('disconnected', () => logger.debug(`Disconnected`))
|
||||
|
||||
return () => {
|
||||
const interval = reconnectTimeout.current
|
||||
interval && window.clearTimeout(interval)
|
||||
readyListener.off()
|
||||
}
|
||||
}, [messageTransporter])
|
||||
|
||||
useEffect(() => {
|
||||
const disconnectCallback = () => messageTransporter.disconnect()
|
||||
window.addEventListener('beforeunload', disconnectCallback)
|
||||
return () => window.removeEventListener('beforeunload', disconnectCallback)
|
||||
}, [messageTransporter])
|
||||
|
||||
useEffect(() => {
|
||||
const connectedListener = messageTransporter.doAsSoonAsConnected(() => setRealtimeConnectionState(true))
|
||||
const disconnectedListener = messageTransporter.on('disconnected', () => setRealtimeConnectionState(false), {
|
||||
objectify: true
|
||||
}) as Listener
|
||||
|
||||
return () => {
|
||||
connectedListener.off()
|
||||
disconnectedListener.off()
|
||||
}
|
||||
}, [messageTransporter])
|
||||
|
||||
return messageTransporter
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import { setRealtimeUsers } from '../../../../../redux/realtime/methods'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { MessageType } from '@hedgedoc/commons'
|
||||
import type { Listener } from 'eventemitter2'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Waits for remote cursor updates that are sent from the backend and saves them in the global application state.
|
||||
*
|
||||
* @param messageTransporter the {@link MessageTransporter} that should be used to receive the remote cursor updates
|
||||
*/
|
||||
export const useReceiveRealtimeUsers = (messageTransporter: MessageTransporter): void => {
|
||||
const isConnected = useApplicationState((state) => state.realtimeStatus.isConnected)
|
||||
|
||||
useEffect(() => {
|
||||
const listener = messageTransporter.on(
|
||||
MessageType.REALTIME_USER_STATE_SET,
|
||||
(payload) => setRealtimeUsers(payload.payload),
|
||||
{ objectify: true }
|
||||
) as Listener
|
||||
|
||||
return () => {
|
||||
listener.off()
|
||||
}
|
||||
}, [messageTransporter])
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
messageTransporter.sendMessage({ type: MessageType.REALTIME_USER_STATE_REQUEST })
|
||||
}
|
||||
}, [isConnected, messageTransporter])
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { isMockMode } from '../../../../../utils/test-modes'
|
||||
import { MockConnection } from './mock-connection'
|
||||
import { useWebsocketUrl } from './use-websocket-url'
|
||||
import { WebsocketConnection } from './websocket-connection'
|
||||
import type { YDocMessageTransporter } from '@hedgedoc/commons'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { Awareness } from 'y-protocols/awareness'
|
||||
import type { Doc } from 'yjs'
|
||||
|
||||
/**
|
||||
* Creates a {@link WebsocketConnection websocket connection handler } that handles the realtime communication with the backend.
|
||||
*
|
||||
* @param yDoc The {@link Doc y-doc} that should be synchronized with the backend
|
||||
* @param awareness The {@link Awareness awareness} that should be synchronized with the backend.
|
||||
* @return the created connection handler
|
||||
*/
|
||||
export const useWebsocketConnection = (yDoc: Doc, awareness: Awareness): YDocMessageTransporter => {
|
||||
const websocketUrl = useWebsocketUrl()
|
||||
|
||||
const websocketConnection: YDocMessageTransporter = useMemo(() => {
|
||||
return isMockMode ? new MockConnection(yDoc, awareness) : new WebsocketConnection(websocketUrl, yDoc, awareness)
|
||||
}, [awareness, websocketUrl, yDoc])
|
||||
|
||||
useEffect(() => {
|
||||
const disconnectCallback = () => websocketConnection.disconnect()
|
||||
window.addEventListener('beforeunload', disconnectCallback)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', disconnectCallback)
|
||||
disconnectCallback()
|
||||
}
|
||||
}, [websocketConnection])
|
||||
|
||||
return websocketConnection
|
||||
}
|
|
@ -13,7 +13,7 @@ const LOCAL_FALLBACK_URL = 'ws://localhost:8080/realtime/'
|
|||
/**
|
||||
* Provides the URL for the realtime endpoint.
|
||||
*/
|
||||
export const useWebsocketUrl = (): URL => {
|
||||
export const useWebsocketUrl = (): URL | undefined => {
|
||||
const noteId = useApplicationState((state) => state.noteDetails.id)
|
||||
const baseUrl = useBaseUrl()
|
||||
|
||||
|
@ -33,6 +33,9 @@ export const useWebsocketUrl = (): URL => {
|
|||
}, [baseUrl])
|
||||
|
||||
return useMemo(() => {
|
||||
if (noteId === '') {
|
||||
return
|
||||
}
|
||||
const url = new URL(websocketUrl)
|
||||
url.search = `?noteId=${noteId}`
|
||||
return url
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { setRealtimeSyncedState } from '../../../../../redux/realtime/methods'
|
||||
import { Logger } from '../../../../../utils/logger'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { YDocSyncClientAdapter } from '@hedgedoc/commons'
|
||||
import type { Listener } from 'eventemitter2'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { Doc } from 'yjs'
|
||||
|
||||
const logger = new Logger('useYDocSyncClient')
|
||||
|
||||
/**
|
||||
* Creates a {@link YDocSyncClientAdapter} and mirrors its sync state to the global application state.
|
||||
*
|
||||
* @param messageTransporter The {@link MessageTransporter message transporter} that sends and receives messages for the synchronisation
|
||||
* @param yDoc The {@link Doc y-doc} that should be synchronized
|
||||
* @return the created adapter
|
||||
*/
|
||||
export const useYDocSyncClientAdapter = (
|
||||
messageTransporter: MessageTransporter,
|
||||
yDoc: Doc | undefined
|
||||
): YDocSyncClientAdapter => {
|
||||
const syncAdapter = useMemo(() => new YDocSyncClientAdapter(messageTransporter), [messageTransporter])
|
||||
|
||||
useEffect(() => {
|
||||
syncAdapter.setYDoc(yDoc)
|
||||
}, [syncAdapter, yDoc])
|
||||
|
||||
useEffect(() => {
|
||||
const onceSyncedListener = syncAdapter.doAsSoonAsSynced(() => {
|
||||
logger.debug('YDoc synced')
|
||||
setRealtimeSyncedState(true)
|
||||
})
|
||||
const desyncedListener = syncAdapter.eventEmitter.on(
|
||||
'desynced',
|
||||
() => {
|
||||
logger.debug('YDoc de-synced')
|
||||
setRealtimeSyncedState(false)
|
||||
},
|
||||
{
|
||||
objectify: true
|
||||
}
|
||||
) as Listener
|
||||
|
||||
return () => {
|
||||
onceSyncedListener.off()
|
||||
desyncedListener.off()
|
||||
}
|
||||
}, [messageTransporter, syncAdapter])
|
||||
|
||||
return syncAdapter
|
||||
}
|
|
@ -3,16 +3,28 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Doc } from 'yjs'
|
||||
import type { MessageTransporter } from '@hedgedoc/commons'
|
||||
import { RealtimeDoc } from '@hedgedoc/commons'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Creates a new {@link Doc y-doc}.
|
||||
* Creates a new {@link RealtimeDoc y-doc}.
|
||||
*
|
||||
* @return The created {@link Doc y-doc}
|
||||
* @return The created {@link RealtimeDoc y-doc}
|
||||
*/
|
||||
export const useYDoc = (): Doc => {
|
||||
const yDoc = useMemo(() => new Doc(), [])
|
||||
useEffect(() => () => yDoc.destroy(), [yDoc])
|
||||
export const useYDoc = (messageTransporter: MessageTransporter): RealtimeDoc | undefined => {
|
||||
const [yDoc, setYDoc] = useState<RealtimeDoc>()
|
||||
|
||||
useEffect(() => {
|
||||
messageTransporter.doAsSoonAsConnected(() => {
|
||||
setYDoc(new RealtimeDoc())
|
||||
})
|
||||
messageTransporter.on('disconnected', () => {
|
||||
setYDoc(undefined)
|
||||
})
|
||||
}, [messageTransporter])
|
||||
|
||||
useEffect(() => () => yDoc?.destroy(), [yDoc])
|
||||
|
||||
return yDoc
|
||||
}
|
||||
|
|
|
@ -1,59 +0,0 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
encodeAwarenessUpdateMessage,
|
||||
encodeCompleteAwarenessStateRequestMessage,
|
||||
encodeDocumentUpdateMessage,
|
||||
WebsocketTransporter
|
||||
} from '@hedgedoc/commons'
|
||||
import WebSocket from 'isomorphic-ws'
|
||||
import type { Awareness } from 'y-protocols/awareness'
|
||||
import type { Doc } from 'yjs'
|
||||
|
||||
/**
|
||||
* Handles the communication with the realtime endpoint of the backend and synchronizes the given y-doc and awareness with other clients.
|
||||
*/
|
||||
export class WebsocketConnection extends WebsocketTransporter {
|
||||
constructor(url: URL, doc: Doc, awareness: Awareness) {
|
||||
super(doc, awareness)
|
||||
this.bindYDocEvents(doc)
|
||||
this.bindAwarenessMessageEvents(awareness)
|
||||
const websocket = new WebSocket(url)
|
||||
this.setupWebsocket(websocket)
|
||||
}
|
||||
|
||||
private bindAwarenessMessageEvents(awareness: Awareness) {
|
||||
const updateCallback = (
|
||||
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
|
||||
origin: unknown
|
||||
) => {
|
||||
if (origin !== this) {
|
||||
this.send(encodeAwarenessUpdateMessage(awareness, [...added, ...updated, ...removed]))
|
||||
}
|
||||
}
|
||||
this.on('disconnected', () => {
|
||||
awareness.off('update', updateCallback)
|
||||
awareness.destroy()
|
||||
})
|
||||
|
||||
this.on('ready', () => {
|
||||
awareness.on('update', updateCallback)
|
||||
})
|
||||
this.on('synced', () => {
|
||||
this.send(encodeCompleteAwarenessStateRequestMessage())
|
||||
this.send(encodeAwarenessUpdateMessage(awareness, [awareness.doc.clientID]))
|
||||
})
|
||||
}
|
||||
|
||||
private bindYDocEvents(doc: Doc): void {
|
||||
doc.on('destroy', () => this.disconnect())
|
||||
doc.on('update', (update: Uint8Array, origin: unknown) => {
|
||||
if (origin !== this && this.isSynced() && this.isWebSocketOpen()) {
|
||||
this.send(encodeDocumentUpdateMessage(update))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { resetRealtimeStatus } from '../../redux/realtime/methods'
|
||||
import { LoadingScreen } from '../application-loader/loading-screen/loading-screen'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import React, { Fragment, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Resets the realtime status in the global application state to its initial state before loading the given child elements.
|
||||
*
|
||||
* @param children The children to load after the reset
|
||||
*/
|
||||
export const ResetRealtimeStateBoundary: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const [globalStateInitialized, setGlobalStateInitialized] = useState(false)
|
||||
useEffect(() => {
|
||||
resetRealtimeStatus()
|
||||
setGlobalStateInitialized(true)
|
||||
}, [])
|
||||
if (!globalStateInitialized) {
|
||||
return <LoadingScreen />
|
||||
} else {
|
||||
return <Fragment>{children}</Fragment>
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
.user-line-color-indicator {
|
||||
border-left: 3px solid;
|
||||
border-left: 3px solid var(--color);
|
||||
min-height: 30px;
|
||||
height: 100%;
|
||||
flex: 0 0 3px;
|
||||
|
|
|
@ -3,16 +3,16 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { ActiveIndicatorStatus } from '../../../../redux/realtime/types'
|
||||
import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username'
|
||||
import { createCursorCssClass } from '../../editor-pane/codemirror-extensions/remote-cursors/create-cursor-css-class'
|
||||
import { ActiveIndicator } from '../users-online-sidebar-menu/active-indicator'
|
||||
import styles from './user-line.module.scss'
|
||||
import React from 'react'
|
||||
|
||||
export interface UserLineProps {
|
||||
username: string | null
|
||||
color: string
|
||||
status: ActiveIndicatorStatus
|
||||
active: boolean
|
||||
color: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -22,19 +22,20 @@ export interface UserLineProps {
|
|||
* @param color The color of the user's edits.
|
||||
* @param status The user's current online status.
|
||||
*/
|
||||
export const UserLine: React.FC<UserLineProps> = ({ username, color, status }) => {
|
||||
export const UserLine: React.FC<UserLineProps> = ({ username, active, color }) => {
|
||||
return (
|
||||
<div className={'d-flex align-items-center h-100 w-100'}>
|
||||
<div
|
||||
className={`d-inline-flex align-items-bottom ${styles['user-line-color-indicator']}`}
|
||||
style={{ borderLeftColor: color }}
|
||||
className={`d-inline-flex align-items-bottom ${styles['user-line-color-indicator']} ${createCursorCssClass(
|
||||
color
|
||||
)}`}
|
||||
/>
|
||||
<UserAvatarForUsername
|
||||
username={username}
|
||||
additionalClasses={'flex-fill overflow-hidden px-2 text-nowrap w-100'}
|
||||
/>
|
||||
<div className={styles['active-indicator-container']}>
|
||||
<ActiveIndicator status={status} />
|
||||
<ActiveIndicator active={active} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -3,12 +3,11 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { ActiveIndicatorStatus } from '../../../../redux/realtime/types'
|
||||
import styles from './active-indicator.module.scss'
|
||||
import React from 'react'
|
||||
|
||||
export interface ActiveIndicatorProps {
|
||||
status: ActiveIndicatorStatus
|
||||
active: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -16,6 +15,6 @@ export interface ActiveIndicatorProps {
|
|||
*
|
||||
* @param status The state of the indicator to render
|
||||
*/
|
||||
export const ActiveIndicator: React.FC<ActiveIndicatorProps> = ({ status }) => {
|
||||
return <span className={`${styles['activeIndicator']} ${status}`} />
|
||||
export const ActiveIndicator: React.FC<ActiveIndicatorProps> = ({ active }) => {
|
||||
return <span className={`${styles['activeIndicator']} ${active ? styles.active : styles.inactive}`} />
|
||||
}
|
||||
|
|
|
@ -31,21 +31,19 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
|
|||
selectedMenuId
|
||||
}) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const onlineUsers = useApplicationState((state) => state.realtime.users)
|
||||
const realtimeUsers = useApplicationState((state) => state.realtimeStatus.onlineUsers)
|
||||
useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
const value = `${Object.keys(onlineUsers).length}`
|
||||
buttonRef.current?.style.setProperty('--users-online', `"${value}"`)
|
||||
}, [onlineUsers])
|
||||
buttonRef.current?.style.setProperty('--users-online', `"${realtimeUsers.length}"`)
|
||||
}, [realtimeUsers])
|
||||
|
||||
const hide = selectedMenuId !== DocumentSidebarMenuSelection.NONE && selectedMenuId !== menuId
|
||||
const expand = selectedMenuId === menuId
|
||||
const onClickHandler = useCallback(() => onClick(menuId), [menuId, onClick])
|
||||
|
||||
const onlineUserElements = useMemo(() => {
|
||||
const entries = Object.entries(onlineUsers)
|
||||
if (entries.length === 0) {
|
||||
if (realtimeUsers.length === 0) {
|
||||
return (
|
||||
<SidebarButton>
|
||||
<span className={'ms-3'}>
|
||||
|
@ -54,15 +52,19 @@ export const UsersOnlineSidebarMenu: React.FC<SpecificSidebarMenuProps> = ({
|
|||
</SidebarButton>
|
||||
)
|
||||
} else {
|
||||
return entries.map(([clientId, onlineUser]) => {
|
||||
return realtimeUsers.map((realtimeUser) => {
|
||||
return (
|
||||
<SidebarButton key={clientId}>
|
||||
<UserLine username={onlineUser.username} color={onlineUser.color} status={onlineUser.active} />
|
||||
<SidebarButton key={realtimeUser.styleIndex}>
|
||||
<UserLine
|
||||
username={realtimeUser.displayName}
|
||||
color={realtimeUser.styleIndex}
|
||||
active={realtimeUser.active}
|
||||
/>
|
||||
</SidebarButton>
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [onlineUsers])
|
||||
}, [realtimeUsers])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { WaitSpinner } from '../../common/wait-spinner/wait-spinner'
|
||||
import React from 'react'
|
||||
import { Col, Container, Modal, Row } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* Modal with a spinner that is only shown while reconnecting to the realtime backend
|
||||
*/
|
||||
export const RealtimeConnectionModal: React.FC = () => {
|
||||
const isConnected = useApplicationState((state) => state.realtimeStatus.isSynced)
|
||||
useTranslation()
|
||||
|
||||
return (
|
||||
<Modal show={!isConnected}>
|
||||
<Modal.Body>
|
||||
<Container className={'text-center'}>
|
||||
<Row className={'mb-4'}>
|
||||
<Col xs={12}>
|
||||
<WaitSpinner size={5}></WaitSpinner>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<span>
|
||||
<Trans i18nKey={'realtime.reconnect'}></Trans>
|
||||
</span>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue