From 7c2aa9f28206ff9046bc12d2e812947b9135ae4d Mon Sep 17 00:00:00 2001 From: Anna No <anna.no@xored.com> Date: Wed, 6 Sep 2023 16:01:25 +0700 Subject: [PATCH] Collaborator Text Editor: add node highlight extension for inline comments feature (#3660) * Collaborator Text Editor: add node highlight extension for inline comments feature Signed-off-by: Anna No <anna.no@xored.com> * fix dependencies Signed-off-by: Anna No <anna.no@xored.com> * use generateId instead of uuid Signed-off-by: Anna No <anna.no@xored.com> --------- Signed-off-by: Anna No <anna.no@xored.com> --- .../src/components/CollaboratorEditor.svelte | 159 ++++++++++++++---- .../src/components/TextEditor.svelte | 2 - .../components/TextEditorStyleToolbar.svelte | 18 +- .../src/components/extension/nodeHighlight.ts | 88 ++++++++++ .../src/components/extension/nodeUuid.ts | 105 ++++++++++++ packages/text-editor/src/types.ts | 9 +- packages/theme/styles/_text-editor.scss | 17 +- 7 files changed, 358 insertions(+), 40 deletions(-) create mode 100644 packages/text-editor/src/components/extension/nodeHighlight.ts create mode 100644 packages/text-editor/src/components/extension/nodeUuid.ts diff --git a/packages/text-editor/src/components/CollaboratorEditor.svelte b/packages/text-editor/src/components/CollaboratorEditor.svelte index 78bd7c03ce..3cd3337574 100644 --- a/packages/text-editor/src/components/CollaboratorEditor.svelte +++ b/packages/text-editor/src/components/CollaboratorEditor.svelte @@ -15,31 +15,31 @@ // --> <script lang="ts"> - import { IntlString, translate } from '@hcengineering/platform' - - import { Editor, Extension, HTMLContent } from '@tiptap/core' + import { Plugin, PluginKey, TextSelection, Transaction } from 'prosemirror-state' + import { DecorationSet } from 'prosemirror-view' + import { getContext, createEventDispatcher, onDestroy, onMount } from 'svelte' + import { WebsocketProvider } from 'y-websocket' + import * as Y from 'yjs' + import { Editor, Extension, HTMLContent, getMarkRange } from '@tiptap/core' import Collaboration from '@tiptap/extension-collaboration' import CollaborationCursor from '@tiptap/extension-collaboration-cursor' import Placeholder from '@tiptap/extension-placeholder' + import BubbleMenu from '@tiptap/extension-bubble-menu' + import { generateId, getCurrentAccount, Markup } from '@hcengineering/core' + import { IntlString, translate } from '@hcengineering/platform' + import { Component, getPlatformColorForText, IconObjects, IconSize, themeStore } from '@hcengineering/ui' - import { Plugin, PluginKey, Transaction } from 'prosemirror-state' - import { createEventDispatcher, onDestroy, onMount } from 'svelte' - - import { getCurrentAccount, Markup } from '@hcengineering/core' - import { getPlatformColorForText, IconObjects, IconSize, themeStore } from '@hcengineering/ui' - import { WebsocketProvider } from 'y-websocket' - import * as Y from 'yjs' - import StyleButton from './StyleButton.svelte' - - import { DecorationSet } from 'prosemirror-view' import textEditorPlugin from '../plugin' - import { CollaborationIds, TextFormatCategory } from '../types' + import { CollaborationIds, TextFormatCategory, TextNodeAction } from '../types' - import { getContext } from 'svelte' import { calculateDecorations } from './diff/decorations' import { defaultExtensions } from './extensions' + import { NodeHighlightExtension, NodeHighlightType } from './extension/nodeHighlight' import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte' + import StyleButton from './StyleButton.svelte' + import { NodeUuidExtension } from './extension/nodeUuid' + export let documentId: string export let readonly = false export let visible = true @@ -59,6 +59,9 @@ export let autoOverflow = false export let initialContent: string | undefined = undefined + export let textNodeActions: TextNodeAction[] = [] + export let isNodeHighlightModeOn: boolean = false + export let onNodeHighlightType: (uuid: string) => NodeHighlightType = () => NodeHighlightType.WARNING const ydoc = (getContext(CollaborationIds.Doc) as Y.Doc | undefined) ?? new Y.Doc() const contextProvider = getContext(CollaborationIds.Provider) as WebsocketProvider | undefined @@ -80,6 +83,9 @@ const currentUser = getCurrentAccount() + let currentTextNodeAction: TextNodeAction | undefined | null + let selectedNodeUuid: string | null | undefined + let textNodeActionMenuElement: HTMLElement let element: HTMLElement let editor: Editor @@ -91,21 +97,46 @@ const dispatch = createEventDispatcher() - export function clear (): void { - editor.commands.clearContent(false) - } - export function insertText (text: string): void { - editor.commands.insertContent(text as HTMLContent) - } - export function getHTML (): string | undefined { if (editor) { return editor.getHTML() } } - export function checkIsSelectionEmpty () { - return editor.view.state.selection.empty + export function selectNode (uuid: string) { + if (!editor) { + return + } + + const { doc, schema, tr } = editor.view.state + let foundNode = false + doc.descendants((node, pos) => { + if (foundNode) { + return false + } + + const nodeUuidMark = node.marks.find( + (mark) => mark.type.name === NodeUuidExtension.name && mark.attrs[NodeUuidExtension.name] === uuid + ) + + if (!nodeUuidMark) { + return + } + + foundNode = true + + // the first pos does not contain the mark, so we need to add 1 (pos + 1) to get the correct range + const range = getMarkRange(doc.resolve(pos + 1), schema.marks[NodeUuidExtension.name]) + + if (!range) { + return false + } + + const [$start, $end] = [doc.resolve(range.from), doc.resolve(range.to)] + + editor.view.dispatch(tr.setSelection(new TextSelection($start, $end))) + needFocus = true + }) } let needFocus = false @@ -145,6 +176,18 @@ } } + const getNodeUuid = () => { + if (editor.view.state.selection.empty) { + return null + } + if (!selectedNodeUuid) { + selectedNodeUuid = generateId() + editor.chain().setUuid(selectedNodeUuid!).run() + } + + return selectedNodeUuid + } + const DecorationExtension = Extension.create({ addProseMirrorPlugins () { return [ @@ -170,7 +213,6 @@ ph.then(() => { editor = new Editor({ element, - // content: 'Hello world<br/> This is simple text<br/>Some more text<br/>Yahoo <br/>Cool <br/><br/> Done', editable: true, extensions: [ ...defaultExtensions, @@ -187,7 +229,34 @@ color: getPlatformColorForText(currentUser.email, $themeStore.dark) } }), - DecorationExtension + DecorationExtension, + NodeHighlightExtension.configure({ + isHighlightModeOn: () => isNodeHighlightModeOn, + getNodeHighlightType: onNodeHighlightType, + onNodeSelected: (uuid: string | null) => { + if (selectedNodeUuid !== uuid) { + selectedNodeUuid = uuid + dispatch('node-selected', selectedNodeUuid) + } + } + }), + BubbleMenu.configure({ + pluginKey: 'text-node-action-menu', + element: textNodeActionMenuElement, + tippyOptions: { + maxWidth: '38rem', + onClickOutside: () => { + currentTextNodeAction = undefined + } + }, + shouldShow: (editor) => { + if (!editor) { + return false + } + + return !!currentTextNodeAction + } + }) // ...extensions ], onTransaction: () => { @@ -203,14 +272,11 @@ }, onUpdate: (op: { editor: Editor; transaction: Transaction }) => { dispatch('content', editor.getHTML()) - }, - onSelectionUpdate: () => { - dispatch('selection-update') } }) if (initialContent) { - insertText(initialContent) + editor.commands.insertContent(initialContent as HTMLContent) } }) }) @@ -229,6 +295,24 @@ let showDiff = true </script> +<div class="actionPanel" bind:this={textNodeActionMenuElement}> + {#if !!currentTextNodeAction} + <Component + is={currentTextNodeAction.panel} + props={{ + documentId, + field, + editor, + action: currentTextNodeAction, + disabled: editor.view.state.selection.empty, + onNodeUuid: getNodeUuid + }} + on:close={() => { + currentTextNodeAction = undefined + }} + /> + {/if} +</div> {#if visible} <div class="ref-container" class:autoOverflow> {#if isFormatting && !readonly} @@ -246,9 +330,14 @@ TextFormatCategory.Table ]} formatButtonSize={buttonSize} + {textNodeActions} on:focus={() => { needFocus = true }} + on:action={(event) => { + currentTextNodeAction = textNodeActions.find((action) => action.id === event.detail) + needFocus = true + }} /> </div> <div class="flex-grow" /> @@ -422,8 +511,18 @@ box-shadow: var(--button-shadow); z-index: 1; } + .ref-container:focus-within .formatPanel { position: sticky; top: 1.25rem; } + + .actionPanel { + margin: -0.5rem -0.25rem 0.5rem; + padding: 0.375rem; + background-color: var(--theme-comp-header-color); + border-radius: 0.5rem; + box-shadow: var(--theme-popup-shadow); + z-index: 1; + } </style> diff --git a/packages/text-editor/src/components/TextEditor.svelte b/packages/text-editor/src/components/TextEditor.svelte index eedc93121f..1924eb43fd 100644 --- a/packages/text-editor/src/components/TextEditor.svelte +++ b/packages/text-editor/src/components/TextEditor.svelte @@ -176,8 +176,6 @@ }, onSelectionUpdate: () => { showContextMenu = false - - dispatch('selection-update') } }) }) diff --git a/packages/text-editor/src/components/TextEditorStyleToolbar.svelte b/packages/text-editor/src/components/TextEditorStyleToolbar.svelte index a2297bf631..aa759d2387 100644 --- a/packages/text-editor/src/components/TextEditorStyleToolbar.svelte +++ b/packages/text-editor/src/components/TextEditorStyleToolbar.svelte @@ -20,7 +20,7 @@ import { Editor } from '@tiptap/core' import { Level } from '@tiptap/extension-heading' import textEditorPlugin from '../plugin' - import { TextFormatCategory } from '../types' + import { TextFormatCategory, TextNodeAction } from '../types' import { mInsertTable } from './extensions' import Bold from './icons/Bold.svelte' import Code from './icons/Code.svelte' @@ -48,6 +48,7 @@ export let formatButtonSize: IconSize = 'small' export let textEditor: Editor export let textFormatCategories: TextFormatCategory[] = [] + export let textNodeActions: TextNodeAction[] = [] const dispatch = createEventDispatcher() @@ -318,4 +319,19 @@ <div class="buttons-divider" /> {/if} {/each} + {#if textFormatCategories.length > 0 && textNodeActions.length > 0} + <div class="buttons-divider" /> + {/if} + {#each textNodeActions as action} + <StyleButton + icon={action.icon} + size={formatButtonSize} + selected={false} + disabled={textEditor.view.state.selection.empty} + showTooltip={{ label: action.label }} + on:click={() => { + dispatch('action', action.id) + }} + /> + {/each} {/if} diff --git a/packages/text-editor/src/components/extension/nodeHighlight.ts b/packages/text-editor/src/components/extension/nodeHighlight.ts new file mode 100644 index 0000000000..5e930225cc --- /dev/null +++ b/packages/text-editor/src/components/extension/nodeHighlight.ts @@ -0,0 +1,88 @@ +import { Extension, getMarkRange, mergeAttributes } from '@tiptap/core' +import { Plugin, TextSelection } from 'prosemirror-state' +import { NodeUuidExtension, NodeUuidOptions } from './nodeUuid' + +export enum NodeHighlightType { + WARNING = 'warning', + SUCCESS = 'success', + ERROR = 'error' +} +interface NodeHighlightExtensionOptions extends NodeUuidOptions { + getNodeHighlightType: (uuid: string) => NodeHighlightType | undefined | null + isHighlightModeOn: () => boolean +} + +/** + * Extension allows to highlight nodes based on uuid + */ +export const NodeHighlightExtension = Extension.create<NodeHighlightExtensionOptions>({ + addProseMirrorPlugins () { + const options = this.options + const plugins = [ + new Plugin({ + props: { + handleClick (view, pos) { + if (!options.isHighlightModeOn()) { + return + } + const { schema, doc, tr } = view.state + + const range = getMarkRange(doc.resolve(pos), schema.marks[NodeUuidExtension.name]) + + if (range === null || range === undefined) { + return false + } + + const [$start, $end] = [doc.resolve(range.from), doc.resolve(range.to)] + + view.dispatch(tr.setSelection(new TextSelection($start, $end))) + + return true + } + } + }) + ] + + return plugins + }, + + addExtensions () { + const options = this.options + + return [ + NodeUuidExtension.extend({ + addOptions () { + return { + ...this.parent?.(), + ...options + } + }, + addAttributes () { + return { + [NodeUuidExtension.name]: { + renderHTML: (attrs) => { + // get uuid from parent mark (NodeUuidExtension) attributes + const uuid = attrs[NodeUuidExtension.name] + const classAttrs: { class?: string } = {} + + if (options.isHighlightModeOn()) { + const type = options.getNodeHighlightType(uuid) + + if (type === NodeHighlightType.ERROR) { + classAttrs.class = 'text-editor-highlighted-node-error' + } else if (type === NodeHighlightType.WARNING) { + classAttrs.class = 'text-editor-highlighted-node-warning' + } else if (type === NodeHighlightType.SUCCESS) { + classAttrs.class = 'text-editor-highlighted-node-success' + } + } + + return mergeAttributes(attrs, classAttrs) + } + } + } + } + }) + ] + } +}) diff --git a/packages/text-editor/src/components/extension/nodeUuid.ts b/packages/text-editor/src/components/extension/nodeUuid.ts new file mode 100644 index 0000000000..7fae0b6a61 --- /dev/null +++ b/packages/text-editor/src/components/extension/nodeUuid.ts @@ -0,0 +1,105 @@ +import { Mark, mergeAttributes } from '@tiptap/core' + +const NAME = 'node-uuid' + +export interface NodeUuidOptions { + HTMLAttributes: Record<string, any> + onNodeSelected?: (uuid: string | null) => any +} + +declare module '@tiptap/core' { + interface Commands<ReturnType> { + [NAME]: { + /** + * Add uuid mark + */ + setUuid: (uuid: string) => ReturnType + /** + * Unset uuid mark + */ + unsetUuid: () => ReturnType + } + } +} + +export interface NodeUuidStorage { + activeNodeUuid: string | null +} + +/** + * This mark allows to add node uuid to the selected text + * Creates span node with attribute node-uuid + */ +export const NodeUuidExtension = Mark.create<NodeUuidOptions, NodeUuidStorage>({ + name: NAME, + addOptions () { + return { + HTMLAttributes: {} + } + }, + + addAttributes () { + return { + [NAME]: { + default: null, + parseHTML: (el) => (el as HTMLSpanElement).getAttribute(NAME) + } + } + }, + + parseHTML () { + return [ + { + tag: `span[${NAME}]`, + getAttrs: (el) => { + const value = (el as HTMLSpanElement).getAttribute(NAME)?.trim() + if (value === null || value === undefined || value.length === 0) { + return false + } + + return null + } + } + ] + }, + + renderHTML ({ HTMLAttributes }) { + return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0] + }, + + addCommands () { + return { + setUuid: + (uuid: string) => + ({ commands }) => + commands.setMark(this.name, { [NAME]: uuid }), + unsetUuid: + () => + ({ commands }) => + commands.unsetMark(this.name) + } + }, + + addStorage () { + return { + activeNodeUuid: null + } + }, + + onSelectionUpdate () { + const { $head } = this.editor.state.selection + + const marks = $head.marks() + this.storage.activeNodeUuid = null + if (marks.length > 0) { + const nodeUuidMark = this.editor.schema.marks[NAME] + const activeNodeUuidMark = marks.find((mark) => mark.type === nodeUuidMark) + + if (activeNodeUuidMark !== undefined && activeNodeUuidMark !== null) { + this.storage.activeNodeUuid = activeNodeUuidMark.attrs[NAME] + } + } + + this.options.onNodeSelected?.(this.storage.activeNodeUuid) + } +}) diff --git a/packages/text-editor/src/types.ts b/packages/text-editor/src/types.ts index 424e18536b..88a8b9a8c0 100644 --- a/packages/text-editor/src/types.ts +++ b/packages/text-editor/src/types.ts @@ -1,6 +1,6 @@ import { Asset, IntlString, Resource } from '@hcengineering/platform' import { Doc } from '@hcengineering/core' -import type { AnySvelteComponent } from '@hcengineering/ui' +import type { AnyComponent, AnySvelteComponent } from '@hcengineering/ui' /** * @public @@ -56,3 +56,10 @@ export interface RefAction { fill?: string disabled?: boolean } + +export interface TextNodeAction { + id: string + label?: IntlString + icon: Asset | AnySvelteComponent + panel: AnyComponent +} diff --git a/packages/theme/styles/_text-editor.scss b/packages/theme/styles/_text-editor.scss index b6e0b264e7..5f39f38369 100644 --- a/packages/theme/styles/_text-editor.scss +++ b/packages/theme/styles/_text-editor.scss @@ -3,6 +3,17 @@ object-fit: contain; } +.text-editor-highlighted-node-warning { + background-color: var(--theme-warning-color); +} + +.text-editor-highlighted-node-error { + background-color: var(--theme-error-color); +} + +.text-editor-highlighted-node-success { + background-color: var(--theme-won-color); +} .proseH1 { margin-block-start: 1.25rem; margin-block-end: 1.25rem; @@ -19,9 +30,3 @@ // line-height: 1.75rem; color: green; } - -// need to override editor's bubble max-width -// due to https://github.com/atomiks/tippyjs/issues/451 -// .tippy-box { -// max-width: 30rem !important; -// }