mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-29 04:55:03 +02:00
Cloud collaborator refactoring (#6424)
This commit is contained in:
@@ -16,14 +16,12 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { type Class, type CollaborativeDoc, type Doc, type Ref } from '@hcengineering/core'
|
||||
import { type DocumentId, type PlatformDocumentId } from '@hcengineering/collaborator-client'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import { onDestroy, setContext } from 'svelte'
|
||||
import textEditor, { CollaborationIds } from '@hcengineering/text-editor'
|
||||
import { CollaborationIds } from '@hcengineering/text-editor'
|
||||
|
||||
import { TiptapCollabProvider, createTiptapCollaborationData } from '../provider/tiptap'
|
||||
import { formatCollaborativeDocumentId, formatPlatformDocumentId } from '../provider/utils'
|
||||
import { createTiptapCollaborationData } from '../provider/utils'
|
||||
import { Provider } from '../provider/types'
|
||||
import { formatDocumentId } from '@hcengineering/collaborator-client'
|
||||
|
||||
export let collaborativeDoc: CollaborativeDoc
|
||||
export let initialCollaborativeDoc: CollaborativeDoc | undefined = undefined
|
||||
@@ -32,50 +30,34 @@
|
||||
export let objectId: Ref<Doc> | undefined = undefined
|
||||
export let objectAttr: string | undefined = undefined
|
||||
|
||||
const token = getMetadata(presentation.metadata.Token) ?? ''
|
||||
const collaboratorURL = getMetadata(textEditor.metadata.CollaboratorUrl) ?? ''
|
||||
let provider: Provider | undefined
|
||||
|
||||
let initialContentId: DocumentId | undefined
|
||||
let platformDocumentId: PlatformDocumentId | undefined
|
||||
// while editing, collaborative doc may change, hence we need to
|
||||
// build stable key to ensure we don't do unnecessary updates
|
||||
$: documentId = formatDocumentId('', collaborativeDoc)
|
||||
|
||||
$: documentId = formatCollaborativeDocumentId(collaborativeDoc)
|
||||
$: if (initialCollaborativeDoc !== undefined) {
|
||||
initialContentId = formatCollaborativeDocumentId(initialCollaborativeDoc)
|
||||
}
|
||||
$: if (objectClass !== undefined && objectId !== undefined && objectAttr !== undefined) {
|
||||
platformDocumentId = formatPlatformDocumentId(objectClass, objectId, objectAttr)
|
||||
}
|
||||
|
||||
let _documentId: DocumentId | undefined
|
||||
|
||||
let provider: TiptapCollabProvider | undefined
|
||||
let _documentId: string | undefined
|
||||
|
||||
$: if (_documentId !== documentId) {
|
||||
_documentId = documentId
|
||||
|
||||
if (provider !== undefined) {
|
||||
provider.disconnect()
|
||||
void provider.destroy()
|
||||
}
|
||||
const data = createTiptapCollaborationData({
|
||||
documentId,
|
||||
initialContentId,
|
||||
platformDocumentId,
|
||||
collaboratorURL,
|
||||
token
|
||||
document: collaborativeDoc,
|
||||
initialDocument: initialCollaborativeDoc,
|
||||
objectClass,
|
||||
objectId,
|
||||
objectAttr
|
||||
})
|
||||
provider = data.provider
|
||||
setContext(CollaborationIds.Doc, data.ydoc)
|
||||
setContext(CollaborationIds.Provider, provider)
|
||||
|
||||
provider.on('status', (event: any) => {
|
||||
console.log('Collaboration:', documentId, event.status) // logs "connected" or "disconnected"
|
||||
})
|
||||
provider.on('synced', (event: any) => {
|
||||
console.log('Collaboration:', event) // logs "connected" or "disconnected"
|
||||
})
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
provider?.destroy()
|
||||
void provider?.destroy()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,29 +16,32 @@
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import type { AwarenessState, AwarenessStateMap } from '@hcengineering/text-editor'
|
||||
import { AnySvelteComponent, Button, DelayedCaller } from '@hcengineering/ui'
|
||||
import { onMount } from 'svelte'
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { onMount } from 'svelte'
|
||||
import { createRelativePositionFromJSON } from 'yjs'
|
||||
import { relativePositionToAbsolutePosition, ySyncPluginKey } from 'y-prosemirror'
|
||||
import { Provider } from '../provider/types'
|
||||
|
||||
import { TiptapCollabProvider } from '../provider/tiptap'
|
||||
import { AwarenessChangeEvent, CollaborationUserState } from '@hcengineering/text-editor'
|
||||
|
||||
export let provider: TiptapCollabProvider
|
||||
export let provider: Provider
|
||||
export let editor: Editor
|
||||
export let component: AnySvelteComponent
|
||||
|
||||
let states: CollaborationUserState[] = []
|
||||
let states: AwarenessState[] = []
|
||||
|
||||
const debounce = new DelayedCaller(100)
|
||||
const onAwarenessChange = (event: AwarenessChangeEvent): void => {
|
||||
const onAwarenessChange = (): void => {
|
||||
debounce.call(() => {
|
||||
states = event.states.filter((p) => p.user != null).filter((p) => p.clientId !== provider.awareness?.clientID)
|
||||
const map: AwarenessStateMap = provider.awareness?.states ?? new Map()
|
||||
const entries: Array<[number, AwarenessState]> = Array.from(map.entries())
|
||||
states = entries
|
||||
.filter(([clientId, state]) => clientId !== provider.awareness?.clientID && state.user != null)
|
||||
.map(([_, state]) => state)
|
||||
})
|
||||
}
|
||||
|
||||
function goToCursor (state: CollaborationUserState): void {
|
||||
function goToCursor (state: AwarenessState): void {
|
||||
const cursor = state.cursor
|
||||
if (cursor?.head != null) {
|
||||
try {
|
||||
@@ -60,8 +63,8 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
provider.on('awarenessUpdate', onAwarenessChange)
|
||||
return () => provider.off('awarenessUpdate', onAwarenessChange)
|
||||
provider.awareness?.on('update', onAwarenessChange)
|
||||
return () => provider.awareness?.off('update', onAwarenessChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -15,10 +15,9 @@
|
||||
//
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { type DocumentId, type PlatformDocumentId } from '@hcengineering/collaborator-client'
|
||||
import { type Space, type Class, type CollaborativeDoc, type Doc, type Ref } from '@hcengineering/core'
|
||||
import { IntlString, getMetadata, translate } from '@hcengineering/platform'
|
||||
import presentation, { getFileUrl, getImageSize } from '@hcengineering/presentation'
|
||||
import { IntlString, translate } from '@hcengineering/platform'
|
||||
import { getFileUrl, getImageSize } from '@hcengineering/presentation'
|
||||
import { markupToJSON } from '@hcengineering/text'
|
||||
import {
|
||||
AnySvelteComponent,
|
||||
@@ -43,9 +42,8 @@
|
||||
import { deleteAttachment } from '../command/deleteAttachment'
|
||||
import { textEditorCommandHandler } from '../commands'
|
||||
import { EditorKitOptions, getEditorKit } from '../../src/kits/editor-kit'
|
||||
import { IndexeddbProvider } from '../provider/indexeddb'
|
||||
import { TiptapCollabProvider } from '../provider/tiptap'
|
||||
import { formatCollaborativeDocumentId, formatPlatformDocumentId } from '../provider/utils'
|
||||
import { Provider } from '../provider/types'
|
||||
import { createLocalProvider, createRemoteProvider } from '../provider/utils'
|
||||
import textEditor, {
|
||||
CollaborationIds,
|
||||
CollaborationUser,
|
||||
@@ -103,37 +101,19 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const token = getMetadata(presentation.metadata.Token) ?? ''
|
||||
const collaboratorURL = getMetadata(textEditor.metadata.CollaboratorUrl) ?? ''
|
||||
|
||||
const documentId = formatCollaborativeDocumentId(collaborativeDoc)
|
||||
|
||||
let initialContentId: DocumentId | undefined
|
||||
if (initialCollaborativeDoc !== undefined) {
|
||||
initialContentId = formatCollaborativeDocumentId(collaborativeDoc)
|
||||
}
|
||||
|
||||
let platformDocumentId: PlatformDocumentId | undefined
|
||||
if (objectClass !== undefined && objectId !== undefined && objectAttr !== undefined) {
|
||||
platformDocumentId = formatPlatformDocumentId(objectClass, objectId, objectAttr)
|
||||
}
|
||||
|
||||
const ydoc = getContext<YDoc>(CollaborationIds.Doc) ?? new YDoc()
|
||||
const contextProvider = getContext<TiptapCollabProvider>(CollaborationIds.Provider)
|
||||
const contextProvider = getContext<Provider>(CollaborationIds.Provider)
|
||||
|
||||
const localProvider = new IndexeddbProvider(collaborativeDoc, ydoc)
|
||||
const localProvider = createLocalProvider(ydoc, collaborativeDoc)
|
||||
|
||||
const remoteProvider: TiptapCollabProvider =
|
||||
const remoteProvider =
|
||||
contextProvider ??
|
||||
new TiptapCollabProvider({
|
||||
url: collaboratorURL,
|
||||
name: documentId,
|
||||
document: ydoc,
|
||||
token,
|
||||
parameters: {
|
||||
initialContentId,
|
||||
platformDocumentId
|
||||
}
|
||||
createRemoteProvider(ydoc, {
|
||||
document: collaborativeDoc,
|
||||
initialDocument: initialCollaborativeDoc,
|
||||
objectClass,
|
||||
objectId,
|
||||
objectAttr
|
||||
})
|
||||
|
||||
let localSynced = false
|
||||
@@ -478,7 +458,7 @@
|
||||
} catch (err: any) {}
|
||||
}
|
||||
if (contextProvider === undefined) {
|
||||
remoteProvider.destroy()
|
||||
void remoteProvider.destroy()
|
||||
}
|
||||
void localProvider.destroy()
|
||||
})
|
||||
|
||||
@@ -73,12 +73,8 @@ export { InlineToolbarExtension, type InlineStyleToolbarOptions } from './compon
|
||||
export { ImageExtension, type ImageOptions } from './components/extension/imageExt'
|
||||
export { ImageUploadExtension, type ImageUploadOptions } from './components/extension/imageUploadExt'
|
||||
export * from './command/deleteAttachment'
|
||||
export {
|
||||
TiptapCollabProvider,
|
||||
type TiptapCollabProviderConfiguration,
|
||||
createTiptapCollaborationData
|
||||
} from './provider/tiptap'
|
||||
export { formatCollaborativeDocumentId, formatPlatformDocumentId } from './provider/utils'
|
||||
export { createTiptapCollaborationData } from './provider/utils'
|
||||
export { type Provider } from './provider/types'
|
||||
|
||||
export default async (): Promise<Resources> => ({
|
||||
function: {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
import { type Doc as YDoc } from 'yjs'
|
||||
import { WebsocketProvider } from 'y-websocket'
|
||||
|
||||
import { type Provider } from './types'
|
||||
|
||||
export interface DatalakeCollabProviderParameters {
|
||||
url: string
|
||||
name: string
|
||||
token: string
|
||||
|
||||
document: YDoc
|
||||
}
|
||||
|
||||
export class CloudCollabProvider extends WebsocketProvider implements Provider {
|
||||
readonly loaded: Promise<void>
|
||||
|
||||
constructor (params: DatalakeCollabProviderParameters) {
|
||||
const { document, url, name } = params
|
||||
|
||||
super(url, encodeURIComponent(name), document)
|
||||
|
||||
this.loaded = new Promise((resolve) => {
|
||||
this.on('synced', resolve)
|
||||
})
|
||||
}
|
||||
|
||||
destroy (): void {
|
||||
this.disconnect()
|
||||
}
|
||||
}
|
||||
+7
-30
@@ -14,23 +14,23 @@
|
||||
//
|
||||
import { type DocumentId, type PlatformDocumentId } from '@hcengineering/collaborator-client'
|
||||
import { HocuspocusProvider, type HocuspocusProviderConfiguration } from '@hocuspocus/provider'
|
||||
import { Doc as Ydoc } from 'yjs'
|
||||
import { type Provider } from './types'
|
||||
|
||||
export type TiptapCollabProviderConfiguration = HocuspocusProviderConfiguration &
|
||||
export type HocuspocusCollabProviderConfiguration = HocuspocusProviderConfiguration &
|
||||
Required<Pick<HocuspocusProviderConfiguration, 'token'>> &
|
||||
Omit<HocuspocusProviderConfiguration, 'parameters'> & {
|
||||
parameters: TiptapCollabProviderURLParameters
|
||||
parameters: HocuspocusCollabProviderURLParameters
|
||||
}
|
||||
|
||||
export interface TiptapCollabProviderURLParameters {
|
||||
export interface HocuspocusCollabProviderURLParameters {
|
||||
initialContentId?: DocumentId
|
||||
platformDocumentId?: PlatformDocumentId
|
||||
}
|
||||
|
||||
export class TiptapCollabProvider extends HocuspocusProvider {
|
||||
loaded: Promise<void>
|
||||
export class HocuspocusCollabProvider extends HocuspocusProvider implements Provider {
|
||||
readonly loaded: Promise<void>
|
||||
|
||||
constructor (configuration: TiptapCollabProviderConfiguration) {
|
||||
constructor (configuration: HocuspocusCollabProviderConfiguration) {
|
||||
const parameters: Record<string, any> = {}
|
||||
|
||||
const initialContentId = configuration.parameters?.initialContentId
|
||||
@@ -59,26 +59,3 @@ export class TiptapCollabProvider extends HocuspocusProvider {
|
||||
this.configuration.websocketProvider.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
export const createTiptapCollaborationData = (params: {
|
||||
documentId: string
|
||||
initialContentId?: DocumentId
|
||||
platformDocumentId?: PlatformDocumentId
|
||||
collaboratorURL: string
|
||||
token: string
|
||||
}): { provider: TiptapCollabProvider, ydoc: Ydoc } => {
|
||||
const ydoc: Ydoc = new Ydoc()
|
||||
return {
|
||||
ydoc,
|
||||
provider: new TiptapCollabProvider({
|
||||
url: params.collaboratorURL,
|
||||
name: params.documentId,
|
||||
document: ydoc,
|
||||
token: params.token,
|
||||
parameters: {
|
||||
initialContentId: params.initialContentId,
|
||||
platformDocumentId: params.platformDocumentId
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -12,16 +12,23 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
import { collaborativeDocParse, type CollaborativeDoc } from '@hcengineering/core'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
|
||||
import { type Doc as YDoc } from 'yjs'
|
||||
import { IndexeddbPersistence } from 'y-indexeddb'
|
||||
import { type Awareness } from 'y-protocols/awareness'
|
||||
|
||||
export class IndexeddbProvider extends IndexeddbPersistence {
|
||||
loaded: Promise<void>
|
||||
import { type Provider } from './types'
|
||||
|
||||
constructor (collaborativeDoc: CollaborativeDoc, doc: YDoc) {
|
||||
const { documentId, versionId } = collaborativeDocParse(collaborativeDoc)
|
||||
const name = `${documentId}/${versionId}`
|
||||
export class IndexeddbProvider extends IndexeddbPersistence implements Provider {
|
||||
readonly loaded: Promise<void>
|
||||
readonly awareness: Awareness | null = null
|
||||
|
||||
constructor (documentId: string, doc: YDoc) {
|
||||
const workspaceId: string = getMetadata(presentation.metadata.Workspace) ?? ''
|
||||
|
||||
const name = `${workspaceId}/${documentId}`
|
||||
|
||||
super(name, doc)
|
||||
|
||||
@@ -29,4 +36,8 @@ export class IndexeddbProvider extends IndexeddbPersistence {
|
||||
this.on('synced', resolve)
|
||||
})
|
||||
}
|
||||
|
||||
async destroy (): Promise<void> {
|
||||
await super.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Copyright © 2023, 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type Awareness } from 'y-protocols/awareness'
|
||||
|
||||
export interface Provider {
|
||||
loaded: Promise<void>
|
||||
awareness: Awareness | null
|
||||
|
||||
destroy: () => void | Promise<void>
|
||||
}
|
||||
@@ -17,18 +17,22 @@ import { type Ref, type CollaborativeDoc, type Doc, type Class } from '@hcengine
|
||||
import {
|
||||
type DocumentId,
|
||||
type PlatformDocumentId,
|
||||
formatMinioDocumentId,
|
||||
formatDocumentId,
|
||||
formatPlatformDocumentId as origFormatPlatformDocumentId
|
||||
} from '@hcengineering/collaborator-client'
|
||||
import { getCurrentLocation } from '@hcengineering/ui'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import textEditor from '@hcengineering/text-editor'
|
||||
import { Doc as Ydoc } from 'yjs'
|
||||
|
||||
function getWorkspace (): string {
|
||||
return getCurrentLocation().path[1] ?? ''
|
||||
}
|
||||
import { CloudCollabProvider } from './cloud'
|
||||
import { HocuspocusCollabProvider } from './hocuspocus'
|
||||
import { IndexeddbProvider } from './indexeddb'
|
||||
import { type Provider } from './types'
|
||||
|
||||
export function formatCollaborativeDocumentId (collaborativeDoc: CollaborativeDoc): DocumentId {
|
||||
const workspace = getWorkspace()
|
||||
return formatMinioDocumentId(workspace, collaborativeDoc)
|
||||
const workspace = getMetadata(presentation.metadata.Workspace) ?? ''
|
||||
return formatDocumentId(workspace, collaborativeDoc)
|
||||
}
|
||||
|
||||
export function formatPlatformDocumentId (
|
||||
@@ -38,3 +42,66 @@ export function formatPlatformDocumentId (
|
||||
): PlatformDocumentId {
|
||||
return origFormatPlatformDocumentId(objectClass, objectId, objectAttr)
|
||||
}
|
||||
|
||||
export function createLocalProvider (ydoc: Ydoc, document: CollaborativeDoc): Provider {
|
||||
const documentId = formatCollaborativeDocumentId(document)
|
||||
return new IndexeddbProvider(documentId, ydoc)
|
||||
}
|
||||
|
||||
export function createRemoteProvider (
|
||||
ydoc: Ydoc,
|
||||
params: {
|
||||
document: CollaborativeDoc
|
||||
initialDocument?: CollaborativeDoc
|
||||
objectClass?: Ref<Class<Doc>>
|
||||
objectId?: Ref<Doc>
|
||||
objectAttr?: string
|
||||
}
|
||||
): Provider {
|
||||
const collaborator = getMetadata(textEditor.metadata.Collaborator)
|
||||
|
||||
const token = getMetadata(presentation.metadata.Token) ?? ''
|
||||
const collaboratorUrl = getMetadata(presentation.metadata.CollaboratorUrl) ?? ''
|
||||
|
||||
const documentId = formatCollaborativeDocumentId(params.document)
|
||||
const initialContentId =
|
||||
params.initialDocument !== undefined ? formatCollaborativeDocumentId(params.initialDocument) : undefined
|
||||
|
||||
const { objectClass, objectId, objectAttr } = params
|
||||
const platformDocumentId =
|
||||
objectClass !== undefined && objectId !== undefined && objectAttr !== undefined
|
||||
? formatPlatformDocumentId(objectClass, objectId, objectAttr)
|
||||
: undefined
|
||||
|
||||
return collaborator === 'cloud'
|
||||
? new CloudCollabProvider({
|
||||
url: collaboratorUrl,
|
||||
name: documentId,
|
||||
document: ydoc,
|
||||
token
|
||||
})
|
||||
: new HocuspocusCollabProvider({
|
||||
url: collaboratorUrl,
|
||||
name: documentId,
|
||||
document: ydoc,
|
||||
token,
|
||||
parameters: {
|
||||
initialContentId,
|
||||
platformDocumentId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const createTiptapCollaborationData = (params: {
|
||||
document: CollaborativeDoc
|
||||
initialDocument?: CollaborativeDoc
|
||||
objectClass?: Ref<Class<Doc>>
|
||||
objectId?: Ref<Doc>
|
||||
objectAttr?: string
|
||||
}): { provider: Provider, ydoc: Ydoc } => {
|
||||
const ydoc: Ydoc = new Ydoc()
|
||||
return {
|
||||
ydoc,
|
||||
provider: createRemoteProvider(ydoc, params)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user