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>
This commit is contained in:
Anna No 2023-09-06 16:01:25 +07:00 committed by GitHub
parent a52b806e5c
commit 7c2aa9f282
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 358 additions and 40 deletions

View File

@ -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>

View File

@ -176,8 +176,6 @@
},
onSelectionUpdate: () => {
showContextMenu = false
dispatch('selection-update')
}
})
})

View File

@ -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}

View File

@ -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)
}
}
}
}
})
]
}
})

View File

@ -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)
}
})

View File

@ -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
}

View File

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