mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-23 08:48:01 +00:00
607 lines
17 KiB
Svelte
607 lines
17 KiB
Svelte
<!--
|
|
//
|
|
// Copyright © 2022, 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.
|
|
//
|
|
-->
|
|
<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 { markupToJSON } from '@hcengineering/text'
|
|
import {
|
|
AnySvelteComponent,
|
|
Button,
|
|
IconSize,
|
|
Loading,
|
|
PopupAlignment,
|
|
ThrottledCaller,
|
|
getEventPositionElement,
|
|
getPopupPositionElement,
|
|
themeStore
|
|
} from '@hcengineering/ui'
|
|
import view from '@hcengineering/view'
|
|
import { AnyExtension, Editor, FocusPosition, mergeAttributes } from '@tiptap/core'
|
|
import Collaboration, { isChangeOrigin } from '@tiptap/extension-collaboration'
|
|
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
|
|
import Placeholder from '@tiptap/extension-placeholder'
|
|
import { createEventDispatcher, getContext, onDestroy, onMount } from 'svelte'
|
|
import { Doc as YDoc } from 'yjs'
|
|
|
|
import { Completion } from '../Completion'
|
|
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 textEditor, {
|
|
CollaborationIds,
|
|
CollaborationUser,
|
|
RefAction,
|
|
TextEditorCommandHandler,
|
|
TextEditorHandler
|
|
} from '@hcengineering/text-editor'
|
|
import { addTableHandler } from '../utils'
|
|
|
|
import CollaborationUsers from './CollaborationUsers.svelte'
|
|
import TextEditorToolbar from './TextEditorToolbar.svelte'
|
|
import { noSelectionRender, renderCursor } from './editor/collaboration'
|
|
import { defaultEditorAttributes } from './editor/editorProps'
|
|
import { EmojiExtension } from './extension/emoji'
|
|
import { FileUploadExtension } from './extension/fileUploadExt'
|
|
import { ImageUploadExtension } from './extension/imageUploadExt'
|
|
import { InlineCommandsExtension } from './extension/inlineCommands'
|
|
import { LeftMenuExtension } from './extension/leftMenu'
|
|
import { type FileAttachFunction } from './extension/types'
|
|
import { completionConfig, inlineCommandsConfig } from './extensions'
|
|
|
|
export let collaborativeDoc: CollaborativeDoc
|
|
export let initialCollaborativeDoc: CollaborativeDoc | undefined = undefined
|
|
export let field: string
|
|
|
|
export let objectClass: Ref<Class<Doc>> | undefined = undefined
|
|
export let objectId: Ref<Doc> | undefined = undefined
|
|
export let objectSpace: Ref<Space> | 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'
|
|
export let actionsButtonSize: IconSize = 'medium'
|
|
export let full: boolean = false
|
|
export let placeholder: IntlString = textEditor.string.EditorPlaceholder
|
|
|
|
export let extensions: AnyExtension[] = []
|
|
export let refActions: RefAction[] = []
|
|
|
|
export let editorAttributes: Record<string, string> = {}
|
|
export let overflow: 'auto' | 'none' = 'none'
|
|
export let boundary: HTMLElement | undefined = undefined
|
|
|
|
export let attachFile: FileAttachFunction | undefined = undefined
|
|
export let canShowPopups = true
|
|
export let canEmbedFiles = true
|
|
export let canEmbedImages = true
|
|
export let withSideMenu = true
|
|
export let withInlineCommands = true
|
|
export let kitOptions: Partial<EditorKitOptions> = {}
|
|
|
|
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 localProvider = new IndexeddbProvider(collaborativeDoc, ydoc)
|
|
|
|
const remoteProvider: TiptapCollabProvider =
|
|
contextProvider ??
|
|
new TiptapCollabProvider({
|
|
url: collaboratorURL,
|
|
name: documentId,
|
|
document: ydoc,
|
|
token,
|
|
parameters: {
|
|
initialContentId,
|
|
platformDocumentId
|
|
}
|
|
})
|
|
|
|
let localSynced = false
|
|
let remoteSynced = false
|
|
|
|
$: loading = !localSynced && !remoteSynced
|
|
$: editable = !readonly && remoteSynced
|
|
|
|
void localProvider.loaded.then(() => (localSynced = true))
|
|
void remoteProvider.loaded.then(() => (remoteSynced = true))
|
|
|
|
let editor: Editor
|
|
let element: HTMLElement
|
|
let textToolbarElement: HTMLElement
|
|
let imageToolbarElement: HTMLElement
|
|
|
|
let placeHolderStr: string = ''
|
|
|
|
$: ph = translate(placeholder, {}, $themeStore.language).then((r) => {
|
|
if (editor !== undefined && placeHolderStr !== r) {
|
|
const placeholderIndex = editor.extensionManager.extensions.findIndex(
|
|
(extension) => extension.name === 'placeholder'
|
|
)
|
|
if (placeholderIndex !== -1) {
|
|
editor.extensionManager.extensions[placeholderIndex].options.placeholder = r
|
|
editor.view.dispatch(editor.state.tr)
|
|
}
|
|
}
|
|
placeHolderStr = r
|
|
})
|
|
|
|
$: dispatch('editor', editor)
|
|
|
|
const editorHandler: TextEditorHandler = {
|
|
insertText: (text) => {
|
|
editor?.commands.insertContent(text)
|
|
},
|
|
insertMarkup: (markup) => {
|
|
editor?.commands.insertContent(markupToJSON(markup))
|
|
},
|
|
insertTemplate: (name, markup) => {
|
|
editor?.commands.insertContent(markupToJSON(markup))
|
|
},
|
|
insertTable: (options: { rows?: number, cols?: number, withHeaderRow?: boolean }) => {
|
|
editor?.commands.insertTable(options)
|
|
},
|
|
insertCodeBlock: () => {
|
|
editor?.commands.insertContent(
|
|
{
|
|
type: 'codeBlock',
|
|
content: [{ type: 'text', text: ' ' }]
|
|
},
|
|
{
|
|
updateSelection: false
|
|
}
|
|
)
|
|
},
|
|
insertContent: (content) => {
|
|
editor?.commands.insertContent(content)
|
|
},
|
|
insertSeparatorLine: () => {
|
|
editor?.commands.setHorizontalRule()
|
|
},
|
|
focus: () => {
|
|
focus()
|
|
}
|
|
}
|
|
|
|
function handleAction (a: RefAction, evt?: MouseEvent): void {
|
|
a.action(evt?.target as HTMLElement, editorHandler, evt)
|
|
}
|
|
|
|
$: commandHandler = textEditorCommandHandler(editor)
|
|
|
|
export function commands (): TextEditorCommandHandler | undefined {
|
|
return commandHandler
|
|
}
|
|
|
|
export function removeAttachment (id: string): void {
|
|
editor.commands.command(deleteAttachment(id))
|
|
}
|
|
|
|
export function isEditable (): boolean {
|
|
return editor?.isEditable ?? false
|
|
}
|
|
|
|
let needFocus = false
|
|
let focused = false
|
|
let posFocus: FocusPosition | undefined = undefined
|
|
|
|
export function focus (position?: FocusPosition): void {
|
|
posFocus = position
|
|
needFocus = true
|
|
}
|
|
|
|
export function isFocused (): boolean {
|
|
return focused
|
|
}
|
|
|
|
$: if (editor !== undefined && needFocus) {
|
|
if (!focused) {
|
|
editor.commands.focus(posFocus)
|
|
posFocus = undefined
|
|
}
|
|
needFocus = false
|
|
}
|
|
|
|
function handleFocus (): void {
|
|
needFocus = true
|
|
}
|
|
|
|
$: if (editor !== undefined) {
|
|
editor.setEditable(editable, true)
|
|
}
|
|
|
|
// TODO: should be inside the editor
|
|
$: showToolbar = canShowPopups
|
|
|
|
const optionalExtensions: AnyExtension[] = []
|
|
|
|
if (attachFile !== undefined) {
|
|
if (canEmbedFiles) {
|
|
optionalExtensions.push(
|
|
FileUploadExtension.configure({
|
|
attachFile
|
|
})
|
|
)
|
|
}
|
|
if (canEmbedImages) {
|
|
optionalExtensions.push(
|
|
ImageUploadExtension.configure({
|
|
attachFile,
|
|
getFileUrl
|
|
})
|
|
)
|
|
}
|
|
if (withSideMenu) {
|
|
optionalExtensions.push(
|
|
LeftMenuExtension.configure({
|
|
width: 20,
|
|
height: 20,
|
|
marginX: 8,
|
|
className: 'tiptap-left-menu',
|
|
icon: view.icon.Add,
|
|
iconProps: {
|
|
className: 'svg-tiny',
|
|
fill: 'currentColor'
|
|
},
|
|
items: [
|
|
...(canEmbedImages ? [{ id: 'image', label: textEditor.string.Image, icon: view.icon.Image }] : []),
|
|
{ id: 'table', label: textEditor.string.Table, icon: view.icon.Table2 },
|
|
{ id: 'code-block', label: textEditor.string.CodeBlock, icon: view.icon.CodeBlock },
|
|
{ id: 'separator-line', label: textEditor.string.SeparatorLine, icon: view.icon.SeparatorLine },
|
|
{ id: 'todo-list', label: textEditor.string.TodoList, icon: view.icon.TodoList }
|
|
],
|
|
handleSelect: handleLeftMenuClick
|
|
})
|
|
)
|
|
}
|
|
if (withInlineCommands) {
|
|
optionalExtensions.push(
|
|
InlineCommandsExtension.configure(
|
|
inlineCommandsConfig(handleLeftMenuClick, attachFile === undefined || !canEmbedImages ? ['image'] : [])
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
let inputImage: HTMLInputElement
|
|
|
|
export function handleAttachImage (): void {
|
|
inputImage.click()
|
|
}
|
|
|
|
async function createInlineImage (file: File): Promise<void> {
|
|
if (!file.type.startsWith('image/') || attachFile === undefined) {
|
|
return
|
|
}
|
|
|
|
const attached = await attachFile(file)
|
|
if (attached === undefined) {
|
|
return
|
|
}
|
|
|
|
const size = await getImageSize(file)
|
|
|
|
editor.commands.insertContent(
|
|
{
|
|
type: 'image',
|
|
attrs: {
|
|
'file-id': attached.file,
|
|
width: Math.round(size.width / size.pixelRatio)
|
|
}
|
|
},
|
|
{
|
|
updateSelection: false
|
|
}
|
|
)
|
|
}
|
|
|
|
async function fileSelected (): Promise<void> {
|
|
if (readonly) return
|
|
const list = inputImage.files
|
|
if (list === null || list.length === 0) return
|
|
for (let index = 0; index < list.length; index++) {
|
|
const file = list.item(index)
|
|
if (file !== null) {
|
|
await createInlineImage(file)
|
|
}
|
|
}
|
|
inputImage.value = ''
|
|
}
|
|
|
|
async function handleLeftMenuClick (id: string, pos: number, targetItem?: MouseEvent | HTMLElement): Promise<void> {
|
|
editor.commands.focus(pos, { scrollIntoView: false })
|
|
|
|
switch (id) {
|
|
case 'image':
|
|
handleAttachImage()
|
|
break
|
|
case 'table': {
|
|
let position: PopupAlignment | undefined = undefined
|
|
if (targetItem !== undefined) {
|
|
position =
|
|
targetItem instanceof MouseEvent ? getEventPositionElement(targetItem) : getPopupPositionElement(targetItem)
|
|
}
|
|
|
|
// We need to trigger it asynchronously in order for the editor to finish its focus event
|
|
// Otherwise, it hoggs the focus from the popup and keyboard navigation doesn't work
|
|
setTimeout(() => {
|
|
addTableHandler(editor.commands.insertTable, position)
|
|
})
|
|
break
|
|
}
|
|
case 'code-block':
|
|
// For some reason .setCodeBlock doesnt work in our case
|
|
editor.commands.insertContent(
|
|
{
|
|
type: 'codeBlock',
|
|
content: [{ type: 'text', text: ' ' }]
|
|
},
|
|
{
|
|
updateSelection: false
|
|
}
|
|
)
|
|
editor.commands.focus(pos, { scrollIntoView: false })
|
|
break
|
|
case 'todo-list':
|
|
editor.commands.toggleTaskList()
|
|
break
|
|
case 'separator-line':
|
|
editor.commands.setHorizontalRule()
|
|
break
|
|
}
|
|
}
|
|
|
|
const throttle = new ThrottledCaller(100)
|
|
const updateLastUpdateTime = (): void => {
|
|
remoteProvider.awareness?.setLocalStateField('lastUpdate', Date.now())
|
|
}
|
|
|
|
onMount(async () => {
|
|
await ph
|
|
|
|
editor = new Editor({
|
|
element,
|
|
editorProps: { attributes: mergeAttributes(defaultEditorAttributes, editorAttributes, { class: 'flex-grow' }) },
|
|
extensions: [
|
|
(await getEditorKit()).configure({
|
|
objectId,
|
|
objectClass,
|
|
objectSpace,
|
|
history: false,
|
|
submit: false,
|
|
toolbar: {
|
|
element: textToolbarElement,
|
|
boundary,
|
|
isHidden: () => !showToolbar
|
|
},
|
|
image: {
|
|
toolbar: {
|
|
element: imageToolbarElement,
|
|
boundary,
|
|
appendTo: () => boundary ?? element,
|
|
isHidden: () => !showToolbar
|
|
}
|
|
},
|
|
...kitOptions
|
|
}),
|
|
...optionalExtensions,
|
|
Placeholder.configure({ placeholder: placeHolderStr }),
|
|
Collaboration.configure({
|
|
document: ydoc,
|
|
field
|
|
}),
|
|
CollaborationCursor.configure({
|
|
provider: remoteProvider,
|
|
user,
|
|
render: renderCursor,
|
|
selectionRender: noSelectionRender
|
|
}),
|
|
Completion.configure({
|
|
...completionConfig,
|
|
showDoc (event: MouseEvent, _id: string, _class: string) {
|
|
dispatch('open-document', { event, _id, _class })
|
|
}
|
|
}),
|
|
EmojiExtension,
|
|
...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
|
|
|
|
throttle.call(updateLastUpdateTime)
|
|
dispatch('update')
|
|
}
|
|
})
|
|
})
|
|
|
|
onDestroy(() => {
|
|
if (editor !== undefined) {
|
|
try {
|
|
editor.destroy()
|
|
} catch (err: any) {}
|
|
}
|
|
if (contextProvider === undefined) {
|
|
remoteProvider.destroy()
|
|
}
|
|
void localProvider.destroy()
|
|
})
|
|
</script>
|
|
|
|
<input
|
|
bind:this={inputImage}
|
|
multiple
|
|
type="file"
|
|
name="file"
|
|
id="imageInput"
|
|
accept="image/*"
|
|
style="display: none"
|
|
on:change={fileSelected}
|
|
/>
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
<div
|
|
style:overflow
|
|
class="ref-container clear-mins"
|
|
class:h-full={full}
|
|
on:click|preventDefault|stopPropagation={() => (needFocus = true)}
|
|
>
|
|
{#if loading}
|
|
<div class="flex p-3">
|
|
<Loading />
|
|
</div>
|
|
{/if}
|
|
|
|
<TextEditorToolbar
|
|
bind:toolbar={textToolbarElement}
|
|
visible={showToolbar}
|
|
{editor}
|
|
formatButtonSize={buttonSize}
|
|
on:focus={handleFocus}
|
|
/>
|
|
|
|
<TextEditorToolbar
|
|
bind:toolbar={imageToolbarElement}
|
|
kind="image"
|
|
visible={showToolbar}
|
|
{editor}
|
|
formatButtonSize={buttonSize}
|
|
on:focus={handleFocus}
|
|
/>
|
|
|
|
<div class="textInput">
|
|
<div class="select-text" class:hidden={loading} style="width: 100%;" bind:this={element} />
|
|
<div class="collaborationUsers-container flex-col flex-gap-2 pt-2">
|
|
{#if remoteProvider && editor && userComponent}
|
|
<CollaborationUsers provider={remoteProvider} {editor} component={userComponent} />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if refActions.length > 0}
|
|
<div class="buttons-panel flex-between clear-mins no-print">
|
|
<div class="buttons-group xsmall-gap mt-3">
|
|
{#each refActions as a}
|
|
<Button
|
|
disabled={a.disabled}
|
|
icon={a.icon}
|
|
iconProps={{ size: actionsButtonSize }}
|
|
kind="ghost"
|
|
showTooltip={{ label: a.label }}
|
|
size="medium"
|
|
on:click={(evt) => {
|
|
handleAction(a, evt)
|
|
}}
|
|
/>
|
|
{#if a.order % 10 === 1}
|
|
<div class="buttons-divider" />
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style lang="scss">
|
|
.ref-container {
|
|
flex-grow: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: relative;
|
|
}
|
|
|
|
.textInput {
|
|
flex-grow: 1;
|
|
gap: 1rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
min-height: 1.25rem;
|
|
background-color: transparent;
|
|
|
|
:global(.tiptap-left-menu) {
|
|
color: var(--theme-trans-color);
|
|
width: 20px;
|
|
height: 20px;
|
|
border-radius: 20%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
&:hover {
|
|
background-color: var(--theme-button-hovered);
|
|
cursor: pointer;
|
|
}
|
|
|
|
&:active {
|
|
background-color: var(--theme-button-pressed);
|
|
}
|
|
}
|
|
}
|
|
.collaborationUsers-container {
|
|
position: sticky;
|
|
top: 0;
|
|
min-width: 1.5rem;
|
|
}
|
|
|
|
.hidden {
|
|
display: none;
|
|
}
|
|
</style>
|