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;
-// }