UBERF-6068 Show collaborators in collaborative editors (#5335)

Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
This commit is contained in:
Alexander Onnikov 2024-04-15 18:11:47 +07:00 committed by GitHub
parent 07ff2a9d3c
commit 2220d7e19e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 296 additions and 69 deletions

View File

@ -20650,7 +20650,7 @@ packages:
dev: false
file:projects/panel.tgz(@types/node@20.11.19)(esbuild@0.20.1)(postcss-load-config@4.0.2)(postcss@8.4.35)(ts-node@10.9.2):
resolution: {integrity: sha512-P4H97igqWddz9UBNpLY742cNaADue6UfT+1y00SNB/MmJE7qop/lttaCMAvFXgZJ4HvlN7d18II/AHpMZAnABg==, tarball: file:projects/panel.tgz}
resolution: {integrity: sha512-cFoTjnZ6mHJtNSppmfDuDx2wE5B4xGLxee96kAfHWSufWMYuHF69977P0VSlDEen1iXSNu2jQAWUux7wiEaKQQ==, tarball: file:projects/panel.tgz}
id: file:projects/panel.tgz
name: '@rush-temp/panel'
version: 0.0.0

View File

@ -39,7 +39,6 @@
},
"dependencies": {
"@hcengineering/ui": "^0.6.11",
"@hcengineering/text-editor": "^0.6.0",
"svelte": "^4.2.12",
"@hcengineering/platform": "^0.6.9",
"@hcengineering/core": "^0.6.28",

View File

@ -41,7 +41,6 @@
"@hcengineering/presentation": "^0.6.2",
"@hcengineering/platform": "^0.6.9",
"@hcengineering/core": "^0.6.28",
"@hcengineering/contact": "^0.6.20",
"@hcengineering/ui": "^0.6.11",
"@hcengineering/view": "^0.6.9",
"@hcengineering/text": "^0.6.1",

View File

@ -15,7 +15,7 @@
//
-->
<script lang="ts">
import contact from '@hcengineering/contact'
import core from '@hcengineering/core'
import { Component } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { CollaborationUser } from '../types'
@ -27,7 +27,7 @@
is={view.component.ObjectPresenter}
props={{
objectId: user.id,
_class: contact.class.PersonAccount,
_class: core.class.Account,
shouldShowAvatar: true,
shouldShowName: true
}}

View File

@ -0,0 +1,97 @@
<!--
//
// 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.
//
-->
<script lang="ts">
import { AnySvelteComponent, Button, DelayedCaller } from '@hcengineering/ui'
import { onMount } from 'svelte'
import { Editor } from '@tiptap/core'
import { createRelativePositionFromJSON } from 'yjs'
import { relativePositionToAbsolutePosition, ySyncPluginKey } from 'y-prosemirror'
import { TiptapCollabProvider } from '../provider/tiptap'
import { AwarenessChangeEvent, CollaborationUserState } from '../types'
export let provider: TiptapCollabProvider
export let editor: Editor
export let component: AnySvelteComponent
let states: CollaborationUserState[] = []
const debounce = new DelayedCaller(100)
const onAwarenessChange = (event: AwarenessChangeEvent): void => {
debounce.call(() => {
states = event.states.filter((p) => p.user != null).filter((p) => p.clientId !== provider.awareness?.clientID)
})
}
function goToCursor (state: CollaborationUserState): void {
const cursor = state.cursor
if (cursor?.head != null) {
try {
const ystate = ySyncPluginKey.getState(editor.state)
const abs = relativePositionToAbsolutePosition(
ystate.doc,
ystate.type,
createRelativePositionFromJSON(cursor.head),
ystate.binding.mapping
)
if (abs != null) {
editor.commands.focus(abs, { scrollIntoView: true })
}
} catch (err) {
// relative to absolute position conversion sometimes fails
console.error(err)
}
}
}
onMount(() => {
provider.on('awarenessUpdate', onAwarenessChange)
return () => provider.off('awarenessUpdate', onAwarenessChange)
})
</script>
{#if states.length > 0}
<div class="container flex-col flex-gap-2 pt-2">
{#each states as state}
<Button
kind="icon"
shape="round-small"
padding="0"
size="x-small"
noFocus
on:click={(e) => {
e.preventDefault()
e.stopPropagation()
goToCursor(state)
}}
>
<svelte:fragment slot="icon">
<svelte:component this={component} user={state.user} lastUpdate={state.lastUpdate ?? 0} size={'x-small'} />
</svelte:fragment>
</Button>
{/each}
</div>
{/if}
<style lang="scss">
.container {
position: sticky;
top: 0;
width: 1.5rem;
}
</style>

View File

@ -16,12 +16,12 @@
import core, { CollaborativeDoc, Doc, getCollaborativeDoc, getCollaborativeDocId } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { KeyedAttribute, getAttribute, getClient } from '@hcengineering/presentation'
import { registerFocus } from '@hcengineering/ui'
import { AnySvelteComponent, registerFocus } from '@hcengineering/ui'
import CollaborativeTextEditor from './CollaborativeTextEditor.svelte'
import { FocusExtension } from './extension/focus'
import { type FileAttachFunction } from './extension/types'
import textEditorPlugin from '../plugin'
import { RefAction, TextNodeAction } from '../types'
import { CollaborationUser, RefAction, TextNodeAction } from '../types'
export let object: Doc
export let key: KeyedAttribute
@ -29,6 +29,9 @@
export let textNodeActions: TextNodeAction[] = []
export let refActions: RefAction[] = []
export let user: CollaborationUser
export let userComponent: AnySvelteComponent | undefined = undefined
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
export let attachFile: FileAttachFunction | undefined = undefined
export let boundary: HTMLElement | undefined = undefined
@ -105,6 +108,8 @@
objectClass={object._class}
objectId={object._id}
objectAttr={key.key}
{user}
{userComponent}
{textNodeActions}
{refActions}
{extensions}

View File

@ -19,12 +19,16 @@
import { Label, Icon } from '@hcengineering/ui'
import type { AnySvelteComponent } from '@hcengineering/ui'
import textEditorPlugin from '../plugin'
import { CollaborationUser } from '../types'
import CollaborativeAttributeBox from './CollaborativeAttributeBox.svelte'
import IconDescription from './icons/Description.svelte'
export let object: Doc
export let key: KeyedAttribute
export let user: CollaborationUser
export let userComponent: AnySvelteComponent | undefined = undefined
export let label: IntlString = textEditorPlugin.string.FullDescription
export let icon: Asset | AnySvelteComponent = IconDescription
@ -40,5 +44,14 @@
<Label {label} />
</span>
</div>
<CollaborativeAttributeBox {object} {key} boundary={element?.parentElement ?? undefined} on:focus on:blur on:update />
<CollaborativeAttributeBox
{object}
{key}
{user}
{userComponent}
boundary={element?.parentElement ?? undefined}
on:focus
on:blur
on:update
/>
</div>

View File

@ -20,7 +20,7 @@
import { IntlString, getMetadata, translate } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { markupToJSON } from '@hcengineering/text'
import { Button, IconSize, Loading, themeStore } from '@hcengineering/ui'
import { AnySvelteComponent, Button, IconSize, Loading, ThrottledCaller, themeStore } from '@hcengineering/ui'
import { AnyExtension, Editor, FocusPosition, mergeAttributes } from '@tiptap/core'
import Collaboration, { isChangeOrigin } from '@tiptap/extension-collaboration'
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
@ -38,14 +38,15 @@
import { formatCollaborativeDocumentId, formatPlatformDocumentId } from '../provider/utils'
import {
CollaborationIds,
CollaborationUser,
RefAction,
TextEditorCommandHandler,
TextEditorHandler,
TextFormatCategory,
TextNodeAction
} from '../types'
import { getCollaborationUser } from '../utils'
import CollaborationUsers from './CollaborationUsers.svelte'
import ImageStyleToolbar from './ImageStyleToolbar.svelte'
import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte'
import { noSelectionRender, renderCursor } from './editor/collaboration'
@ -66,6 +67,9 @@
export let objectId: Ref<Doc> | undefined
export let objectAttr: string | undefined
export let user: CollaborationUser
export let userComponent: AnySvelteComponent | undefined = undefined
export let readonly = false
export let buttonSize: IconSize = 'small'
@ -256,9 +260,13 @@
}
}
const throttle = new ThrottledCaller(100)
const updateLastUpdateTime = (): void => {
remoteProvider.awareness?.setLocalStateField('lastUpdate', Date.now())
}
onMount(async () => {
await ph
const user = await getCollaborationUser()
editor = new Editor({
element,
@ -326,6 +334,7 @@
// ignore non-local changes
if (isChangeOrigin(transaction)) return
throttle.call(updateLastUpdateTime)
dispatch('update')
}
})
@ -384,6 +393,9 @@
<div class="textInput">
<div class="select-text" class:hidden={loading} style="width: 100%;" bind:this={element} />
{#if remoteProvider && editor && userComponent}
<CollaborationUsers provider={remoteProvider} {editor} component={userComponent} />
{/if}
</div>
{#if refActions.length > 0}
@ -430,7 +442,7 @@
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: flex-end;
align-items: flex-start;
min-height: 1.25rem;
background-color: transparent;
}

View File

@ -17,12 +17,12 @@
<script lang="ts">
import { type Class, type CollaborativeDoc, type Doc, type Ref } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { IconSize, registerFocus } from '@hcengineering/ui'
import { AnySvelteComponent, IconSize, registerFocus } from '@hcengineering/ui'
import { AnyExtension, Editor, FocusPosition, getMarkRange } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
import textEditorPlugin from '../plugin'
import { TextEditorCommandHandler, TextFormatCategory, TextNodeAction } from '../types'
import { CollaborationUser, TextEditorCommandHandler, TextFormatCategory, TextNodeAction } from '../types'
import CollaborativeTextEditor from './CollaborativeTextEditor.svelte'
import { FileAttachFunction } from './extension/types'
@ -36,6 +36,9 @@
export let objectId: Ref<Doc> | undefined = undefined
export let objectAttr: string | undefined = undefined
export let user: CollaborationUser
export let userComponent: AnySvelteComponent | undefined = undefined
export let readonly = false
export let buttonSize: IconSize = 'small'
@ -162,6 +165,8 @@
{objectClass}
{objectId}
{objectAttr}
{user}
{userComponent}
{readonly}
{buttonSize}
{placeholder}

View File

@ -14,15 +14,17 @@
//
import { type DecorationAttrs } from '@tiptap/pm/view'
import { showTooltip } from '@hcengineering/ui'
import { getPlatformColor, showTooltip } from '@hcengineering/ui'
import { type CollaborationUser } from '../../types'
import CollaborationUserPopup from '../CollaborationUserPopup.svelte'
export const renderCursor = (user: CollaborationUser): HTMLElement => {
const color = getPlatformColor(user.color, false)
const cursor = document.createElement('span')
cursor.classList.add('collaboration-cursor__caret')
cursor.setAttribute('style', `border-color: ${user.color}`)
cursor.setAttribute('style', `border-color: ${color}`)
cursor.addEventListener('mousemove', () => {
showTooltip(undefined, cursor, 'top', CollaborationUserPopup, { user })

View File

@ -55,8 +55,8 @@ export class TiptapCollabProvider extends HocuspocusProvider {
}
destroy (): void {
this.configuration.websocketProvider.disconnect()
super.destroy()
this.configuration.websocketProvider.disconnect()
}
}

View File

@ -2,6 +2,7 @@ import { type Asset, type IntlString, type Resource } from '@hcengineering/platf
import { type Account, type Doc, type Markup, type Ref } from '@hcengineering/core'
import type { AnySvelteComponent } from '@hcengineering/ui'
import { type Editor, type SingleCommands } from '@tiptap/core'
import { type RelativePosition } from 'yjs'
/**
* @public
@ -97,12 +98,26 @@ export interface TextEditorCommandHandler {
chain: (...commands: TextEditorCommand[]) => boolean
}
/**
* @public
*/
/** @public */
export interface CollaborationUser {
id: Ref<Account>
name: string
email: string
color: string
color: number
}
/** @public */
export interface CollaborationUserState {
clientId: number
user: CollaborationUser
cursor?: {
anchor: RelativePosition
head: RelativePosition
} | null
lastUpdate?: number
}
/** @public */
export interface AwarenessChangeEvent {
states: CollaborationUserState[]
}

View File

@ -14,19 +14,6 @@
//
import { type Attribute } from '@tiptap/core'
import { get } from 'svelte/store'
import contact, { type PersonAccount, formatName, AvatarType } from '@hcengineering/contact'
import { getCurrentAccount } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import {
type ColorDefinition,
getPlatformAvatarColorByName,
getPlatformAvatarColorForTextDef,
themeStore
} from '@hcengineering/ui'
import { type CollaborationUser } from './types'
export function getDataAttribute (
name: string,
@ -50,27 +37,3 @@ export function getDataAttribute (
...(options ?? {})
}
}
function getAvatarColor (name: string, avatar: string, darkTheme: boolean): ColorDefinition {
const [type, color] = avatar.split('://')
if (type === AvatarType.COLOR) {
return getPlatformAvatarColorByName(color, darkTheme)
}
return getPlatformAvatarColorForTextDef(name, darkTheme)
}
export async function getCollaborationUser (): Promise<CollaborationUser> {
const client = getClient()
const me = getCurrentAccount() as PersonAccount
const person = await client.findOne(contact.class.Person, { _id: me.person })
const name = person !== undefined ? formatName(person.name) : me.email
const color = getAvatarColor(name, person?.avatar ?? '', get(themeStore).dark)
return {
id: me._id,
name,
email: me.email,
color: color.icon ?? 'var(--theme-button-default)'
}
}

View File

@ -267,6 +267,22 @@ export class DelayedCaller {
}
}
/**
* @public
*/
export class ThrottledCaller {
timeout?: any
constructor (readonly delay: number = 10) {}
call (op: () => void): void {
if (this.timeout === undefined) {
op()
this.timeout = setTimeout(() => {
this.timeout = undefined
}, this.delay)
}
}
}
export const testing = (localStorage.getItem('#platform.testing.enabled') ?? 'false') === 'true'
export const rootBarExtensions = writable<Array<['left' | 'right', AnyComponent]>>([])

View File

@ -14,13 +14,14 @@
-->
<script lang="ts">
import attachment, { Attachment } from '@hcengineering/attachment'
import contact from '@hcengineering/contact'
import { Account, Doc, Ref, generateId } from '@hcengineering/core'
import { IntlString, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { IntlString, getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { KeyedAttribute, createQuery, getClient } from '@hcengineering/presentation'
import textEditor, { AttachIcon, CollaborativeAttributeBox, RefAction } from '@hcengineering/text-editor'
import { navigate } from '@hcengineering/ui'
import { AnySvelteComponent, navigate } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { getObjectLinkFragment } from '@hcengineering/view-resources'
import { getCollaborationUser, getObjectLinkFragment } from '@hcengineering/view-resources'
import AttachmentsGrid from './AttachmentsGrid.svelte'
import { uploadFile } from '../utils'
import { defaultRefActions, getModelRefActions } from '@hcengineering/text-editor/src/components/editor/actions'
@ -38,6 +39,12 @@
const client = getClient()
const user = getCollaborationUser()
let userComponent: AnySvelteComponent | undefined
void getResource(contact.component.CollaborationUserAvatar).then((component) => {
userComponent = component
})
let editor: CollaborativeAttributeBox
let refActions: RefAction[] = []
@ -235,6 +242,8 @@
bind:this={editor}
{object}
{key}
{user}
{userComponent}
{focusIndex}
{placeholder}
{boundary}

View File

@ -54,6 +54,7 @@
"@hcengineering/attachment": "^0.6.9",
"@hcengineering/login": "^0.6.8",
"@hcengineering/templates": "^0.6.7",
"@hcengineering/image-cropper": "^0.6.0"
"@hcengineering/image-cropper": "^0.6.0",
"@hcengineering/text-editor": "^0.6.0"
}
}

View File

@ -50,6 +50,7 @@
timer = null
update = undefined
}
// eslint-disable-next-line @typescript-eslint/no-misused-promises
timer = setTimeout(() => update?.(), 500)
}

View File

@ -0,0 +1,43 @@
<!--
//
// 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.
//
-->
<script lang="ts">
import { Employee, PersonAccount } from '@hcengineering/contact'
import { Ref } from '@hcengineering/core'
import { CollaborationUser } from '@hcengineering/text-editor'
import { IconSize } from '@hcengineering/ui'
import { employeeByIdStore, personAccountByIdStore, personByIdStore } from '../utils'
import Avatar from './Avatar.svelte'
export let user: CollaborationUser
export let lastUpdate: number
export let size: IconSize = 'x-small'
let avatar: Avatar | undefined
$: lastUpdate !== 0 && avatar?.pulse()
$: personAccount = $personAccountByIdStore.get(user.id as Ref<PersonAccount>)
$: person =
personAccount?.person !== undefined
? $employeeByIdStore.get(personAccount.person as Ref<Employee>) ?? $personByIdStore.get(personAccount.person)
: undefined
</script>
{#if person}
<Avatar bind:this={avatar} {size} avatar={person.avatar} name={person.name} borderColor={user.color} />
{/if}

View File

@ -57,6 +57,7 @@ import ContactsTabs from './components/ContactsTabs.svelte'
import CreateEmployee from './components/CreateEmployee.svelte'
import CreateOrganization from './components/CreateOrganization.svelte'
import CreatePerson from './components/CreatePerson.svelte'
import CollaborationUserAvatar from './components/CollaborationUserAvatar.svelte'
import DeleteConfirmationPopup from './components/DeleteConfirmationPopup.svelte'
import EditEmployee from './components/EditEmployee.svelte'
import EditMember from './components/EditMember.svelte'
@ -303,6 +304,7 @@ export default async (): Promise<Resources> => ({
OrganizationPresenter,
ChannelsPresenter,
CreatePerson,
CollaborationUserAvatar,
CreateOrganization,
EditPerson,
EditEmployee,

View File

@ -198,7 +198,8 @@ export const contactPlugin = plugin(contactId, {
DeleteConfirmationPopup: '' as AnyComponent,
AccountArrayEditor: '' as AnyComponent,
PersonIcon: '' as AnyComponent,
EditOrganizationPanel: '' as AnyComponent
EditOrganizationPanel: '' as AnyComponent,
CollaborationUserAvatar: '' as AnyComponent
},
channelProvider: {
Email: '' as Ref<ChannelProvider>,

View File

@ -15,8 +15,9 @@
//
-->
<script lang="ts">
import { Extensions, FocusPosition } from '@tiptap/core'
import contact from '@hcengineering/contact'
import { Document } from '@hcengineering/document'
import { getResource } from '@hcengineering/platform'
import {
CollaboratorEditor,
HeadingsExtension,
@ -25,6 +26,9 @@
TodoItemExtension,
TodoListExtension
} from '@hcengineering/text-editor'
import { AnySvelteComponent } from '@hcengineering/ui'
import { getCollaborationUser } from '@hcengineering/view-resources'
import { Extensions, FocusPosition } from '@tiptap/core'
import { createEventDispatcher } from 'svelte'
import ToDoItemNodeView from './node-view/ToDoItemNodeView.svelte'
@ -38,6 +42,12 @@
export let overflow: 'auto' | 'none' = 'none'
export let editorAttributes: Record<string, string> = {}
const user = getCollaborationUser()
let userComponent: AnySvelteComponent | undefined
void getResource(contact.component.CollaborationUserAvatar).then((component) => {
userComponent = component
})
let collabEditor: CollaboratorEditor
export function focus (position?: FocusPosition): void {
@ -83,6 +93,8 @@
objectClass={object._class}
objectId={object._id}
objectAttr="content"
{user}
{userComponent}
{focusIndex}
{readonly}
{attachFile}

View File

@ -13,17 +13,26 @@
// limitations under the License.
-->
<script lang="ts">
import contact from '@hcengineering/contact'
import { Doc } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { KeyedAttribute } from '@hcengineering/presentation'
import { CollaborativeAttributeSectionBox } from '@hcengineering/text-editor'
import { AnySvelteComponent } from '@hcengineering/ui'
import { getCollaborationUser } from '../utils'
export let object: Doc
export let key: KeyedAttribute
const user = getCollaborationUser()
let userComponent: AnySvelteComponent | undefined
void getResource(contact.component.CollaborationUserAvatar).then((component) => {
userComponent = component
})
</script>
{#key object._id}
{#key key.key}
<CollaborativeAttributeSectionBox {object} {key} label={key.attr.label} />
<CollaborativeAttributeSectionBox {object} {key} {user} {userComponent} label={key.attr.label} />
{/key}
{/key}

View File

@ -13,15 +13,24 @@
// limitations under the License.
-->
<script lang="ts">
import contact from '@hcengineering/contact'
import { Doc } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { KeyedAttribute } from '@hcengineering/presentation'
import { CollaborativeAttributeSectionBox } from '@hcengineering/text-editor'
import { AnySvelteComponent } from '@hcengineering/ui'
import { getCollaborationUser } from '../utils'
export let object: Doc
export let key: KeyedAttribute
const user = getCollaborationUser()
let userComponent: AnySvelteComponent | undefined
void getResource(contact.component.CollaborationUserAvatar).then((component) => {
userComponent = component
})
</script>
{#key object._id}
<CollaborativeAttributeSectionBox {object} {key} label={key.attr.label} />
<CollaborativeAttributeSectionBox {object} {key} {user} {userComponent} label={key.attr.label} />
{/key}

View File

@ -1,6 +1,6 @@
//
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
// Copyright © 2021, 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
@ -65,8 +65,10 @@ import {
isAdminUser,
createQuery
} from '@hcengineering/presentation'
import { type CollaborationUser } from '@hcengineering/text-editor'
import {
ErrorPresenter,
getColorNumberByText,
getCurrentResolvedLocation,
getPanelURI,
getPlatformColorForText,
@ -1402,3 +1404,15 @@ permissionsQuery.query(core.class.Space, {}, (res) => {
whitelist: whitelistedSpaces
})
})
export function getCollaborationUser (): CollaborationUser {
const me = getCurrentAccount() as PersonAccount
const color = getColorNumberByText(me.email)
return {
id: me._id,
name: me.email,
email: me.email,
color
}
}