EZQMS-359 Add update highlight command to nodeHighlight plugin (#3997)

Signed-off-by: Alexander Onnikov <alexander.onnikov@xored.com>
This commit is contained in:
Alexander Onnikov 2023-11-17 20:00:13 +07:00 committed by GitHub
parent af5c79c928
commit 89bfabb37d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 236 additions and 81 deletions

View File

@ -44,34 +44,16 @@
"@tiptap/core": "^2.1.12",
"@tiptap/pm": "^2.1.12",
"@tiptap/starter-kit": "^2.1.12",
"@tiptap/suggestion": "^2.1.12",
"@tiptap/extension-highlight": "^2.1.12",
"@tiptap/extension-placeholder": "^2.1.12",
"@tiptap/extension-mention": "^2.1.12",
"@tiptap/extension-typography": "^2.1.12",
"@tiptap/extension-link": "^2.1.12",
"@tiptap/suggestion": "^2.1.12",
"@tiptap/extension-task-list": "^2.1.12",
"@tiptap/extension-task-item": "^2.1.12",
"@tiptap/extension-collaboration": "^2.1.12",
"@tiptap/extension-collaboration-cursor": "^2.1.12",
"@tiptap/prosemirror-tables": "^1.1.4",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-collab": "^1.3.1",
"prosemirror-state": "^1.4.3",
"prosemirror-transform": "^1.7.3",
"prosemirror-schema-list": "^1.3.0",
"prosemirror-commands": "^1.5.2",
"yjs": "^13.5.52",
"y-websocket": "^1.5.0",
"y-prosemirror": "^1.2.1",
"prosemirror-changeset": "^2.2.1",
"prosemirror-model": "^1.19.2",
"prosemirror-view": "^1.31.4",
"prosemirror-history": "^1.3.2",
"prosemirror-keymap": "^1.2.2",
"rfc6902": "^5.0.1",
"diff": "^5.1.0",
"@tiptap/extension-code-block": "^2.1.12",
"@tiptap/extension-gapcursor": "^2.1.12",
"@tiptap/extension-heading": "^2.1.12",
@ -84,7 +66,12 @@
"@tiptap/extension-underline": "^2.1.12",
"@tiptap/extension-list-keymap": "^2.1.12",
"@hocuspocus/provider": "^2.5.0",
"slugify": "^1.6.6",
"prosemirror-codemark": "^0.4.2"
"prosemirror-codemark": "^0.4.2",
"yjs": "^13.5.52",
"y-websocket": "^1.5.0",
"y-prosemirror": "^1.2.1",
"rfc6902": "^5.0.1",
"diff": "^5.1.0",
"slugify": "^1.6.6"
}
}

View File

@ -1,6 +1,6 @@
import { Extension, Mark, mergeAttributes } from '@tiptap/core'
import { ChangeSet } from 'prosemirror-changeset'
import { Plugin } from 'prosemirror-state'
import { ChangeSet } from '@tiptap/pm/changeset'
import { Plugin } from '@tiptap/pm/state'
export interface ChangeHighlightOptions {
multicolor: boolean

View File

@ -0,0 +1,38 @@
//
// 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.
//
import { Editor } from '@tiptap/core'
import { TextEditorCommand, TextEditorCommandHandler } from './types'
export function textEditorCommandHandler (editor: Editor | undefined): TextEditorCommandHandler | undefined {
return editor !== undefined ? new TextEditorCommandHandlerImpl(editor) : undefined
}
class TextEditorCommandHandlerImpl implements TextEditorCommandHandler {
constructor (private readonly editor: Editor) {}
chain (...commands: TextEditorCommand[]): boolean {
let chain = this.editor.chain()
for (const command of commands) {
chain = chain.command(({ editor, commands }) => command({ editor, commands }))
}
return chain.run()
}
command (command: TextEditorCommand): boolean {
return this.editor.commands.command(({ editor, commands }) => command({ editor, commands }))
}
}

View File

@ -16,20 +16,18 @@
-->
<script lang="ts">
import { Editor, Extension, mergeAttributes } from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { DecorationSet } from '@tiptap/pm/view'
import { onDestroy, onMount } from 'svelte'
import { Markup } from '@hcengineering/core'
import { IconObjects, IconSize } from '@hcengineering/ui'
import StyleButton from './StyleButton.svelte'
import { DecorationSet } from 'prosemirror-view'
import textEditorPlugin from '../plugin'
import { calculateDecorations } from './diff/decorations'
import { defaultExtensions } from './extensions'
import { defaultEditorAttributes } from './editor/editorProps'
import { defaultExtensions } from './extensions'
import StyleButton from './StyleButton.svelte'
export let content: Markup
export let buttonSize: IconSize = 'small'

View File

@ -15,8 +15,8 @@
//
-->
<script lang="ts">
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'
import { DecorationSet } from 'prosemirror-view'
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { DecorationSet } from '@tiptap/pm/view'
import { getContext, createEventDispatcher, onDestroy, onMount } from 'svelte'
import * as Y from 'yjs'
import {
@ -33,12 +33,13 @@
import Placeholder from '@tiptap/extension-placeholder'
import { getCurrentAccount, Markup } from '@hcengineering/core'
import { IntlString, translate } from '@hcengineering/platform'
import { getPlatformColorForText, IconObjects, IconSize, Loading, registerFocus, themeStore } from '@hcengineering/ui'
import { IconObjects, IconSize, Loading, getPlatformColorForText, registerFocus, themeStore } from '@hcengineering/ui'
import { Completion } from '../Completion'
import { textEditorCommandHandler } from '../commands'
import textEditorPlugin from '../plugin'
import { TiptapCollabProvider } from '../provider'
import { CollaborationIds, TextFormatCategory, TextNodeAction } from '../types'
import { CollaborationIds, TextEditorCommandHandler, TextFormatCategory, TextNodeAction } from '../types'
import { copyDocumentContent, copyDocumentField } from '../utils'
import { calculateDecorations } from './diff/decorations'
@ -112,6 +113,12 @@
const dispatch = createEventDispatcher()
$: handler = textEditorCommandHandler(editor)
export function commands (): TextEditorCommandHandler | undefined {
return handler
}
export function getHTML (): string | undefined {
if (editor) {
return editor.getHTML()

View File

@ -15,12 +15,12 @@
import { Markup } from '@hcengineering/core'
import { Editor } from '@tiptap/core'
import { ChangeSet } from 'prosemirror-changeset'
import { DOMParser, Node, Schema } from 'prosemirror-model'
import { Decoration, DecorationSet } from 'prosemirror-view'
import { recreateTransform } from './recreate'
import { ChangeSet } from '@tiptap/pm/changeset'
import { DOMParser, Node, Schema } from '@tiptap/pm/model'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { yDocToProsemirrorJSON } from 'y-prosemirror'
import { Doc, applyUpdate } from 'yjs'
import { recreateTransform } from './recreate'
/**
* @public

View File

@ -17,8 +17,8 @@
//
import { Change, diffWordsWithSpace } from 'diff'
import { Node, Schema } from 'prosemirror-model'
import { ReplaceStep, Step, Transform } from 'prosemirror-transform'
import { Node, Schema } from '@tiptap/pm/model'
import { ReplaceStep, Step, Transform } from '@tiptap/pm/transform'
import { applyPatch, createPatch, Operation } from 'rfc6902'
import { Pointer } from 'rfc6902/pointer'
import { diffArraysPM } from './diff'

View File

@ -13,6 +13,6 @@
// limitations under the License.
//
import { DecorationAttrs } from 'prosemirror-view'
import { DecorationAttrs } from '@tiptap/pm/view'
export const noSelectionRender = (_user: Record<string, any>): DecorationAttrs => ({})

View File

@ -1,6 +1,6 @@
import { Extension, isTextSelection } from '@tiptap/core'
import { BubbleMenuOptions } from '@tiptap/extension-bubble-menu'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { InlinePopupExtension } from './inlinePopup'
export type InlineStyleToolbarOptions = BubbleMenuOptions & {

View File

@ -1,13 +1,21 @@
import { Extension, Range, getMarkRange, mergeAttributes } from '@tiptap/core'
import { Command, CommandProps, Extension, Range, getMarkRange, mergeAttributes } from '@tiptap/core'
import { Node as ProseMirrorNode, MarkType } from '@tiptap/pm/model'
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
import { AddMarkStep, RemoveMarkStep } from '@tiptap/pm/transform'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { NodeUuidExtension, NodeUuidOptions, NodeUuidStorage, findNodeUuidMark } from './nodeUuid'
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'
import { Decoration, DecorationSet } from 'prosemirror-view'
import { TextEditorCommand } from '../../types'
export enum NodeHighlightType {
WARNING = 'warning',
ADD = 'add',
DELETE = 'delete'
}
export function highlightUpdateCommand (): TextEditorCommand {
return ({ commands }) => commands.updateHighlight()
}
export interface NodeHighlightExtensionOptions extends NodeUuidOptions {
getNodeHighlightType: (uuid: string) => NodeHighlightType | undefined | null
isHighlightModeOn: () => boolean
@ -41,14 +49,34 @@ const generateAttributes = (uuid: string, options: NodeHighlightExtensionOptions
return classAttrs
}
const NodeHighlight = 'node-highlight'
const NodeHighlightMeta = 'node-highlight'
export interface NodeHighlightCommands<ReturnType> {
[NodeHighlight]: {
/**
* Force all nodes to be re-rendered
*/
updateHighlight: () => ReturnType
}
}
declare module '@tiptap/core' {
interface Commands<ReturnType> extends NodeHighlightCommands<ReturnType> {}
}
/**
* Extension allows to highlight nodes based on uuid
*/
export const NodeHighlightExtension: Extension<NodeHighlightExtensionOptions, NodeUuidStorage> =
Extension.create<NodeHighlightExtensionOptions>({
name: NodeHighlight,
addStorage (): NodeUuidStorage {
return { activeNodeUuid: null }
},
addProseMirrorPlugins () {
const options = this.options
const storage: NodeUuidStorage = this.storage
@ -56,7 +84,7 @@ export const NodeHighlightExtension: Extension<NodeHighlightExtensionOptions, No
const plugins = [
...(this.parent?.() ?? []),
new Plugin({
key: new PluginKey('handle-node-highlight-click-plugin'),
key: new PluginKey('node-highlight-click-plugin'),
props: {
handleClick (view, pos) {
if (!options.isHighlightModeOn() || options.isAutoSelect?.() !== true) {
@ -79,45 +107,65 @@ export const NodeHighlightExtension: Extension<NodeHighlightExtensionOptions, No
}
}
}),
new Plugin({
key: new PluginKey('node-highlight-click-decorations-plugin'),
props: {
decorations (state) {
if (!options.isHighlightModeOn()) {
return undefined
}
const decorations: Decoration[] = []
key: new PluginKey('node-highlight-decoration-plugin'),
state: {
init (_config, state): DecorationSet {
const { doc, schema } = state
doc.descendants((node, pos) => {
const nodeUuidMark = findNodeUuidMark(node)
const markType = schema.marks[NodeUuidExtension.name]
return createDecorations(doc, markType, storage, options)
},
if (nodeUuidMark !== null && nodeUuidMark !== undefined) {
const nodeUuid = nodeUuidMark.attrs[NodeUuidExtension.name]
const attributes = generateAttributes(nodeUuid, options)
if (attributes === null || attributes === undefined) {
return
apply (tr, decorations, oldState, newState) {
const markType = newState.schema.marks[NodeUuidExtension.name]
if (tr.getMeta(NodeHighlightMeta) !== undefined) {
return createDecorations(tr.doc, markType, storage, options)
}
if (!tr.docChanged) {
return decorations.map(tr.mapping, tr.doc)
}
// update all decorations when transaction has mark changes
if (
tr.steps.some(
(step) =>
(step instanceof AddMarkStep && step.mark.type === markType) ||
(step instanceof RemoveMarkStep && step.mark.type === markType)
)
) {
return createDecorations(tr.doc, markType, storage, options)
}
// update all decorations when changed content has mark changes
let hasMarkChanges = false
tr.mapping.maps.forEach((map, index) => {
if (hasMarkChanges) return
map.forEach((oldStart, oldEnd, newStart, newEnd) => {
const oldDoc = tr.docs[index]
const newDoc = tr.docs[index + 1] ?? tr.doc
if (
oldDoc.rangeHasMark(oldStart, oldEnd, markType) !== newDoc.rangeHasMark(newStart, newEnd, markType)
) {
hasMarkChanges = 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 (!isRange(range)) {
return
}
decorations.push(
Decoration.inline(
range.from,
range.to,
mergeAttributes(
attributes,
nodeUuid === storage.activeNodeUuid ? { class: 'text-editor-highlighted-node-selected' } : {}
)
)
)
}
})
})
return DecorationSet.empty.add(doc, decorations)
if (hasMarkChanges) {
return createDecorations(tr.doc, markType, storage, options)
}
return decorations.map(tr.mapping, tr.doc)
}
},
props: {
decorations (state) {
return this.getState(state)
}
}
})
@ -125,6 +173,20 @@ export const NodeHighlightExtension: Extension<NodeHighlightExtensionOptions, No
return plugins
},
addCommands () {
const result: NodeHighlightCommands<Command>[typeof NodeHighlight] = {
updateHighlight:
() =>
({ view: { dispatch, state } }: CommandProps) => {
dispatch(state.tr.setMeta(NodeHighlightMeta, ''))
return true
}
}
return result
},
addExtensions () {
const options: NodeHighlightExtensionOptions = this.options
const storage: NodeUuidStorage = this.storage
@ -144,3 +206,43 @@ export const NodeHighlightExtension: Extension<NodeHighlightExtensionOptions, No
]
}
})
const createDecorations = (
doc: ProseMirrorNode,
markType: MarkType,
storage: NodeUuidStorage,
options: NodeHighlightExtensionOptions
): DecorationSet => {
const decorations: Decoration[] = []
doc.descendants((node, pos) => {
const nodeUuidMark = findNodeUuidMark(node)
if (nodeUuidMark !== null && nodeUuidMark !== undefined) {
const nodeUuid = nodeUuidMark.attrs[NodeUuidExtension.name]
const attributes = generateAttributes(nodeUuid, options)
if (attributes === null || attributes === undefined) {
return
}
// 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), markType)
if (!isRange(range)) {
return
}
decorations.push(
Decoration.inline(
range.from,
range.to,
mergeAttributes(
attributes,
nodeUuid === storage.activeNodeUuid ? { class: 'text-editor-highlighted-node-selected' } : {}
)
)
)
}
})
return DecorationSet.create(doc, decorations)
}

View File

@ -1,6 +1,6 @@
import { Command, CommandProps, Mark, getMarkType, getMarksBetween, mergeAttributes } from '@tiptap/core'
import { Node, Mark as ProseMirrorMark } from 'prosemirror-model'
import { EditorState, Plugin, PluginKey } from 'prosemirror-state'
import { Node, Mark as ProseMirrorMark } from '@tiptap/pm/model'
import { EditorState, Plugin, PluginKey } from '@tiptap/pm/state'
const NAME = 'node-uuid'

View File

@ -45,7 +45,8 @@ export {
export {
NodeHighlightExtension,
NodeHighlightType,
type NodeHighlightExtensionOptions
type NodeHighlightExtensionOptions,
highlightUpdateCommand
} from './components/extension/nodeHighlight'
export {
NodeUuidExtension,

View File

@ -1,6 +1,7 @@
import { Asset, IntlString, Resource } from '@hcengineering/platform'
import { Doc } from '@hcengineering/core'
import type { AnySvelteComponent } from '@hcengineering/ui'
import { Editor, SingleCommands } from '@tiptap/core'
/**
* @public
@ -72,3 +73,24 @@ export interface Heading {
level: number
title: string
}
/**
* @public
*/
export interface TextEditorCommandProps {
editor: Editor
commands: SingleCommands
}
/**
* @public
*/
export type TextEditorCommand = (props: TextEditorCommandProps) => boolean
/**
* @public
*/
export interface TextEditorCommandHandler {
command: (command: TextEditorCommand) => boolean
chain: (...commands: TextEditorCommand[]) => boolean
}