UBER-1077 Better collaborators representation (#4267)

This commit is contained in:
Alexander Onnikov 2023-12-26 12:24:28 +07:00 committed by GitHub
parent cc730092c1
commit 2a345280eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 191 additions and 99 deletions

View File

@ -39,7 +39,9 @@
"@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",
"svelte": "^4.2.5",
"@tiptap/core": "^2.1.12",

View File

@ -0,0 +1,34 @@
<!--
//
// Copyright © 2023 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 contact from '@hcengineering/contact'
import { Component } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { CollaborationUser } from '../types'
export let user: CollaborationUser
</script>
<Component
is={view.component.ObjectPresenter}
props={{
objectId: user.id,
_class: contact.class.PersonAccount,
shouldShowAvatar: true,
shouldShowName: true
}}
/>

View File

@ -15,10 +15,9 @@
//
-->
<script lang="ts">
import { getCurrentAccount } from '@hcengineering/core'
import { IntlString, getMetadata, translate } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { Button, IconSize, Loading, getPlatformColorForText, themeStore } from '@hcengineering/ui'
import { Button, IconSize, Loading, 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,11 +37,11 @@
TextFormatCategory,
TextNodeAction
} from '../types'
import { copyDocumentContent, copyDocumentField } from '../utils'
import { copyDocumentContent, copyDocumentField, getCollaborationUser } from '../utils'
import ImageStyleToolbar from './ImageStyleToolbar.svelte'
import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte'
import { noSelectionRender } from './editor/collaboration'
import { noSelectionRender, renderCursor } from './editor/collaboration'
import { defaultEditorAttributes } from './editor/editorProps'
import { EmojiExtension } from './extension/emoji'
import { FileAttachFunction, ImageExtension } from './extension/imageExt'
@ -106,8 +105,6 @@
let loading = true
void provider.loaded.then(() => (loading = false))
const currentUser = getCurrentAccount()
let editor: Editor
let element: HTMLElement
let textToolbarElement: HTMLElement
@ -215,80 +212,79 @@
)
}
onMount(() => {
void ph.then(() => {
editor = new Editor({
element,
editorProps: { attributes: mergeAttributes(defaultEditorAttributes, editorAttributes, { class: 'flex-grow' }) },
extensions: [
...defaultExtensions,
...optionalExtensions,
Placeholder.configure({ placeholder: placeHolderStr }),
InlineStyleToolbarExtension.configure({
tippyOptions,
element: textToolbarElement,
isSupported: () => showTextStyleToolbar,
isSelectionOnly: () => false
}),
InlinePopupExtension.configure({
pluginKey: 'show-image-actions-popup',
element: imageToolbarElement,
tippyOptions: {
...tippyOptions,
appendTo: () => boundary ?? element
},
shouldShow: ({ editor }) => {
if (readonly || !canShowPopups) {
return false
}
return editor.isActive('image')
}
}),
Collaboration.configure({
document: ydoc,
field
}),
CollaborationCursor.configure({
provider,
user: {
name: currentUser.email,
color: getPlatformColorForText(currentUser.email, $themeStore.dark)
},
selectionRender: noSelectionRender
}),
Completion.configure({
...completionConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}
}),
EmojiExtension.configure(),
...extensions
],
parseOptions: {
preserveWhitespace: 'full'
},
onTransaction: () => {
// force re-render so `editor.isActive` works as expected
editor = editor
},
onBlur: ({ event }) => {
focused = false
dispatch('blur', event)
},
onFocus: () => {
focused = true
dispatch('focus')
},
onUpdate: ({ transaction }) => {
// ignore non-document changes
if (!transaction.docChanged) return
// ignore non-local changes
if (isChangeOrigin(transaction)) return
onMount(async () => {
await ph
const user = await getCollaborationUser()
dispatch('update')
}
})
editor = new Editor({
element,
editorProps: { attributes: mergeAttributes(defaultEditorAttributes, editorAttributes, { class: 'flex-grow' }) },
extensions: [
...defaultExtensions,
...optionalExtensions,
Placeholder.configure({ placeholder: placeHolderStr }),
InlineStyleToolbarExtension.configure({
tippyOptions,
element: textToolbarElement,
isSupported: () => showTextStyleToolbar,
isSelectionOnly: () => false
}),
InlinePopupExtension.configure({
pluginKey: 'show-image-actions-popup',
element: imageToolbarElement,
tippyOptions: {
...tippyOptions,
appendTo: () => boundary ?? element
},
shouldShow: ({ editor }) => {
if (readonly || !canShowPopups) {
return false
}
return editor.isActive('image')
}
}),
Collaboration.configure({
document: ydoc,
field
}),
CollaborationCursor.configure({
provider,
user,
render: renderCursor,
selectionRender: noSelectionRender
}),
Completion.configure({
...completionConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}
}),
EmojiExtension.configure(),
...extensions
],
parseOptions: {
preserveWhitespace: 'full'
},
onTransaction: () => {
// force re-render so `editor.isActive` works as expected
editor = editor
},
onBlur: ({ event }) => {
focused = false
dispatch('blur', event)
},
onFocus: () => {
focused = true
dispatch('focus')
},
onUpdate: ({ transaction }) => {
// ignore non-document changes
if (!transaction.docChanged) return
// ignore non-local changes
if (isChangeOrigin(transaction)) return
dispatch('update')
}
})
})

View File

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

View File

@ -1,5 +1,5 @@
import { type Asset, type IntlString, type Resource } from '@hcengineering/platform'
import { type Doc } from '@hcengineering/core'
import { type Account, type Doc, type Ref } from '@hcengineering/core'
import type { AnySvelteComponent } from '@hcengineering/ui'
import { type Editor, type SingleCommands } from '@tiptap/core'
@ -95,3 +95,13 @@ export interface TextEditorCommandHandler {
command: (command: TextEditorCommand) => boolean
chain: (...commands: TextEditorCommand[]) => boolean
}
/**
* @public
*/
export interface CollaborationUser {
id: Ref<Account>
name: string
email: string
color: string
}

View File

@ -15,9 +15,21 @@
import { type onStatelessParameters } from '@hocuspocus/provider'
import { type Attribute } from '@tiptap/core'
import { get } from 'svelte/store'
import * as Y from 'yjs'
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 DocumentId, TiptapCollabProvider } from './provider'
import { type CollaborationUser } from './types'
type ProviderData = (
| {
@ -106,3 +118,27 @@ 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

@ -153,29 +153,27 @@
/* Give a remote user a caret */
.collaboration-cursor__caret {
border-left: 1px solid #0d0d0d;
border-right: 1px solid #0d0d0d;
border-left: 1px solid transparent;
border-right: 1px solid transparent;
margin-left: -1px;
margin-right: -1px;
pointer-events: none;
position: relative;
word-break: normal;
}
/* Render the username above the caret */
.collaboration-cursor__label {
border-radius: 3px 3px 3px 0;
color: #0d0d0d;
font-size: 12px;
font-style: normal;
font-weight: 600;
left: -1px;
line-height: normal;
padding: 0.1rem 0.3rem;
position: absolute;
top: -1.4em;
user-select: none;
white-space: nowrap;
&::after {
content: '';
position: absolute;
border-color: inherit;
border-style: solid;
width: 0;
height: 0;
top: -2px;
left: -4px;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top-width: 4px;
border-bottom: 0;
}
}
cmark {