diff --git a/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte b/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte index 5c7b4e71fd..ae079c94d2 100644 --- a/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte +++ b/plugins/controlled-documents-resources/src/components/document/EditDocContent.svelte @@ -25,7 +25,8 @@ TableOfContents, TableOfContentsContent, getNodeElement, - highlightUpdateCommand + highlightUpdateCommand, + selectNode } from '@hcengineering/text-editor-resources' import { EditBox, Label, Scroller } from '@hcengineering/ui' import { getCollaborationUser } from '@hcengineering/view-resources' @@ -41,8 +42,8 @@ $documentCommentHighlightedLocation as documentCommentHighlightedLocation, $documentComments as documentComments, documentCommentsDisplayRequested, - documentCommentsHighlightUpdated, documentCommentsLocationNavigateRequested, + documentCommentsAddCanceled, $isEditable as isEditable } from '../../stores/editors/document' import DocumentPrintTitlePage from '../print/DocumentPrintTitlePage.svelte' @@ -58,19 +59,16 @@ let headings: Heading[] = [] let textEditor: CollaboratorEditor let selectedNodeId: string | null | undefined = undefined - let isFocused = false let editor: Editor let title = $controlledDocument?.title ?? '' $: isTemplate = $controlledDocument != null && hierarchy.hasMixin($controlledDocument, documents.mixin.DocumentTemplate) - function handleRefreshHighlight () { - if (!textEditor) { - return - } + $: commentUuids = $documentComments.map((p) => p.nodeId).filter((id) => id != null) - textEditor.commands()?.command(highlightUpdateCommand()) + function handleRefreshHighlight (): void { + textEditor?.commands()?.command(highlightUpdateCommand()) } const unsubscribeHighlightRefresh = merge([documentCommentHighlightedLocation, documentComments.updates]).subscribe({ @@ -82,21 +80,28 @@ const unsubscribeNavigateToLocation = documentCommentsLocationNavigateRequested.subscribe({ // eslint-disable-next-line @typescript-eslint/no-misused-promises next: async ({ nodeId }) => { - if (!nodeId) { + if (nodeId == null) { handleRefreshHighlight() return } - if (!textEditor) { - return + selectedNodeId = nodeId + + if (editor !== undefined) { + await tick() + + const element = getNodeElement(editor, nodeId) + element?.scrollIntoView({ behavior: 'smooth' }) } + } + }) - await tick() - - const element = getNodeElement(editor, nodeId) - - if (element) { - element.scrollIntoView({ behavior: 'smooth' }) + const unsubscribeCommentsAddCanceled = documentCommentsAddCanceled.subscribe({ + next: ({ nodeId }) => { + if (editor !== undefined && nodeId != null) { + if (selectNode(editor, nodeId)) { + editor.commands.unsetQMSInlineCommentMark() + } } } }) @@ -104,6 +109,7 @@ onDestroy(() => { unsubscribeHighlightRefresh() unsubscribeNavigateToLocation() + unsubscribeCommentsAddCanceled() }) const handleUpdateTitle = async () => { @@ -137,15 +143,9 @@ return null } - function handleShowDocumentComments (uuid: string) { - if (!uuid) { - return - } - - documentCommentsDisplayRequested({ - element: getNodeElement(editor, uuid), - nodeId: uuid - }) + function handleShowDocumentComments (nodeId: string): void { + const element = getNodeElement(editor, nodeId) + documentCommentsDisplayRequested({ element, nodeId }) } async function createEmbedding (file: File): Promise<{ file: Ref, type: string } | undefined> { @@ -241,32 +241,22 @@ qmsInlineComment: { isHighlightModeOn: () => $canViewDocumentComments || $canAddDocumentComments, getNodeHighlight: handleNodeHighlight, - onNodeSelected: (uuid) => { - if (selectedNodeId !== uuid) { - selectedNodeId = uuid - } - if (isFocused) { - documentCommentsHighlightUpdated(selectedNodeId !== null ? { nodeId: selectedNodeId } : null) - } - }, - onNodeClicked: (uuid) => { - if (selectedNodeId !== uuid) { - selectedNodeId = uuid - } + onNodeClicked: (uuids) => { + // filter out those uuids that are not in comments + uuids = Array.isArray(uuids) ? uuids : [uuids] + uuids = uuids.filter((id) => commentUuids.includes(id)).sort() - if (!$arePopupsOpened && $canViewDocumentComments && selectedNodeId) { + // scroll through the comments as user clicks on the same node + const currIndex = selectedNodeId != null ? uuids.indexOf(selectedNodeId) : -1 + const nextIndex = currIndex === -1 ? 0 : (currIndex + 1) % uuids.length + selectedNodeId = uuids[nextIndex] + + if (!$arePopupsOpened && $canViewDocumentComments && selectedNodeId != null) { handleShowDocumentComments(selectedNodeId) } } } }, - hooks: { - focus: { - onFocus: (focused) => { - isFocused = focused - } - } - }, toc: { onChange: (h) => { headings = h diff --git a/plugins/controlled-documents-resources/src/components/document/popups/AddCommentPopup.svelte b/plugins/controlled-documents-resources/src/components/document/popups/AddCommentPopup.svelte index 1bba64379d..034f56241d 100644 --- a/plugins/controlled-documents-resources/src/components/document/popups/AddCommentPopup.svelte +++ b/plugins/controlled-documents-resources/src/components/document/popups/AddCommentPopup.svelte @@ -11,17 +11,29 @@ const dispatch = createEventDispatcher() - let messageId: Ref = generateId() - async function handleMessage (event: CustomEvent): Promise { + const messageId: Ref = generateId() const comment = await addDocumentCommentFx({ content: event.detail, messageId, nodeId }) - messageId = generateId() dispatch('close', comment) } + + let popup: HTMLDivElement | undefined + + function handleClick (event: MouseEvent): void { + if (event.target instanceof Node) { + if (popup !== undefined && !popup.contains(event.target)) { + event.preventDefault() + event.stopPropagation() + dispatch('close', undefined) + } + } + } -
+ + +
>>( generateActionName('savedAttachmentsUpdated') ) +export const documentCommentsAddCanceled = createEvent<{ + nodeId?: string | null +}>(generateActionName('documentCommentsAddCanceled')) + export const documentCommentsDisplayRequested = createEvent<{ nodeId?: string | null element?: PopupAlignment diff --git a/plugins/controlled-documents-resources/src/stores/editors/document/documentComments.ts b/plugins/controlled-documents-resources/src/stores/editors/document/documentComments.ts index 6365f5dc58..bef590700b 100644 --- a/plugins/controlled-documents-resources/src/stores/editors/document/documentComments.ts +++ b/plugins/controlled-documents-resources/src/stores/editors/document/documentComments.ts @@ -19,9 +19,10 @@ import { type CompAndProps, type PopupAlignment, popupstore, showPopup } from '@ import documents, { type Document, type DocumentComment } from '@hcengineering/controlled-documents' import { isDocumentCommentAttachedTo } from '../../../utils' import { - DocumentCommentPopupCategory, type DocumentCommentsFilter, + DocumentCommentPopupCategory, documentCommentPopupsOpened, + documentCommentsAddCanceled, documentCommentsDisplayRequested, documentCommentsHighlightCleared, documentCommentsHighlightUpdated, @@ -31,7 +32,8 @@ import { documentCommentsSortByChanged, documentCommentsUpdated, controlledDocumentClosed, - savedAttachmentsUpdated + savedAttachmentsUpdated, + controlledDocumentOpened } from './actions' export const $areDocumentCommentPopupsOpened = createStore(false).on( @@ -120,6 +122,7 @@ export const showAddCommentPopupFx = createEffect((payload: { element?: PopupAli payload.element, (result) => { if (result === null || result === undefined) { + documentCommentsAddCanceled({ nodeId: payload.nodeId }) documentCommentsHighlightCleared() } else { documentCommentsDisplayRequested(payload) @@ -187,4 +190,5 @@ export const $savedAttachments = createStore>>([]) .on(savedAttachmentsUpdated, (_, payload) => payload) .reset(controlledDocumentClosed) +forward({ from: controlledDocumentOpened, to: documentCommentsHighlightCleared }) forward({ from: documentCommentsLocationNavigateRequested, to: documentCommentsHighlightUpdated }) diff --git a/plugins/controlled-documents-resources/src/text.ts b/plugins/controlled-documents-resources/src/text.ts index 1b370ce60b..33d340312a 100644 --- a/plugins/controlled-documents-resources/src/text.ts +++ b/plugins/controlled-documents-resources/src/text.ts @@ -24,7 +24,7 @@ import { getCurrentEmployee } from '@hcengineering/contact' import { RequestStatus } from '@hcengineering/request' import { getClient } from '@hcengineering/presentation' import { type ActionContext } from '@hcengineering/text-editor' -import { getNodeElement, selectNode, nodeUuidName } from '@hcengineering/text-editor-resources' +import { getNodeElement, selectNode } from '@hcengineering/text-editor-resources' import { showAddCommentPopupFx } from './stores/editors/document' import { $editorMode } from './stores/editors/document/editor' @@ -127,39 +127,20 @@ async function canAddDocumentComments (doc: ControlledDocument, mode: EditorMode return false } -function setQMSInlineCommentMark (editor: Editor): string | undefined { - if (editor === undefined) { +export async function comment (editor: Editor, event: MouseEvent, ctx: ActionContext): Promise { + const { objectId, objectClass } = ctx + + if (editor === undefined || objectId === undefined || objectClass === undefined) { return } const nodeId = generateId() editor.commands.setQMSInlineCommentMark(nodeId) - return nodeId -} + const element = getNodeElement(editor, nodeId) + await showAddCommentPopupFx({ element, nodeId }) -export async function comment (editor: Editor, event: MouseEvent, ctx: ActionContext): Promise { - const { objectId, objectClass } = ctx - if (objectId === undefined || objectClass === undefined) { - return - } - - let selectedNodeId = editor.extensionStorage[nodeUuidName].activeNodeUuid - - if (selectedNodeId == null) { - selectedNodeId = setQMSInlineCommentMark(editor) - } - - if (selectedNodeId == null) { - return - } - - await showAddCommentPopupFx({ - element: getNodeElement(editor, selectedNodeId), - nodeId: selectedNodeId - }) - - selectNode(editor, selectedNodeId) + selectNode(editor, nodeId) } export async function isCommentVisible (editor: Editor, ctx: ActionContext): Promise { diff --git a/plugins/text-editor-resources/src/components/extension/qms/qmsInlineComment.ts b/plugins/text-editor-resources/src/components/extension/qms/qmsInlineComment.ts index f89421d2b2..5479a276d7 100644 --- a/plugins/text-editor-resources/src/components/extension/qms/qmsInlineComment.ts +++ b/plugins/text-editor-resources/src/components/extension/qms/qmsInlineComment.ts @@ -22,7 +22,8 @@ import { Decoration, DecorationSet } from '@tiptap/pm/view' import { QMSInlineCommentMark, type QMSInlineCommentMarkOptions, - findQMSInlineCommentMark + findQMSInlineCommentMark, + getMarkUuid } from './qmsInlineCommentMark' export enum CommentHighlightType { @@ -233,24 +234,24 @@ const createDecorations = ( ): DecorationSet => { const decorations: Decoration[] = [] - doc.descendants((node, pos) => { - const qmsInlineCommentMark = findQMSInlineCommentMark(node) + doc.descendants((descendant, pos) => { + descendant.marks.forEach((mark) => { + const uuid = getMarkUuid(mark) + if (uuid !== undefined) { + const attributes = generateAttributes(uuid, options) + if (attributes === null || attributes === undefined) { + return + } - if (qmsInlineCommentMark !== null && qmsInlineCommentMark !== undefined) { - const nodeUuid = qmsInlineCommentMark.attrs[QMSInlineCommentMark.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, mark.attrs) + if (!isRange(range)) { + return + } + + decorations.push(Decoration.inline(range.from, range.to, attributes)) } - - // 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, qmsInlineCommentMark.attrs) - if (!isRange(range)) { - return - } - - decorations.push(Decoration.inline(range.from, range.to, attributes)) - } + }) }) return DecorationSet.create(doc, decorations) diff --git a/plugins/text-editor-resources/src/components/extension/qms/qmsInlineCommentMark.ts b/plugins/text-editor-resources/src/components/extension/qms/qmsInlineCommentMark.ts index 3b76ad5a4e..3627de1dc5 100644 --- a/plugins/text-editor-resources/src/components/extension/qms/qmsInlineCommentMark.ts +++ b/plugins/text-editor-resources/src/components/extension/qms/qmsInlineCommentMark.ts @@ -13,25 +13,16 @@ // limitations under the License. // -import { - type CommandProps, - type Editor, - Mark, - getMarkRange, - getMarkType, - getMarksBetween, - mergeAttributes -} from '@tiptap/core' -import { type Node, type Mark as ProseMirrorMark } from '@tiptap/pm/model' -import { type EditorState, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' +import { type CommandProps, type Editor, Mark, getMarkRange, mergeAttributes } from '@tiptap/core' +import { type Node, type Mark as ProseMirrorMark, Fragment, Slice } from '@tiptap/pm/model' +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state' export const qmsInlineCommentMarkName = 'node-uuid' export const nodeElementQuerySelector = (nodeUuid: string): string => `span[${qmsInlineCommentMarkName}='${nodeUuid}']` export interface QMSInlineCommentMarkOptions { HTMLAttributes: Record - onNodeSelected?: (uuid: string | null) => void - onNodeClicked?: (uuid: string) => void + onNodeClicked?: (uuid: string | string[]) => void } declare module '@tiptap/core' { @@ -43,32 +34,6 @@ declare module '@tiptap/core' { } } -export interface QMSInlineCommentMarkStorage { - activeQMSInlineComment: string | null -} - -const findSelectionQMSInlineCommentMark = (state: EditorState): ProseMirrorMark | undefined => { - const { doc, selection } = state - - if (selection === null || selection === undefined) { - return - } - - let nodeUuidMark: ProseMirrorMark | undefined - for (const range of selection.ranges) { - if (nodeUuidMark === undefined) { - doc.nodesBetween(range.$from.pos, range.$to.pos, (node) => { - if (nodeUuidMark !== undefined) { - return false - } - nodeUuidMark = findQMSInlineCommentMark(node) - }) - } - } - - return nodeUuidMark -} - export const findQMSInlineCommentMark = (node: Node): ProseMirrorMark | undefined => { if (node === null || node === undefined) { return @@ -85,13 +50,14 @@ export function getNodeElement (editor: Editor, uuid: string): Element | null { return editor.view.dom.querySelector(nodeElementQuerySelector(uuid)) } -export function selectNode (editor: Editor, uuid: string): void { +export function selectNode (editor: Editor, uuid: string): boolean { if (editor === undefined) { - return + return false } - const { doc, schema, tr } = editor.view.state let foundNode = false + + const { doc, schema, tr } = editor.view.state doc.descendants((node, pos) => { if (foundNode) { return false @@ -118,12 +84,17 @@ export function selectNode (editor: Editor, uuid: string): void { editor?.view.dispatch(tr.setSelection(new TextSelection($start, $end))) editor.commands.focus() }) + + return foundNode } -export const QMSInlineCommentMark = Mark.create({ +export const QMSInlineCommentMark = Mark.create({ name: qmsInlineCommentMarkName, exitable: true, inclusive: false, + // set to empty string to allow multiple marks of the same type + // https://prosemirror.net/docs/ref/#model.MarkSpec.excludes + excludes: '', addOptions () { return { HTMLAttributes: {} @@ -161,37 +132,28 @@ export const QMSInlineCommentMark = Mark.create - markRange.mark.type.name === qmsInlineCommentMarkName && markRange.from <= pos && markRange.to >= pos - ) ?? [] - let nodeUuid: string | null = null + const marks = view.state.selection.$head + .marks() + .filter((mark) => mark.type.name === qmsInlineCommentMarkName) - if (markRanges.length > 0) { - nodeUuid = markRanges[0].mark.attrs[qmsInlineCommentMarkName] - } + const uuids = marks + .map((mark) => mark.attrs[qmsInlineCommentMarkName]) + .filter((uuid) => uuid !== null && uuid !== undefined && uuid.length > 0) - if (nodeUuid !== null) { - options.onNodeClicked?.(nodeUuid) - } - - if (storage.activeQMSInlineComment !== nodeUuid) { - storage.activeQMSInlineComment = nodeUuid - options.onNodeSelected?.(storage.activeQMSInlineComment) + if (uuids.length !== 0) { + options.onNodeClicked?.(uuids) } } } - }) + }), + QmsInlineCommentPastePlugin() ] return plugins @@ -202,14 +164,9 @@ export const QMSInlineCommentMark = Mark.create ({ commands, state }: CommandProps) => { - const { doc, selection } = state - if (selection.empty) { + if (state.selection.empty) { return false } - if (doc.rangeHasMark(selection.from, selection.to, getMarkType(qmsInlineCommentMarkName, state.schema))) { - return false - } - return commands.setMark(this.name, { [qmsInlineCommentMarkName]: uuid }) }, unsetQMSInlineCommentMark: @@ -217,24 +174,68 @@ export const QMSInlineCommentMark = Mark.create commands.unsetMark(this.name) } - }, - - addStorage () { - return { - activeQMSInlineComment: null - } - }, - - onSelectionUpdate () { - const activeQMSInlineCommentMark = findSelectionQMSInlineCommentMark(this.editor.state) - const activeQMSInlineComment = - activeQMSInlineCommentMark !== null && activeQMSInlineCommentMark !== undefined - ? activeQMSInlineCommentMark.attrs[qmsInlineCommentMarkName] - : null - - if (this.storage.activeQMSInlineComment !== activeQMSInlineComment) { - this.storage.activeQMSInlineComment = activeQMSInlineComment - this.options.onNodeSelected?.(this.storage.activeQMSInlineComment) - } } }) + +function removeMarkFromNode (node: Node, name: string): Node { + if (node.isText) { + return node.mark(node.marks.filter((mark) => mark.type.name !== name)) + } + + if (node.content.size > 0) { + const nodes: Node[] = [] + node.content.forEach((child) => { + nodes.push(removeMarkFromNode(child, name)) + }) + return node.copy(Fragment.fromArray(nodes)) + } + + return node +} + +export function getMarkUuid (mark: ProseMirrorMark): string | undefined { + return mark.type.name === qmsInlineCommentMarkName ? mark.attrs[qmsInlineCommentMarkName] : undefined +} + +export function QmsInlineCommentPastePlugin (): Plugin { + return new Plugin({ + key: new PluginKey('qms-inline-comment-paste-plugin'), + props: { + transformPasted: (slice, view) => { + const pastedUuids = new Set() + slice.content.forEach((node) => { + node.descendants((descendant) => { + descendant.marks.forEach((mark) => { + const uuid = getMarkUuid(mark) + if (uuid !== undefined) { + pastedUuids.add(uuid) + } + }) + }) + }) + + let hasDuplicatedUuids = false + view.state.doc.descendants((node) => { + if (hasDuplicatedUuids) return false + for (const mark of node.marks) { + const uuid = getMarkUuid(mark) + if (uuid !== undefined && pastedUuids.has(uuid)) { + hasDuplicatedUuids = true + break + } + } + }) + + if (hasDuplicatedUuids) { + const nodes: Node[] = [] + slice.content.forEach((node) => { + nodes.push(removeMarkFromNode(node, qmsInlineCommentMarkName)) + }) + return new Slice(Fragment.fromArray(nodes), slice.openStart, slice.openEnd) + } + + return slice + } + } + }) +} diff --git a/plugins/text-editor-resources/src/index.ts b/plugins/text-editor-resources/src/index.ts index 414b5fb262..0dd85658e6 100644 --- a/plugins/text-editor-resources/src/index.ts +++ b/plugins/text-editor-resources/src/index.ts @@ -95,8 +95,7 @@ export { QMSInlineCommentMark as NodeUuidExtension, qmsInlineCommentMarkName as nodeUuidName, selectNode, - type QMSInlineCommentMarkOptions as NodeUuidOptions, - type QMSInlineCommentMarkStorage as NodeUuidStorage + type QMSInlineCommentMarkOptions as NodeUuidOptions } from './components/extension/qms/qmsInlineCommentMark' export { referenceConfig, ReferenceExtension } from './components/extension/reference' export { type Provider } from './provider/types'