mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-21 17:26:29 -05:00
Add basic realtime communication (#2118)
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
3b86afc17c
commit
0da51bba67
23 changed files with 624 additions and 53 deletions
5
docker/.gitignore
vendored
Normal file
5
docker/.gitignore
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
#
|
||||
# SPDX-License-Identifier: CC0-1.0
|
||||
|
||||
caddy
|
16
docker/Caddyfile
Normal file
16
docker/Caddyfile
Normal file
|
@ -0,0 +1,16 @@
|
|||
#
|
||||
# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
#
|
||||
|
||||
:8080
|
||||
|
||||
log {
|
||||
output stdout
|
||||
}
|
||||
|
||||
reverse_proxy /realtime http://127.0.0.1:3000
|
||||
reverse_proxy /api/* http://127.0.0.1:3000
|
||||
reverse_proxy /public/* http://127.0.0.1:3000
|
||||
reverse_proxy /* http://127.0.0.1:3001
|
13
docker/README.md
Normal file
13
docker/README.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
<!--
|
||||
SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
-->
|
||||
|
||||
To use backend and frontend together you need a reverse proxy that combines both services under one origin.
|
||||
|
||||
We provide a ready to use config for nginx and caddy.
|
||||
Make sure that in the backend HD_DOMAIN is set to `http://localhost:8080`.
|
||||
|
||||
If you have docker you can use our docker-compose file that starts a nginx using `docker-compose up`.
|
||||
If you're on Windows or macOS you rather might want to download [caddy](https://caddyserver.com/) and start it using `caddy run` in this directory.
|
10
docker/download-caddy.sh
Executable file
10
docker/download-caddy.sh
Executable file
|
@ -0,0 +1,10 @@
|
|||
#!/bin/bash
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
#
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
#
|
||||
|
||||
set -e
|
||||
curl -o caddy "https://caddyserver.com/api/download?os=linux&arch=amd64"
|
||||
chmod +x ./caddy
|
|
@ -517,7 +517,7 @@
|
|||
"delete": "Delete",
|
||||
"or": "or",
|
||||
"and": "and",
|
||||
"avatarOf": "avatar of '{{name}}'",
|
||||
"avatarOf": "Avatar of '{{name}}'",
|
||||
"why": "Why?",
|
||||
"loading": "Loading ...",
|
||||
"errorWhileLoading": "An unexpected error occurred while loading '{{name}}'.\nCheck the browser console for more information.\nReport this error only if it comes up again.",
|
||||
|
|
|
@ -1,12 +1,24 @@
|
|||
/*
|
||||
* 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
|
||||
*/
|
||||
|
||||
//TODO: [mrdrogdrog] The following function and two constants already exist in typescript code.
|
||||
// However, this file must be a js file and therefore can't access the ts function.
|
||||
// I have no idea how to resolve this redundancy without extracting it into a node module.
|
||||
const isPositiveAnswer = (value) => {
|
||||
const lowerValue = value.toLowerCase()
|
||||
return lowerValue === 'yes' || lowerValue === '1' || lowerValue === 'true'
|
||||
}
|
||||
|
||||
const isTestMode = !!process.env.NEXT_PUBLIC_TEST_MODE && isPositiveAnswer(process.env.NEXT_PUBLIC_TEST_MODE)
|
||||
|
||||
const isMockMode = !!process.env.NEXT_PUBLIC_USE_MOCK_API && isPositiveAnswer(process.env.NEXT_PUBLIC_USE_MOCK_API)
|
||||
|
||||
console.log('Node env is', process.env.NODE_ENV)
|
||||
|
||||
if (process.env.NEXT_PUBLIC_USE_MOCK_API === 'true') {
|
||||
if (isMockMode) {
|
||||
console.log('Uses mock API')
|
||||
} else if (!!process.env.NEXT_PUBLIC_BACKEND_BASE_URL) {
|
||||
console.log('Backend base url is', process.env.NEXT_PUBLIC_BACKEND_BASE_URL)
|
||||
|
@ -20,11 +32,23 @@ If you want to create a build that uses the mock api then use build:mock instead
|
|||
}
|
||||
|
||||
if (!!process.env.NEXT_PUBLIC_IGNORE_IFRAME_ORIGIN_CONFIG) {
|
||||
console.warn("You have set NEXT_PUBLIC_IGNORE_IFRAME_ORIGIN_CONFIG. This flag is ONLY for testing purposes and will decrease the security of the editor if used in production!")
|
||||
console.warn(
|
||||
'You have set NEXT_PUBLIC_IGNORE_IFRAME_ORIGIN_CONFIG. This flag is ONLY for testing purposes and will decrease the security of the editor if used in production!'
|
||||
)
|
||||
}
|
||||
|
||||
if (!!process.env.NEXT_PUBLIC_TEST_MODE) {
|
||||
console.warn('This build runs in test mode. This means:\n - no sandboxed iframe\n - Additional data-attributes for e2e tests added to DOM')
|
||||
if (isTestMode) {
|
||||
console.warn(`This build runs in test mode. This means:
|
||||
- no sandboxed iframe
|
||||
- Additional data-attributes for e2e tests added to DOM`)
|
||||
}
|
||||
|
||||
if (!!isMockMode) {
|
||||
console.warn(`This build runs in mock mode. This means:
|
||||
- No real data. All API responses are mocked
|
||||
- No persistent data
|
||||
- No realtime editing
|
||||
`)
|
||||
}
|
||||
|
||||
const path = require('path')
|
||||
|
|
11
package.json
11
package.json
|
@ -7,15 +7,17 @@
|
|||
"build:netlify": "bash netlify/patch-files.sh && cross-env NEXT_PUBLIC_IGNORE_IFRAME_ORIGIN_CONFIG=true yarn build:mock",
|
||||
"build:mock": "cross-env NEXT_PUBLIC_USE_MOCK_API=true next build",
|
||||
"build:test": "cross-env NEXT_PUBLIC_USE_MOCK_API=true NEXT_PUBLIC_TEST_MODE=true next build",
|
||||
"build:for-real-backend": "cross-env NEXT_PUBLIC_USE_MOCK_API=false NEXT_PUBLIC_BACKEND_BASE_URL=/ next build",
|
||||
"analyze": "cross-env ANALYZE=true next build",
|
||||
"dev": "cross-env PORT=3001 next dev",
|
||||
"dev:test": "cross-env PORT=3001 NEXT_PUBLIC_TEST_MODE=true next dev",
|
||||
"dev:for-real-backend": "cross-env PORT=3001 NEXT_PUBLIC_USE_MOCK_API=false NEXT_PUBLIC_BACKEND_BASE_URL=http://localhost:3000/ next dev",
|
||||
"dev:for-real-backend": "cross-env PORT=3001 NEXT_PUBLIC_USE_MOCK_API=false NEXT_PUBLIC_BACKEND_BASE_URL=http://localhost:8080/ next dev",
|
||||
"format": "prettier -c \"src/**/*.{ts,tsx,js}\" \"cypress/**/*.{ts,tsx}\"",
|
||||
"format:fix": "prettier -w \"src/**/*.{ts,tsx,js}\" \"cypress/**/*.{ts,tsx}\"",
|
||||
"lint": "eslint --max-warnings=0 --ext .ts,.tsx src",
|
||||
"lint:fix": "eslint --fix --ext .ts,.tsx src",
|
||||
"start": "cross-env PORT=3001 next start",
|
||||
"start:for-real-backend": "cross-env PORT=3001 NEXT_PUBLIC_USE_MOCK_API=false NEXT_PUBLIC_BACKEND_BASE_URL=/ next start",
|
||||
"start:mock": "cross-env PORT=3001 NEXT_PUBLIC_USE_MOCK_API=true next start",
|
||||
"start:ci": "cross-env NEXT_PUBLIC_USE_MOCK_API=true NEXT_PUBLIC_TEST_MODE=true PORT=3001 next start",
|
||||
"cy:open": "cypress open",
|
||||
|
@ -39,11 +41,14 @@
|
|||
"dependencies": {
|
||||
"@codemirror/lang-markdown": "6.0.0",
|
||||
"@codemirror/language-data": "6.1.0",
|
||||
"@codemirror/state": "6.0.0",
|
||||
"@codemirror/theme-one-dark": "6.0.0",
|
||||
"@codemirror/view": "6.0.0",
|
||||
"@fontsource/source-sans-pro": "4.5.10",
|
||||
"@hedgedoc/html-to-react": "1.4.1",
|
||||
"@hedgedoc/markdown-it-image-size": "1.0.3",
|
||||
"@hedgedoc/markdown-it-task-lists": "1.0.3",
|
||||
"@hedgedoc/realtime": "0.0.3",
|
||||
"@matejmazur/react-katex": "3.1.3",
|
||||
"@react-hook/resize-observer": "1.2.5",
|
||||
"@redux-devtools/core": "3.13.1",
|
||||
|
@ -111,7 +116,9 @@
|
|||
"vega": "5.22.1",
|
||||
"vega-embed": "6.21.0",
|
||||
"vega-lite": "5.2.0",
|
||||
"words-count": "2.0.2"
|
||||
"words-count": "2.0.2",
|
||||
"y-codemirror.next": "0.3.0",
|
||||
"y-protocols": "1.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "12.1.6",
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
@ -19,7 +19,7 @@ export const ApplicationLoader: React.FC<PropsWithChildren<unknown>> = ({ childr
|
|||
const initTasks = createSetUpTaskList()
|
||||
for (const task of initTasks) {
|
||||
try {
|
||||
await task.task
|
||||
await task.task()
|
||||
} catch (reason: unknown) {
|
||||
log.error('Error while initialising application', reason)
|
||||
throw new ApplicationLoaderError(task.name)
|
||||
|
|
|
@ -27,38 +27,38 @@ const customDelay: () => Promise<void> = async () => {
|
|||
|
||||
export interface InitTask {
|
||||
name: string
|
||||
task: Promise<void>
|
||||
task: () => Promise<void>
|
||||
}
|
||||
|
||||
export const createSetUpTaskList = (): InitTask[] => {
|
||||
return [
|
||||
{
|
||||
name: 'Load dark mode',
|
||||
task: loadDarkMode()
|
||||
task: loadDarkMode
|
||||
},
|
||||
{
|
||||
name: 'Load Translations',
|
||||
task: setUpI18n()
|
||||
task: setUpI18n
|
||||
},
|
||||
{
|
||||
name: 'Load config',
|
||||
task: fetchFrontendConfig()
|
||||
task: fetchFrontendConfig
|
||||
},
|
||||
{
|
||||
name: 'Fetch user information',
|
||||
task: fetchAndSetUser()
|
||||
task: fetchAndSetUser
|
||||
},
|
||||
{
|
||||
name: 'Motd',
|
||||
task: fetchMotd()
|
||||
task: fetchMotd
|
||||
},
|
||||
{
|
||||
name: 'Load history state',
|
||||
task: refreshHistoryState()
|
||||
task: refreshHistoryState
|
||||
},
|
||||
{
|
||||
name: 'Add Delay',
|
||||
task: customDelay()
|
||||
task: customDelay
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -4,13 +4,11 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import type { ScrollProps } from '../synced-scroll/scroll-props'
|
||||
import { StatusBar } from './status-bar/status-bar'
|
||||
import { ToolBar } from './tool-bar/tool-bar'
|
||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
||||
import { setNoteContent } from '../../../redux/note-details/methods'
|
||||
import { useNoteMarkdownContent } from '../../../hooks/common/use-note-markdown-content'
|
||||
import { MaxLengthWarning } from './max-length-warning/max-length-warning'
|
||||
import ReactCodeMirror from '@uiw/react-codemirror'
|
||||
import { useApplyScrollState } from './hooks/use-apply-scroll-state'
|
||||
|
@ -29,10 +27,14 @@ import { useCursorActivityCallback } from './hooks/use-cursor-activity-callback'
|
|||
import { useCodeMirrorReference, useSetCodeMirrorReference } from '../change-content-context/change-content-context'
|
||||
import { useCodeMirrorTablePasteExtension } from './hooks/table-paste/use-code-mirror-table-paste-extension'
|
||||
import { useOnImageUploadFromRenderer } from './hooks/image-upload-from-renderer/use-on-image-upload-from-renderer'
|
||||
import { useCodeMirrorYjsExtension } from './hooks/yjs/use-code-mirror-yjs-extension'
|
||||
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'
|
||||
|
||||
export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMakeScrollSource }) => {
|
||||
const markdownContent = useNoteMarkdownContent()
|
||||
|
||||
const ligaturesEnabled = useApplicationState((state) => state.editorConfig.ligatures)
|
||||
|
||||
useApplyScrollState(scrollState)
|
||||
|
@ -42,10 +44,6 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
|||
const fileInsertExtension = useCodeMirrorFileInsertExtension()
|
||||
const cursorActivityExtension = useCursorActivityCallback()
|
||||
|
||||
const onBeforeChange = useCallback((value: string): void => {
|
||||
setNoteContent(value)
|
||||
}, [])
|
||||
|
||||
const codeMirrorRef = useCodeMirrorReference()
|
||||
const setCodeMirrorReference = useSetCodeMirrorReference()
|
||||
|
||||
|
@ -57,6 +55,16 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
|||
})
|
||||
}, [codeMirrorRef, setCodeMirrorReference])
|
||||
|
||||
const yDoc = useYDoc()
|
||||
const awareness = useAwareness(yDoc)
|
||||
const yText = useMemo(() => yDoc.getText('markdownContent'), [yDoc])
|
||||
|
||||
useWebsocketConnection(yDoc, awareness)
|
||||
useBindYTextToRedux(yText)
|
||||
|
||||
const yjsExtension = useCodeMirrorYjsExtension(yText, awareness)
|
||||
const mockContentExtension = useInsertInitialNoteContentIntoEditorInMockMode(yText)
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
markdown({
|
||||
|
@ -69,9 +77,19 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
|||
fileInsertExtension,
|
||||
autocompletion(),
|
||||
cursorActivityExtension,
|
||||
updateViewContext
|
||||
updateViewContext,
|
||||
yjsExtension,
|
||||
...(mockContentExtension ? [mockContentExtension] : [])
|
||||
],
|
||||
[cursorActivityExtension, fileInsertExtension, tablePasteExtensions, editorScrollExtension, updateViewContext]
|
||||
[
|
||||
editorScrollExtension,
|
||||
tablePasteExtensions,
|
||||
fileInsertExtension,
|
||||
cursorActivityExtension,
|
||||
updateViewContext,
|
||||
yjsExtension,
|
||||
mockContentExtension
|
||||
]
|
||||
)
|
||||
|
||||
useOnImageUploadFromRenderer()
|
||||
|
@ -102,8 +120,6 @@ export const EditorPane: React.FC<ScrollProps> = ({ scrollState, onScroll, onMak
|
|||
basicSetup={true}
|
||||
className={codeMirrorClassName}
|
||||
theme={oneDark}
|
||||
value={markdownContent}
|
||||
onChange={onBeforeChange}
|
||||
/>
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
@ -15,4 +15,12 @@
|
|||
font-variant-ligatures: none;
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
.cm-widgetBuffer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
//workarounds for line break problem.. see https://github.com/yjs/y-codemirror.next/pull/12
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,9 @@ import type { ScrollState } from '../../synced-scroll/scroll-props'
|
|||
import { EditorView } from '@codemirror/view'
|
||||
import equal from 'fast-deep-equal'
|
||||
import { useCodeMirrorReference } from '../../change-content-context/change-content-context'
|
||||
import { Logger } from '../../../../utils/logger'
|
||||
|
||||
const logger = new Logger('useApplyScrollState')
|
||||
|
||||
/**
|
||||
* Applies the given {@link ScrollState scroll state} to the given {@link EditorView code mirror editor view}.
|
||||
|
@ -16,12 +19,16 @@ import { useCodeMirrorReference } from '../../change-content-context/change-cont
|
|||
* @param view The {@link EditorView view} that should be scrolled
|
||||
* @param scrollState The {@link ScrollState scroll state} that should be applied
|
||||
*/
|
||||
export const applyScrollState = (view: EditorView, scrollState: ScrollState): void => {
|
||||
const line = view.state.doc.line(scrollState.firstLineInView)
|
||||
const lineBlock = view.lineBlockAt(line.from)
|
||||
const margin = Math.floor(lineBlock.height * scrollState.scrolledPercentage) / 100
|
||||
const stateEffect = EditorView.scrollIntoView(line.from, { y: 'start', yMargin: -margin })
|
||||
view.dispatch({ effects: [stateEffect] })
|
||||
const applyScrollState = (view: EditorView, scrollState: ScrollState): void => {
|
||||
try {
|
||||
const line = view.state.doc.line(scrollState.firstLineInView)
|
||||
const lineBlock = view.lineBlockAt(line.from)
|
||||
const margin = Math.floor(lineBlock.height * scrollState.scrolledPercentage) / 100
|
||||
const stateEffect = EditorView.scrollIntoView(line.from, { y: 'start', yMargin: -margin })
|
||||
view.dispatch({ effects: [stateEffect] })
|
||||
} catch (error) {
|
||||
logger.error('Error while applying scroll status', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { YDocMessageTransporter } from '@hedgedoc/realtime'
|
||||
import type { Doc } from 'yjs'
|
||||
import type { Awareness } from 'y-protocols/awareness'
|
||||
|
||||
/**
|
||||
* 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')
|
||||
this.markAsSynced()
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
//Intentionally left empty because this is a mocked connection
|
||||
}
|
||||
|
||||
send(): void {
|
||||
//Intentionally left empty because this is a mocked connection
|
||||
}
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Awareness } from 'y-protocols/awareness'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { addOnlineUser, removeOnlineUser } from '../../../../../redux/realtime/methods'
|
||||
import { ActiveIndicatorStatus } from '../../../../../redux/realtime/types'
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
import type { Doc } from 'yjs'
|
||||
import { Logger } from '../../../../../utils/logger'
|
||||
|
||||
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
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { setNoteContent } from '../../../../../redux/note-details/methods'
|
||||
import type { YText } from 'yjs/dist/src/types/YText'
|
||||
|
||||
/**
|
||||
* One-Way-synchronizes the text of the given {@link YText y-text} into the global application state.
|
||||
*4
|
||||
* @param yText The source text
|
||||
*/
|
||||
export const useBindYTextToRedux = (yText: YText): void => {
|
||||
useEffect(() => {
|
||||
const yTextCallback = () => setNoteContent(yText.toString())
|
||||
yText.observe(yTextCallback)
|
||||
return () => yText.unobserve(yTextCallback)
|
||||
}, [yText])
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { Extension } from '@codemirror/state'
|
||||
import { yCollab } from 'y-codemirror.next'
|
||||
import type { Awareness } from 'y-protocols/awareness'
|
||||
import type { YText } from 'yjs/dist/src/types/YText'
|
||||
|
||||
/**
|
||||
* Creates a {@link Extension code mirror extension} that synchronizes an editor with the given {@link YText ytext} and {@link Awareness awareness}.
|
||||
*
|
||||
* @param yText The source and target for the editor content
|
||||
* @param awareness Contains cursor positions and names from other clients that will be shown
|
||||
* @return the created extension
|
||||
*/
|
||||
export const useCodeMirrorYjsExtension = (yText: YText, awareness: Awareness): Extension => {
|
||||
return useMemo(() => yCollab(yText, awareness), [awareness, yText])
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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])
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { WebsocketConnection } from './websocket-connection'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useWebsocketUrl } from './use-websocket-url'
|
||||
import type { Doc } from 'yjs'
|
||||
import type { Awareness } from 'y-protocols/awareness'
|
||||
import { isMockMode } from '../../../../../utils/test-modes'
|
||||
import { MockConnection } from './mock-connection'
|
||||
import type { YDocMessageTransporter } from '@hedgedoc/realtime'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { backendUrl } from '../../../../../utils/backend-url'
|
||||
import { isMockMode } from '../../../../../utils/test-modes'
|
||||
import { useApplicationState } from '../../../../../hooks/common/use-application-state'
|
||||
|
||||
const LOCAL_FALLBACK_URL = 'ws://localhost:8080/realtime/'
|
||||
|
||||
/**
|
||||
* Provides the URL for the realtime endpoint.
|
||||
*/
|
||||
export const useWebsocketUrl = (): URL => {
|
||||
const noteId = useApplicationState((state) => state.noteDetails.id)
|
||||
|
||||
const baseUrl = useMemo(() => {
|
||||
if (isMockMode) {
|
||||
return process.env.NEXT_PUBLIC_REALTIME_URL ?? LOCAL_FALLBACK_URL
|
||||
}
|
||||
try {
|
||||
const backendBaseUrlParsed = new URL(backendUrl)
|
||||
backendBaseUrlParsed.protocol = backendBaseUrlParsed.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
backendBaseUrlParsed.pathname += 'realtime'
|
||||
return backendBaseUrlParsed.toString()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return LOCAL_FALLBACK_URL
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useMemo(() => {
|
||||
const url = new URL(baseUrl)
|
||||
url.search = `?noteId=${noteId}`
|
||||
return url
|
||||
}, [baseUrl, noteId])
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Doc } from 'yjs'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
/**
|
||||
* Creates a new {@link Doc y-doc}.
|
||||
*
|
||||
* @return The created {@link Doc y-doc}
|
||||
*/
|
||||
export const useYDoc = (): Doc => {
|
||||
const yDoc = useMemo(() => new Doc(), [])
|
||||
useEffect(() => () => yDoc.destroy(), [yDoc])
|
||||
return yDoc
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import {
|
||||
encodeAwarenessUpdateMessage,
|
||||
encodeCompleteAwarenessStateRequestMessage,
|
||||
encodeDocumentUpdateMessage
|
||||
} from '@hedgedoc/realtime'
|
||||
import { WebsocketTransporter } from '@hedgedoc/realtime'
|
||||
import type { Doc } from 'yjs'
|
||||
import type { Awareness } from 'y-protocols/awareness'
|
||||
|
||||
/**
|
||||
* 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)
|
||||
websocket.addEventListener('open', this.onOpen.bind(this))
|
||||
}
|
||||
|
||||
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.destroy()
|
||||
awareness.off('update', updateCallback)
|
||||
})
|
||||
|
||||
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.send(encodeDocumentUpdateMessage(update))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -6,8 +6,25 @@
|
|||
|
||||
import { isMockMode } from './test-modes'
|
||||
|
||||
if (!isMockMode && process.env.NEXT_PUBLIC_BACKEND_BASE_URL === undefined) {
|
||||
throw new Error('NEXT_PUBLIC_BACKEND_BASE_URL is unset and mock mode is disabled')
|
||||
/**
|
||||
* Generates the backend URL from the environment variable `NEXT_PUBLIC_BACKEND_BASE_URL` or the mock default if mock mode is activated.
|
||||
*
|
||||
* @throws Error if the environment variable is unset or doesn't end with "/"
|
||||
* @return the backend url that should be used in the app
|
||||
*/
|
||||
const generateBackendUrl = (): string => {
|
||||
if (!isMockMode) {
|
||||
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_BASE_URL
|
||||
if (backendUrl === undefined) {
|
||||
throw new Error('NEXT_PUBLIC_BACKEND_BASE_URL is unset and mock mode is disabled')
|
||||
} else if (!backendUrl.endsWith('/')) {
|
||||
throw new Error("NEXT_PUBLIC_BACKEND_BASE_URL must end with an '/'")
|
||||
} else {
|
||||
return backendUrl
|
||||
}
|
||||
} else {
|
||||
return '/'
|
||||
}
|
||||
}
|
||||
|
||||
export const backendUrl = isMockMode ? '/' : (process.env.NEXT_PUBLIC_BACKEND_BASE_URL as string)
|
||||
export const backendUrl = generateBackendUrl()
|
||||
|
|
108
yarn.lock
108
yarn.lock
|
@ -1739,7 +1739,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@codemirror/state@npm:^6.0.0":
|
||||
"@codemirror/state@npm:6.0.0, @codemirror/state@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "@codemirror/state@npm:6.0.0"
|
||||
checksum: 7f6286d8e8b8c5e7018f9ee81943b35324150fd15ccc77ae220b904a73e10fa480ab51a89663d956a9c2fae70b13d754da0bf535092759158cc3707743aa236f
|
||||
|
@ -1758,7 +1758,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@codemirror/view@npm:^6.0.0":
|
||||
"@codemirror/view@npm:6.0.0, @codemirror/view@npm:^6.0.0":
|
||||
version: 6.0.0
|
||||
resolution: "@codemirror/view@npm:6.0.0"
|
||||
dependencies:
|
||||
|
@ -1984,11 +1984,14 @@ __metadata:
|
|||
dependencies:
|
||||
"@codemirror/lang-markdown": 6.0.0
|
||||
"@codemirror/language-data": 6.1.0
|
||||
"@codemirror/state": 6.0.0
|
||||
"@codemirror/theme-one-dark": 6.0.0
|
||||
"@codemirror/view": 6.0.0
|
||||
"@fontsource/source-sans-pro": 4.5.10
|
||||
"@hedgedoc/html-to-react": 1.4.1
|
||||
"@hedgedoc/markdown-it-image-size": 1.0.3
|
||||
"@hedgedoc/markdown-it-task-lists": 1.0.3
|
||||
"@hedgedoc/realtime": 0.0.3
|
||||
"@matejmazur/react-katex": 3.1.3
|
||||
"@next/bundle-analyzer": 12.1.6
|
||||
"@react-hook/resize-observer": 1.2.5
|
||||
|
@ -2101,9 +2104,24 @@ __metadata:
|
|||
vega-embed: 6.21.0
|
||||
vega-lite: 5.2.0
|
||||
words-count: 2.0.2
|
||||
y-codemirror.next: 0.3.0
|
||||
y-protocols: 1.0.5
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@hedgedoc/realtime@npm:0.0.3":
|
||||
version: 0.0.3
|
||||
resolution: "@hedgedoc/realtime@npm:0.0.3"
|
||||
dependencies:
|
||||
isomorphic-ws: 4.0.1
|
||||
lib0: 0.2.51
|
||||
typed-emitter: 2.1.0
|
||||
y-protocols: 1.0.5
|
||||
yjs: 13.5.38
|
||||
checksum: cfb1dc7459b1699841a9ed29147ffe5397206b9b87bb5410d6c4000d1a3a4db6f4d7862930bbff8a0b1aaf7ba8bdcac8904ed0fd626df684c486121afab6f089
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@hpcc-js/wasm@npm:1.12.8":
|
||||
version: 1.12.8
|
||||
resolution: "@hpcc-js/wasm@npm:1.12.8"
|
||||
|
@ -12954,6 +12972,22 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isomorphic-ws@npm:4.0.1":
|
||||
version: 4.0.1
|
||||
resolution: "isomorphic-ws@npm:4.0.1"
|
||||
peerDependencies:
|
||||
ws: "*"
|
||||
checksum: d7190eadefdc28bdb93d67b5f0c603385aaf87724fa2974abb382ac1ec9756ed2cfb27065cbe76122879c2d452e2982bc4314317f3d6c737ddda6c047328771a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isomorphic.js@npm:^0.2.4":
|
||||
version: 0.2.5
|
||||
resolution: "isomorphic.js@npm:0.2.5"
|
||||
checksum: d8d1b083f05f3c337a06628b982ac3ce6db953bbef14a9de8ad49131250c3592f864b73c12030fdc9ef138ce97b76ef55c7d96a849561ac215b1b4b9d301c8e9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"isstream@npm:~0.1.2":
|
||||
version: 0.1.2
|
||||
resolution: "isstream@npm:0.1.2"
|
||||
|
@ -14024,6 +14058,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lib0@npm:0.2.51, lib0@npm:^0.2.42, lib0@npm:^0.2.49":
|
||||
version: 0.2.51
|
||||
resolution: "lib0@npm:0.2.51"
|
||||
dependencies:
|
||||
isomorphic.js: ^0.2.4
|
||||
checksum: bdd00ba42b66d27d048fc169e7d472b9dfe9140e067daeb92db82f40209365d9399aaed679078cc440c496c43d429427b0e231dbaaf171793d98ea6f5476aa3a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lines-and-columns@npm:^1.1.6":
|
||||
version: 1.2.4
|
||||
resolution: "lines-and-columns@npm:1.2.4"
|
||||
|
@ -18028,6 +18071,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rxjs@npm:*, rxjs@npm:^7.5.1":
|
||||
version: 7.5.5
|
||||
resolution: "rxjs@npm:7.5.5"
|
||||
dependencies:
|
||||
tslib: ^2.1.0
|
||||
checksum: e034f60805210cce756dd2f49664a8108780b117cf5d0e2281506e9e6387f7b4f1532d974a8c8b09314fa7a16dd2f6cff3462072a5789672b5dcb45c4173f3c6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rxjs@npm:^6.3.3, rxjs@npm:^6.4.0, rxjs@npm:^6.6.2":
|
||||
version: 6.6.7
|
||||
resolution: "rxjs@npm:6.6.7"
|
||||
|
@ -18037,15 +18089,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"rxjs@npm:^7.5.1":
|
||||
version: 7.5.5
|
||||
resolution: "rxjs@npm:7.5.5"
|
||||
dependencies:
|
||||
tslib: ^2.1.0
|
||||
checksum: e034f60805210cce756dd2f49664a8108780b117cf5d0e2281506e9e6387f7b4f1532d974a8c8b09314fa7a16dd2f6cff3462072a5789672b5dcb45c4173f3c6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0":
|
||||
version: 5.2.1
|
||||
resolution: "safe-buffer@npm:5.2.1"
|
||||
|
@ -19906,6 +19949,18 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typed-emitter@npm:2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "typed-emitter@npm:2.1.0"
|
||||
dependencies:
|
||||
rxjs: "*"
|
||||
dependenciesMeta:
|
||||
rxjs:
|
||||
optional: true
|
||||
checksum: 95821a9e05784b972cc9d152891fd12a56cb4b1a7c57e768c02bea6a8984da7aff8f19404a7b69eea11fae2a3b6c0c510a4c510f575f50162c759ae9059f2520
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"typed-styles@npm:^0.0.7":
|
||||
version: 0.0.7
|
||||
resolution: "typed-styles@npm:0.0.7"
|
||||
|
@ -21149,6 +21204,28 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"y-codemirror.next@npm:0.3.0":
|
||||
version: 0.3.0
|
||||
resolution: "y-codemirror.next@npm:0.3.0"
|
||||
dependencies:
|
||||
lib0: ^0.2.42
|
||||
peerDependencies:
|
||||
"@codemirror/state": ^6.0.0
|
||||
"@codemirror/view": ^6.0.0
|
||||
yjs: ^13.5.6
|
||||
checksum: f9aa8fade4cfa632df83e0eb87d736b8c3c5bbbab835affaaaf9c0a98c115329332586d58d5a8279c5502a19cbad77b83ca22f74f5d5dd7ccc4ce8bca85f6885
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"y-protocols@npm:1.0.5":
|
||||
version: 1.0.5
|
||||
resolution: "y-protocols@npm:1.0.5"
|
||||
dependencies:
|
||||
lib0: ^0.2.42
|
||||
checksum: d19404a4ebafcf3761c28b881abe8c32ab6e457db0e5ffc7dbb749cbc2c3bb98e003a43f3e8eba7f245b2698c76f2c4cdd1c2db869f8ec0c6ef94736d9a88652
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"y18n@npm:^5.0.5":
|
||||
version: 5.0.8
|
||||
resolution: "y18n@npm:5.0.8"
|
||||
|
@ -21246,6 +21323,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yjs@npm:13.5.38":
|
||||
version: 13.5.38
|
||||
resolution: "yjs@npm:13.5.38"
|
||||
dependencies:
|
||||
lib0: ^0.2.49
|
||||
checksum: 48be9cfe514b5f9f7c64cddf38f7fd59142fe56553200ba88543c6c2c5df6a6be6876440d40f8b1a0f34e5a92ba8efe6b8da5da657278393ceb5171c4fc3a4e0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"yn@npm:3.1.1":
|
||||
version: 3.1.1
|
||||
resolution: "yn@npm:3.1.1"
|
||||
|
|
Loading…
Reference in a new issue