QMS: Update inline comments extensions (#3814)

* update documents inline comments extensions

Signed-off-by: Anna No <anna.no@xored.com>

* update qms documents inline comments

Signed-off-by: Anna No <anna.no@xored.com>

* qms: update document inline comments extensions

Signed-off-by: Anna No <anna.no@xored.com>

* qms: move highlight to prose mirror decorations

Signed-off-by: Anna No <anna.no@xored.com>

* fix formatting issues

Signed-off-by: Anna No <anna.no@xored.com>

* fix formatting issues

Signed-off-by: Anna No <anna.no@xored.com>

* fix formatting issues

Signed-off-by: Anna No <anna.no@xored.com>

* fix formatting issues

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-10-10 00:26:09 +07:00 committed by GitHub
parent 8805a588bc
commit 4e08b75040
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 282 additions and 135 deletions

View File

@ -35,7 +35,7 @@
import { calculateDecorations } from './diff/decorations'
import { defaultEditorAttributes } from './editor/editorProps'
import { completionConfig, defaultExtensions } from './extensions'
import { InlineStyleToolbar } from './extension/inlineStyleToolbar'
import { InlineStyleToolbarExtension } from './extension/inlineStyleToolbar'
import { NodeUuidExtension } from './extension/nodeUuid'
import StyleButton from './StyleButton.svelte'
import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte'
@ -47,7 +47,6 @@
export let token: string
export let collaboratorURL: string
export let isFormatting = true
export let buttonSize: IconSize = 'small'
export let focusable: boolean = false
export let placeholder: IntlString = textEditorPlugin.string.EditorPlaceholder
@ -90,7 +89,6 @@
let editor: Editor
let inlineToolbar: HTMLElement
let showInlineToolbar = false
let placeHolderStr: string = ''
@ -136,7 +134,6 @@
}
const [$start, $end] = [doc.resolve(range.from), doc.resolve(range.to)]
editor.view.dispatch(tr.setSelection(new TextSelection($start, $end)))
needFocus = true
})
@ -146,6 +143,22 @@
provider.copyContent(documentId, snapshotId)
}
export function unregisterPlugin (nameOrPluginKey: string | PluginKey) {
if (!editor) {
return
}
editor.unregisterPlugin(nameOrPluginKey)
}
export function registerPlugin (plugin: Plugin) {
if (!editor) {
return
}
editor.registerPlugin(plugin)
}
let needFocus = false
let focused = false
@ -201,6 +214,7 @@
})
$: updateEditor(editor, field, comparedVersion)
$: if (editor) dispatch('editor', editor)
onMount(() => {
ph.then(() => {
@ -211,10 +225,10 @@
extensions: [
...defaultExtensions,
Placeholder.configure({ placeholder: placeHolderStr }),
InlineStyleToolbar.configure({
InlineStyleToolbarExtension.configure({
element: inlineToolbar,
getEditorElement: () => element,
isShown: () => !readonly && showInlineToolbar
isSupported: () => !readonly,
isSelectionOnly: () => false
}),
Collaboration.configure({
document: ydoc,
@ -247,22 +261,14 @@
onFocus: () => {
focused = true
},
onUpdate: ({ editor, transaction }) => {
showInlineToolbar = false
onUpdate: ({ transaction }) => {
// ignore non-document changes
if (!transaction.docChanged) return
// TODO this is heavy and should be replaced with more lightweight event
dispatch('content', editor.getHTML())
// ignore non-local changes
if (isChangeOrigin(transaction)) return
dispatch('update')
},
onSelectionUpdate: () => {
showInlineToolbar = false
}
})
@ -283,16 +289,11 @@
}
})
function onEditorClick () {
if (!editor.isEmpty) {
showInlineToolbar = true
}
}
let showDiff = true
</script>
<slot />
<slot {editor} />
{#if visible}
{#if comparedVersion !== undefined || $$slots.tools}
<div class="ref-container" class:autoOverflow>
@ -336,7 +337,7 @@
needFocus = true
}}
on:action={(event) => {
dispatch('action', { action: event.detail, editor })
dispatch('action', event.detail)
needFocus = true
}}
/>
@ -344,7 +345,7 @@
<div class="ref-container" class:autoOverflow>
<div class="textInput" class:focusable>
<div class="select-text" style="width: 100%;" on:mousedown={onEditorClick} bind:this={element} />
<div class="select-text" style="width: 100%;" bind:this={element} />
</div>
</div>
{/if}

View File

@ -28,7 +28,7 @@
import { themeStore } from '@hcengineering/ui'
import TextEditorStyleToolbar from './TextEditorStyleToolbar.svelte'
import { TextFormatCategory } from '../types'
import { InlineStyleToolbar } from './extension/inlineStyleToolbar'
import { InlineStyleToolbarExtension } from './extension/inlineStyleToolbar'
import { defaultEditorAttributes } from './editor/editorProps'
export let content: string = ''
@ -77,7 +77,6 @@
let needFocus = false
let focused = false
let posFocus: FocusPosition | undefined = undefined
let showContextMenu = false
let textEditorToolbar: HTMLElement
export function focus (position?: FocusPosition): void {
@ -137,10 +136,10 @@
...(supportSubmit ? [Handle] : []), // order important
Placeholder.configure({ placeholder: placeHolderStr }),
...extensions,
InlineStyleToolbar.configure({
InlineStyleToolbarExtension.configure({
element: textEditorToolbar,
getEditorElement: () => element,
isShown: () => showContextMenu
isSupported: () => true,
isSelectionOnly: () => false
})
],
parseOptions: {
@ -160,12 +159,8 @@
},
onUpdate: () => {
content = editor.getHTML()
showContextMenu = false
dispatch('value', content)
dispatch('update', content)
},
onSelectionUpdate: () => {
showContextMenu = false
}
})
})
@ -177,12 +172,6 @@
}
})
function onEditorClick () {
if (!editor.isEmpty) {
showContextMenu = true
}
}
/**
* @public
*/
@ -209,7 +198,7 @@
}}
/>
</div>
<div class="select-text" style="width: 100%;" on:mousedown={onEditorClick} bind:this={element} />
<div class="select-text" style="width: 100%;" bind:this={element} />
<style lang="scss">
.formatPanel {

View File

@ -330,7 +330,7 @@
disabled={textEditor.view.state.selection.empty}
showTooltip={{ label: action.label }}
on:click={() => {
dispatch('action', action.id)
dispatch('action', { action: action.id, editor: textEditor })
}}
/>
{/each}

View File

@ -0,0 +1,16 @@
import { Extension } from '@tiptap/core'
import BubbleMenu, { BubbleMenuOptions } from '@tiptap/extension-bubble-menu'
export const InlinePopupExtension: Extension<BubbleMenuOptions> = BubbleMenu.extend({
addOptions () {
return {
...this.parent?.(),
pluginKey: 'inline-popup',
element: null,
tippyOptions: {
maxWidth: '38rem',
appendTo: () => document.body
}
}
}
})

View File

@ -1,48 +1,86 @@
import { Extension, isTextSelection } from '@tiptap/core'
import BubbleMenu, { BubbleMenuOptions } from '@tiptap/extension-bubble-menu'
import { Editor, Extension, isTextSelection } from '@tiptap/core'
import { BubbleMenuOptions } from '@tiptap/extension-bubble-menu'
import { Plugin, PluginKey } from 'prosemirror-state'
import { InlinePopupExtension } from './inlinePopup'
type InlineStyleToolbarOptions = BubbleMenuOptions & {
getEditorElement: () => HTMLElement | null | undefined
isShown?: () => boolean
export type InlineStyleToolbarOptions = BubbleMenuOptions & {
isSupported: () => boolean
isSelectionOnly?: () => boolean
}
export const InlineStyleToolbar = Extension.create<InlineStyleToolbarOptions>({
defaultOptions: {
pluginKey: 'inline-style-toolbar',
element: null,
tippyOptions: {
maxWidth: '38rem',
appendTo: () => document.body
},
getEditorElement: () => null
export interface InlineStyleToolbarStorage {
isShown: boolean
}
const handleFocus = (editor: Editor, options: InlineStyleToolbarOptions, storage: InlineStyleToolbarStorage): void => {
if (!options.isSupported()) {
return
}
if (editor.isEmpty) {
return
}
if (options.isSelectionOnly?.() === true && editor.view.state.selection.empty) {
return
}
storage.isShown = true
}
export const InlineStyleToolbarExtension = Extension.create<InlineStyleToolbarOptions, InlineStyleToolbarStorage>({
pluginKey: new PluginKey('inline-style-toolbar'),
addProseMirrorPlugins () {
const options = this.options
const storage = this.storage
const editor = this.editor
const plugins = [
...(this.parent?.() ?? []),
new Plugin({
key: new PluginKey('inline-style-toolbar-click-plugin'),
props: {
handleClick () {
handleFocus(editor, options, storage)
}
}
})
]
return plugins
},
addStorage () {
return {
isShown: false
}
},
addExtensions () {
const options: InlineStyleToolbarOptions = this.options
return [
BubbleMenu.configure({
InlinePopupExtension.configure({
...options,
// to override shouldShow behaviour a little
// I need to copypaste original function and make a little change
// with showContextMenu falg
shouldShow: ({ editor, view, state, oldState, from, to }) => {
const editorElement = options.getEditorElement()
if (!this.options.isSupported()) {
return false
}
if (editor.isDestroyed || !editor.isEditable) {
return false
}
if (this.storage.isShown) {
return true
}
// For some reason shouldShow might be called after dismount and
// after destroing the editor. We should handle this just no to have
// any errors in runtime
const editorElement = editor.view.dom
if (editorElement === null || editorElement === undefined) {
return false
}
if (!editor.isEditable) {
return false
}
const isShown = options.isShown?.() ?? false
if (isShown) {
return true
}
// When clicking on a element inside the bubble menu the editor "blur" event
// is called and the bubble menu item is focussed. In this case we should
// consider the menu as part of the editor and keep showing the menu
@ -67,5 +105,14 @@ export const InlineStyleToolbar = Extension.create<InlineStyleToolbarOptions>({
}
})
]
},
onFocus () {
handleFocus(this.editor, this.options, this.storage)
},
onSelectionUpdate () {
this.storage.isShown = false
},
onUpdate () {
this.storage.isShown = false
}
})

View File

@ -1,15 +1,17 @@
import { Extension, Range, getMarkRange, mergeAttributes } from '@tiptap/core'
import { NodeUuidExtension, NodeUuidOptions, NodeUuidStorage, findNodeUuidMark } from './nodeUuid'
import { Plugin, PluginKey, TextSelection } from 'prosemirror-state'
import { NodeUuidExtension, NodeUuidOptions } from './nodeUuid'
import { Decoration, DecorationSet } from 'prosemirror-view'
export enum NodeHighlightType {
INFO = 'info',
WARNING = 'warning',
ADD = 'add',
DELETE = 'delete'
}
export interface NodeHighlightExtensionOptions extends NodeUuidOptions {
getNodeHighlightType: (uuid: string) => NodeHighlightType | undefined | null
isHighlightModeOn: () => boolean
isAutoSelect?: () => boolean
}
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
@ -17,20 +19,47 @@ function isRange (range: Range | undefined | null | void): range is Range {
return range !== null && range !== undefined
}
const generateAttributes = (uuid: string, options: NodeHighlightExtensionOptions): Record<string, any> | undefined => {
if (!options.isHighlightModeOn()) {
return undefined
}
const type = options.getNodeHighlightType(uuid)
if (type === null || type === undefined) {
return undefined
}
const classAttrs: { class?: string } = {}
if (type === NodeHighlightType.WARNING) {
classAttrs.class = 'text-editor-highlighted-node-warning'
} else if (type === NodeHighlightType.ADD) {
classAttrs.class = 'text-editor-highlighted-node-add'
} else if (type === NodeHighlightType.DELETE) {
classAttrs.class = 'text-editor-highlighted-node-delete'
}
return classAttrs
}
/**
* Extension allows to highlight nodes based on uuid
*/
export const NodeHighlightExtension: Extension<NodeHighlightExtensionOptions> =
export const NodeHighlightExtension: Extension<NodeHighlightExtensionOptions, NodeUuidStorage> =
Extension.create<NodeHighlightExtensionOptions>({
addStorage (): NodeUuidStorage {
return { activeNodeUuid: null }
},
addProseMirrorPlugins () {
const options = this.options
const storage: NodeUuidStorage = this.storage
const plugins = [
...(this.parent?.() ?? []),
new Plugin({
key: new PluginKey('handle-node-highlight-click-plugin'),
props: {
handleClick (view, pos) {
if (!options.isHighlightModeOn()) {
if (!options.isHighlightModeOn() || options.isAutoSelect?.() !== true) {
return
}
const { schema, doc, tr } = view.state
@ -49,45 +78,65 @@ export const NodeHighlightExtension: Extension<NodeHighlightExtensionOptions> =
return true
}
}
}),
new Plugin({
key: new PluginKey('node-highlight-click-decorations-plugin'),
props: {
decorations (state) {
if (!options.isHighlightModeOn()) {
return undefined
}
const decorations: Decoration[] = []
const { doc, schema } = state
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), 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)
}
}
})
]
return plugins
},
addExtensions () {
const options = this.options
const options: NodeHighlightExtensionOptions = this.options
const storage: NodeUuidStorage = this.storage
return [
NodeUuidExtension.extend({
addOptions () {
addOptions (): NodeUuidOptions {
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.INFO) {
classAttrs.class = 'text-editor-highlighted-node-info'
} else if (type === NodeHighlightType.ADD) {
classAttrs.class = 'text-editor-highlighted-node-add'
} else if (type === NodeHighlightType.DELETE) {
classAttrs.class = 'text-editor-highlighted-node-delete'
}
}
return mergeAttributes(attrs, classAttrs)
}
...options,
onNodeSelected: (uuid: string) => {
storage.activeNodeUuid = uuid
options.onNodeSelected?.(uuid)
}
}
}

View File

@ -1,5 +1,6 @@
import { Mark, getMarkAttributes, mergeAttributes } from '@tiptap/core'
import { Plugin, PluginKey } from 'prosemirror-state'
import { Command, CommandProps, Mark, getMarkAttributes, getMarkType, mergeAttributes } from '@tiptap/core'
import { Node, Mark as ProseMirrorMark } from 'prosemirror-model'
import { EditorState, Plugin, PluginKey } from 'prosemirror-state'
const NAME = 'node-uuid'
@ -14,11 +15,11 @@ export interface NodeUuidCommands<ReturnType> {
/**
* Add uuid mark
*/
setUuid: (uuid: string) => ReturnType
setNodeUuid: (uuid: string) => ReturnType
/**
* Unset uuid mark
*/
unsetUuid: () => ReturnType
unsetNodeUuid: () => ReturnType
}
}
@ -30,8 +31,32 @@ export interface NodeUuidStorage {
activeNodeUuid: string | null
}
const findSelectionNodeUuidMark = (state: EditorState): ProseMirrorMark | undefined => {
if (state.selection === null || state.selection === undefined) {
return
}
let nodeUuidMark: ProseMirrorMark | undefined
state.doc.nodesBetween(state.selection.from, state.selection.to, (node) => {
if (nodeUuidMark !== null || nodeUuidMark !== undefined) {
return false
}
nodeUuidMark = findNodeUuidMark(node)
})
return nodeUuidMark
}
export const findNodeUuidMark = (node: Node): ProseMirrorMark | undefined => {
if (node === null || node === undefined) {
return
}
return node.marks.find((mark) => mark.type.name === NAME && mark.attrs[NAME])
}
/**
* This mark allows to add node uuid to the selected text
* This extension allows to add node uuid to the selected text
* Creates span node with attribute node-uuid
*/
export const NodeUuidExtension = Mark.create<NodeUuidOptions, NodeUuidStorage>({
@ -73,20 +98,23 @@ export const NodeUuidExtension = Mark.create<NodeUuidOptions, NodeUuidStorage>({
addProseMirrorPlugins () {
const options = this.options
const storage: NodeUuidStorage = this.storage
const plugins = [
...(this.parent?.() ?? []),
new Plugin({
key: new PluginKey('handle-node-uuid-click-plugin'),
props: {
handleClick (view) {
const { schema } = view.state
const attrs = getMarkAttributes(view.state, schema.marks[NAME])
const attrs = getMarkAttributes(view.state, view.state.schema.marks[NAME])
const nodeUuid = attrs?.[NAME]
if (nodeUuid !== null || nodeUuid !== undefined) {
options.onNodeClicked?.(nodeUuid)
}
if (storage.activeNodeUuid !== nodeUuid) {
storage.activeNodeUuid = nodeUuid
options.onNodeSelected?.(storage.activeNodeUuid)
}
}
}
})
@ -96,16 +124,27 @@ export const NodeUuidExtension = Mark.create<NodeUuidOptions, NodeUuidStorage>({
},
addCommands () {
return {
setUuid:
const result: NodeUuidCommands<Command>[typeof NAME] = {
setNodeUuid:
(uuid: string) =>
({ commands }) =>
commands.setMark(this.name, { [NAME]: uuid }),
unsetUuid:
({ commands, state }: CommandProps) => {
const { doc, selection } = state
if (selection.empty) {
return false
}
if (doc.rangeHasMark(selection.from, selection.to, getMarkType(NAME, state.schema))) {
return false
}
return commands.setMark(this.name, { [NAME]: uuid })
},
unsetNodeUuid:
() =>
({ commands }) =>
({ commands }: CommandProps) =>
commands.unsetMark(this.name)
}
return result
},
addStorage () {
@ -115,19 +154,13 @@ export const NodeUuidExtension = Mark.create<NodeUuidOptions, NodeUuidStorage>({
},
onSelectionUpdate () {
const { $head } = this.editor.state.selection
const activeNodeUuidMark = findSelectionNodeUuidMark(this.editor.state)
const activeNodeUuid =
activeNodeUuidMark !== null && activeNodeUuidMark !== undefined ? activeNodeUuidMark.attrs[NAME] : null
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]
}
if (this.storage.activeNodeUuid !== activeNodeUuid) {
this.storage.activeNodeUuid = activeNodeUuid
this.options.onNodeSelected?.(this.storage.activeNodeUuid)
}
this.options.onNodeSelected?.(this.storage.activeNodeUuid)
}
})

View File

@ -27,6 +27,7 @@ export { default as StyledTextArea } from './components/StyledTextArea.svelte'
export { default as StyledTextBox } from './components/StyledTextBox.svelte'
export { default as StyledTextEditor } from './components/StyledTextEditor.svelte'
export { default as TextEditor } from './components/TextEditor.svelte'
export { default as TextEditorStyleToolbar } from './components/TextEditorStyleToolbar.svelte'
export { default } from './plugin'
export * from './types'
@ -41,5 +42,11 @@ export {
NodeHighlightType
} from './components/extension/nodeHighlight'
export { NodeUuidCommands, NodeUuidExtension, NodeUuidOptions, NodeUuidStorage } from './components/extension/nodeUuid'
export { InlinePopupExtension } from './components/extension/inlinePopup'
export {
InlineStyleToolbarExtension,
InlineStyleToolbarOptions,
InlineStyleToolbarStorage
} from './components/extension/inlineStyleToolbar'
export { textEditorId }

View File

@ -73,8 +73,9 @@
--highlight-red-hover: #ff967e;
--highlight-red-press: #f96f50bd;
--text-editor-highlighted-node-info-background-color: #F2D7AE;
--text-editor-highlighted-node-info-border-color: #DE9B35;
--text-editor-highlighted-node-warning-active-background-color: #F2D7AE;
--text-editor-highlighted-node-warning-background-color: #F8EBD7;
--text-editor-highlighted-node-warning-border-color: #DE9B35;
--text-editor-highlighted-node-add-background-color: #DAEDDC;
--text-editor-highlighted-node-add-font-color: #1C4220;

View File

@ -108,9 +108,13 @@
object-fit: contain;
}
.text-editor-highlighted-node-info {
background-color: var(--text-editor-highlighted-node-info-background-color);
border-bottom: 0.0625rem solid var(--text-editor-highlighted-node-info-border-color);
.text-editor-highlighted-node-warning {
background-color: var(--text-editor-highlighted-node-warning-background-color);
border-bottom: 0.0625rem solid var(--text-editor-highlighted-node-warning-border-color);
&.text-editor-highlighted-node-selected, &:hover {
background-color: var(--text-editor-highlighted-node-warning-active-background-color);
}
}
.text-editor-highlighted-node-delete {