diff --git a/src/components/cards/CommentCard.tsx b/src/components/cards/CommentCard.tsx index d408aed74..2c8ffa50a 100644 --- a/src/components/cards/CommentCard.tsx +++ b/src/components/cards/CommentCard.tsx @@ -35,6 +35,7 @@ import { fetcher } from '@/utils/fetcher' import { getTimeDifference } from '@/utils/getTimeDifference' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' import { checkOptimisticStableId, OptimisticUpdate } from '@/utils/optimisticCommentUtils' +import { canSubmitTapwriteContent, isTapwriteInteractionActive } from '@/utils/tapwriteEditorState' import { ReplyResponse } from '@api/activity-logs/schemas/CommentAddedSchema' import { LogResponse } from '@api/activity-logs/schemas/LogResponseSchema' import { Box, Collapse, Stack, Typography } from '@mui/material' @@ -134,6 +135,8 @@ export const CommentCard = ({ setEditedContent(content) } const handleEdit = async () => { + if (!canSubmitTapwriteContent(isListOrMenuActive)) return + if (isTapwriteContentEmpty(editedContent)) { setEditedContent(content) setIsReadOnly(true) @@ -153,11 +156,11 @@ export const CommentCard = ({ if (!isFocused || isMobile()) { return } - if (event.key === 'Enter' && !event.shiftKey && !isListOrMenuActive) { + if (event.key === 'Enter' && !event.shiftKey && canSubmitTapwriteContent(isListOrMenuActive)) { event.preventDefault() handleEdit() } - if (event.key === 'Enter' && event.ctrlKey) { + if (event.key === 'Enter' && event.ctrlKey && canSubmitTapwriteContent(isListOrMenuActive)) { event.preventDefault() handleEdit() //Invoke submit if ctrl+enter is pressed at any time } @@ -321,8 +324,7 @@ export const CommentCard = ({ { - const { isListActive, isFloatingMenuActive } = prop - setIsListOrMenuActive(isListActive || isFloatingMenuActive) + setIsListOrMenuActive(isTapwriteInteractionActive(prop)) }} getContent={setEditedContent} readonly={isReadOnly} diff --git a/src/components/cards/ReplyCard.tsx b/src/components/cards/ReplyCard.tsx index 669f30cda..d435f3227 100644 --- a/src/components/cards/ReplyCard.tsx +++ b/src/components/cards/ReplyCard.tsx @@ -23,6 +23,7 @@ import { deleteEditorAttachmentsHandler, getAttachmentPayload, getCustomFilePath import { createUploadFn } from '@/utils/createUploadFn' import { getTimeDifference } from '@/utils/getTimeDifference' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' +import { canSubmitTapwriteContent, isTapwriteInteractionActive } from '@/utils/tapwriteEditorState' import { Box, Stack } from '@mui/material' import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' @@ -86,6 +87,8 @@ export const ReplyCard = ({ const { postAttachment } = usePostAttachment() const handleEdit = async () => { + if (!canSubmitTapwriteContent(isListOrMenuActive)) return + if (isTapwriteContentEmpty(editedContent)) { setEditedContent(content) setIsReadOnly(true) @@ -105,11 +108,11 @@ export const ReplyCard = ({ if (!isFocused || isMobile()) { return } - if (event.key === 'Enter' && !event.shiftKey && !isListOrMenuActive) { + if (event.key === 'Enter' && !event.shiftKey && canSubmitTapwriteContent(isListOrMenuActive)) { event.preventDefault() handleEdit() } - if (event.key === 'Enter' && event.ctrlKey) { + if (event.key === 'Enter' && event.ctrlKey && canSubmitTapwriteContent(isListOrMenuActive)) { event.preventDefault() handleEdit() //Invoke submit if ctrl+enter is pressed at any time } @@ -236,8 +239,7 @@ export const ReplyCard = ({ { - const { isListActive, isFloatingMenuActive } = prop - setIsListOrMenuActive(isListActive || isFloatingMenuActive) + setIsListOrMenuActive(isTapwriteInteractionActive(prop)) }} getContent={setEditedContent} readonly={isReadOnly} diff --git a/src/components/inputs/CommentInput.tsx b/src/components/inputs/CommentInput.tsx index 15f0e18a5..20d0f6f2c 100644 --- a/src/components/inputs/CommentInput.tsx +++ b/src/components/inputs/CommentInput.tsx @@ -14,6 +14,7 @@ import { deleteEditorAttachmentsHandler, uploadAttachmentHandler } from '@/utils import { createUploadFn } from '@/utils/createUploadFn' import { getMentionsList } from '@/utils/getMentionList' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' +import { canSubmitTapwriteContent, isTapwriteInteractionActive } from '@/utils/tapwriteEditorState' import { Stack } from '@mui/material' import { useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' @@ -41,6 +42,8 @@ export const CommentInput = ({ createComment, task_id, token }: Prop) => { } const handleSubmit = () => { + if (!canSubmitTapwriteContent(isListOrMenuActive)) return + let content = detail const END_P = '

' const endChunk = content.slice(-7) @@ -65,11 +68,11 @@ export const CommentInput = ({ createComment, task_id, token }: Prop) => { if (!isFocused || isMobile()) { return } - if (event.key === 'Enter' && !event.shiftKey && !isListOrMenuActive) { + if (event.key === 'Enter' && !event.shiftKey && canSubmitTapwriteContent(isListOrMenuActive)) { event.preventDefault() // Prevent new line in the editor handleSubmit() } - if (event.key === 'Enter' && event.ctrlKey) { + if (event.key === 'Enter' && event.ctrlKey && canSubmitTapwriteContent(isListOrMenuActive)) { event.preventDefault() handleSubmit() //Invoke submit if ctrl+enter is pressed at any time } @@ -156,8 +159,7 @@ export const CommentInput = ({ createComment, task_id, token }: Prop) => { editorClass="tapwrite-comment-input" hardbreak onActiveStatusChange={(prop) => { - const { isListActive, isFloatingMenuActive } = prop - setIsListOrMenuActive(isListActive || isFloatingMenuActive) + setIsListOrMenuActive(isTapwriteInteractionActive(prop)) }} parentContainerStyle={{ width: '100%', diff --git a/src/components/inputs/ReplyInput.tsx b/src/components/inputs/ReplyInput.tsx index 0d35e313f..33cde4eb8 100644 --- a/src/components/inputs/ReplyInput.tsx +++ b/src/components/inputs/ReplyInput.tsx @@ -9,6 +9,7 @@ import { CreateComment } from '@/types/dto/comment.dto' import { deleteEditorAttachmentsHandler } from '@/utils/attachmentUtils' import { isPopoverInputFocused } from '@/utils/isPopoverInputFocused' import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty' +import { canSubmitTapwriteContent, isTapwriteInteractionActive } from '@/utils/tapwriteEditorState' import { Box, Stack } from '@mui/material' import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } from 'react' import { useSelector } from 'react-redux' @@ -47,6 +48,8 @@ export const ReplyInput = ({ const [pendingReplies, setPendingReplies] = useState<{ content: string; taskId: string }[]>([]) const handleReplySubmission = useCallback(() => { + if (!canSubmitTapwriteContent(isListOrMenuActive)) return + let content = detail const END_P = '

' if (content.slice(-7) === END_P) { @@ -57,7 +60,7 @@ export const ReplyInput = ({ setDetail('') setPendingReplies((prev) => [...prev, { content, taskId: task_id }]) } - }, [comment, detail, task_id]) + }, [detail, isListOrMenuActive, task_id]) useEffect(() => { if (pendingReplies.length > 0 && !comment.details.id.includes('temp-comment')) { @@ -76,11 +79,11 @@ export const ReplyInput = ({ if (!focusReplyInput || isMobile()) { return } - if (event.key === 'Enter' && !event.shiftKey && !isListOrMenuActive) { + if (event.key === 'Enter' && !event.shiftKey && canSubmitTapwriteContent(isListOrMenuActive)) { event.preventDefault() handleReplySubmission() } - if (event.key === 'Enter' && event.ctrlKey) { + if (event.key === 'Enter' && event.ctrlKey && canSubmitTapwriteContent(isListOrMenuActive)) { event.preventDefault() handleReplySubmission() } @@ -181,8 +184,7 @@ export const ReplyInput = ({ className={'tapwrite-reply-input'} hardbreak onActiveStatusChange={(prop) => { - const { isListActive, isFloatingMenuActive } = prop - setIsListOrMenuActive(isListActive || isFloatingMenuActive) + setIsListOrMenuActive(isTapwriteInteractionActive(prop)) }} attachmentLayout={(props) => ( diff --git a/src/components/inputs/tiptap/useTitleEditor.ts b/src/components/inputs/tiptap/useTitleEditor.ts index 20106f0da..c1a9761d3 100644 --- a/src/components/inputs/tiptap/useTitleEditor.ts +++ b/src/components/inputs/tiptap/useTitleEditor.ts @@ -4,6 +4,7 @@ import { tapwriteDynamicFields, } from '@/components/inputs/TapwriteDynamicFieldDropdown' import { getWorstCaseResolvedLength } from '@/utils/dynamicFields' +import { clampProseMirrorPosition } from '@/utils/tapwriteEditorState' import Document from '@tiptap/extension-document' import History from '@tiptap/extension-history' import Paragraph from '@tiptap/extension-paragraph' @@ -132,8 +133,10 @@ export function useTitleEditor({ value, onChange, placeholder = '', autoFocus, o const { schema } = view.state const node = schema.nodes.autofillField.create({ value: fieldKey }) const space = schema.text(' ') - const tr = view.state.tr.insert(pos.pos, Fragment.from([node, space])) - tr.setSelection(TextSelection.create(tr.doc, pos.pos + node.nodeSize + space.nodeSize)) + const insertPos = clampProseMirrorPosition(pos.pos, view.state.doc.content.size) + const tr = view.state.tr.insert(insertPos, Fragment.from([node, space])) + const selectionPos = clampProseMirrorPosition(insertPos + node.nodeSize + space.nodeSize, tr.doc.content.size) + tr.setSelection(TextSelection.create(tr.doc, selectionPos)) view.dispatch(tr) view.focus() // Firefox hides the caret after drag-and-drop; a blur/focus cycle forces it to repaint. diff --git a/src/utils/tapwriteEditorState.test.ts b/src/utils/tapwriteEditorState.test.ts new file mode 100644 index 000000000..fb486e641 --- /dev/null +++ b/src/utils/tapwriteEditorState.test.ts @@ -0,0 +1,66 @@ +import { + canSubmitTapwriteContent, + clampProseMirrorPosition, + hasTapwritePopover, + isTapwriteInteractionActive, +} from '@/utils/tapwriteEditorState' + +const originalDocument = globalThis.document + +function mockDocumentQuerySelector(result: Element | null) { + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: { + querySelector: jest.fn(() => result), + }, + }) +} + +function restoreDocument() { + if (originalDocument) { + Object.defineProperty(globalThis, 'document', { + configurable: true, + value: originalDocument, + }) + return + } + + delete (globalThis as { document?: Document }).document +} + +describe('tapwriteEditorState', () => { + afterEach(() => { + restoreDocument() + }) + + it('detects active Tapwrite interactions from status flags', () => { + expect(isTapwriteInteractionActive({ isListActive: true })).toBe(true) + expect(isTapwriteInteractionActive({ isFloatingMenuActive: true })).toBe(true) + expect(isTapwriteInteractionActive({ isListActive: false, isFloatingMenuActive: false })).toBe(false) + }) + + it('treats Tippy popovers as active Tapwrite interactions', () => { + mockDocumentQuerySelector({} as Element) + + expect(hasTapwritePopover()).toBe(true) + expect(isTapwriteInteractionActive({ isListActive: false, isFloatingMenuActive: false })).toBe(true) + }) + + it('blocks content submission while Tapwrite interactions are active', () => { + expect(canSubmitTapwriteContent(true)).toBe(false) + + mockDocumentQuerySelector({} as Element) + + expect(canSubmitTapwriteContent(false)).toBe(false) + }) + + it('allows content submission when no Tapwrite interaction is active', () => { + expect(canSubmitTapwriteContent(false)).toBe(true) + }) + + it('clamps ProseMirror positions to the current document bounds', () => { + expect(clampProseMirrorPosition(-7, 12)).toBe(0) + expect(clampProseMirrorPosition(7, 12)).toBe(7) + expect(clampProseMirrorPosition(20, 12)).toBe(12) + }) +}) diff --git a/src/utils/tapwriteEditorState.ts b/src/utils/tapwriteEditorState.ts new file mode 100644 index 000000000..25aac5ff7 --- /dev/null +++ b/src/utils/tapwriteEditorState.ts @@ -0,0 +1,22 @@ +interface TapwriteActiveStatus { + isListActive?: boolean + isFloatingMenuActive?: boolean +} + +export function hasTapwritePopover() { + if (typeof document === 'undefined') return false + + return document.querySelector('.tippy-box') !== null +} + +export function isTapwriteInteractionActive(status: TapwriteActiveStatus) { + return Boolean(status.isListActive || status.isFloatingMenuActive || hasTapwritePopover()) +} + +export function canSubmitTapwriteContent(isListOrMenuActive: boolean) { + return !isListOrMenuActive && !hasTapwritePopover() +} + +export function clampProseMirrorPosition(position: number, docSize: number) { + return Math.max(0, Math.min(position, docSize)) +}