diff --git a/package/components/inline-comment/comment-drawer-mobile.tsx b/package/components/inline-comment/comment-drawer-mobile.tsx index 65af88b9..8a758106 100644 --- a/package/components/inline-comment/comment-drawer-mobile.tsx +++ b/package/components/inline-comment/comment-drawer-mobile.tsx @@ -109,6 +109,7 @@ export const CommentDrawerMobile = ({ { if (!isConnected) { @@ -205,6 +215,8 @@ export const CommentDropdown = ({ updateInlineDraftText(activeDraftId, event.target.value); } }} + onFocus={handleDraftFocus} + onBlur={handleDraftBlur} onKeyDown={handleKeyDown} className="color-bg-default w-full text-body-sm color-text-default min-h-[40px] overflow-y-auto no-scrollbar px-3 py-2 whitespace-pre-wrap" placeholder="Type your comment" @@ -222,10 +234,10 @@ export const CommentDropdown = ({ diff --git a/package/components/inline-comment/floating-comment/draft-floating-card.tsx b/package/components/inline-comment/floating-comment/draft-floating-card.tsx index 8a6749bc..47853d4c 100644 --- a/package/components/inline-comment/floating-comment/draft-floating-card.tsx +++ b/package/components/inline-comment/floating-comment/draft-floating-card.tsx @@ -10,6 +10,7 @@ import { nameFormatter } from '../../../utils/helpers'; import verifiedMark from '../../../assets/ens-check.svg'; import EnsLogo from '../../../assets/ens.svg'; import { useEnsStatus } from '../use-ens-status'; +import { useCommentDraftAutoSubmitCountdown } from '../use-comment-draft-auto-submit-countdown'; export const DraftFloatingCard = ({ draft, @@ -108,7 +109,17 @@ const InputField = ({ const updateInlineDraftText = useCommentStore((s) => s.updateInlineDraftText); const cancelInlineDraft = useCommentStore((s) => s.cancelInlineDraft); + const isConnected = useCommentStore((s) => s.isConnected); const draftTextareaRef = useRef(null); + const handleSubmit = useCallback(() => { + submitInlineDraft(draft.draftId); + }, [draft.draftId, submitInlineDraft]); + const { handleDraftBlur, handleDraftFocus, submitLabel } = + useCommentDraftAutoSubmitCountdown({ + draftId: draft.draftId, + canAutoSubmit: isConnected && Boolean(draftState.text.trim()), + onSubmit: handleSubmit, + }); useEffect(() => { if (!draftTextareaRef.current) { @@ -128,11 +139,13 @@ const InputField = ({ updateInlineDraftText(draft.draftId, event.target.value); resizeInlineCommentTextarea(event.currentTarget); }} + onFocus={handleDraftFocus} + onBlur={handleDraftBlur} onInput={(event) => resizeInlineCommentTextarea(event.currentTarget)} onKeyDown={(event) => { if (event.key === 'Enter' && (!event.shiftKey || event.metaKey)) { event.preventDefault(); - submitInlineDraft(draft.draftId); + handleSubmit(); } }} className="color-bg-default w-full text-body-sm color-text-default !p-0 !border-none h-[20px] max-h-[296px] overflow-y-auto no-scrollbar whitespace-pre-wrap" @@ -148,11 +161,11 @@ const InputField = ({ Cancel diff --git a/package/components/inline-comment/floating-comment/suggestion-draft-floating-card.tsx b/package/components/inline-comment/floating-comment/suggestion-draft-floating-card.tsx index 119f6034..2072b5dd 100644 --- a/package/components/inline-comment/floating-comment/suggestion-draft-floating-card.tsx +++ b/package/components/inline-comment/floating-comment/suggestion-draft-floating-card.tsx @@ -9,6 +9,7 @@ import { SuggestionDiffSummary } from '../suggestion-diff-summary'; import EnsLogo from '../../../assets/ens.svg'; import { dateFormatter, nameFormatter } from '../../../utils/helpers'; import verifiedMark from '../../../assets/ens-check.svg'; +import { useSuggestionAutoSubmitCountdown } from '../use-suggestion-auto-submit-countdown'; /** * SuggestionDraftFloatingCard @@ -40,6 +41,14 @@ export const SuggestionDraftFloatingCard = ({ const canSubmit = hasOriginal || hasInserted || hasLink; const username = useCommentStore((s) => s.username); const ensStatus = useEnsStatus(username); + const handleSubmit = useCallback(() => { + submitDraft(card.suggestionId); + }, [card.suggestionId, submitDraft]); + const { submitLabel } = useSuggestionAutoSubmitCountdown({ + suggestionId: card.suggestionId, + canAutoSubmit: isConnected && canSubmit, + onSubmit: handleSubmit, + }); const handleCardNode = useCallback( (node: HTMLDivElement | null) => { registerCardNode(card.floatingCardId, node); @@ -121,10 +130,10 @@ export const SuggestionDraftFloatingCard = ({ diff --git a/package/components/inline-comment/mobile-inline-comment-sheet.tsx b/package/components/inline-comment/mobile-inline-comment-sheet.tsx index 19561f65..73f4eaf7 100644 --- a/package/components/inline-comment/mobile-inline-comment-sheet.tsx +++ b/package/components/inline-comment/mobile-inline-comment-sheet.tsx @@ -1,15 +1,23 @@ import { useRef } from 'react'; -import { Avatar, IconButton, TextAreaFieldV2 } from '@fileverse/ui'; +import { + Avatar, + Button, + IconButton, + LucideIcon, + TextAreaFieldV2, +} from '@fileverse/ui'; import { DeleteConfirmOverlay } from './delete-confirm-overlay'; import { resizeInlineCommentTextarea } from './resize-inline-comment-textarea'; import type { InlineCommentDraft } from './context/types'; import { useCommentStore } from '../../stores/comment-store'; import { useEnsStatus } from './use-ens-status'; import EnsLogo from '../../assets/ens.svg'; +import { useCommentDraftAutoSubmitCountdown } from './use-comment-draft-auto-submit-countdown'; interface MobileInlineCommentProps { activeDraft: InlineCommentDraft | null; activeDraftId: string | null; + isConnected: boolean; isDiscardCommentOverlayVisible: boolean; mobileDraftRef: React.RefObject; onAttemptClose: () => void; @@ -22,6 +30,7 @@ interface MobileInlineCommentProps { export const MobileInlineComment = ({ activeDraft, activeDraftId, + isConnected, isDiscardCommentOverlayVisible, mobileDraftRef, onAttemptClose, @@ -33,6 +42,16 @@ export const MobileInlineComment = ({ const mobileDraftTextareaRef = useRef(null); const username = useCommentStore((s) => s.username); const ensStatus = useEnsStatus(username); + const { handleDraftBlur, handleDraftFocus, submitLabel } = + useCommentDraftAutoSubmitCountdown({ + draftId: activeDraftId, + canAutoSubmit: + isConnected && + Boolean(activeDraftId) && + Boolean(activeDraft?.text.trim()) && + !isDiscardCommentOverlayVisible, + onSubmit, + }); return (
resizeInlineCommentTextarea(event.currentTarget)} onKeyDown={(event) => { if (event.key === 'Enter' && (!event.shiftKey || event.metaKey)) { @@ -83,13 +104,19 @@ export const MobileInlineComment = ({ className="color-bg-default w-full text-body-sm color-text-default !p-0 !border-none h-[20px] max-h-[296px] overflow-y-auto no-scrollbar whitespace-pre-wrap" placeholder="Add a comment" /> - + title={submitLabel} + className="!min-w-[96px] shrink-0 !px-2" + > + + + {submitLabel} + +
- Submit + {submitLabel} diff --git a/package/components/inline-comment/use-auto-submit-countdown.ts b/package/components/inline-comment/use-auto-submit-countdown.ts new file mode 100644 index 00000000..fe40a0eb --- /dev/null +++ b/package/components/inline-comment/use-auto-submit-countdown.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef, useState } from 'react'; + +const AUTO_SUBMIT_COUNTDOWN_SECONDS = 5; + +interface UseAutoSubmitCountdownProps { + label: string; + onSubmit: () => void; + resetKey: string | null; + shouldRun: boolean; +} + +export const useAutoSubmitCountdown = ({ + label, + onSubmit, + resetKey, + shouldRun, +}: UseAutoSubmitCountdownProps) => { + const [remainingSeconds, setRemainingSeconds] = useState( + AUTO_SUBMIT_COUNTDOWN_SECONDS, + ); + const hasSubmittedRef = useRef(false); + + useEffect(() => { + hasSubmittedRef.current = false; + setRemainingSeconds(AUTO_SUBMIT_COUNTDOWN_SECONDS); + }, [resetKey]); + + useEffect(() => { + if (!shouldRun) { + hasSubmittedRef.current = false; + setRemainingSeconds((current) => + current === AUTO_SUBMIT_COUNTDOWN_SECONDS + ? current + : AUTO_SUBMIT_COUNTDOWN_SECONDS, + ); + return; + } + + const timeoutId = window.setTimeout(() => { + if (remainingSeconds <= 1) { + if (!hasSubmittedRef.current) { + hasSubmittedRef.current = true; + onSubmit(); + } + return; + } + + setRemainingSeconds(remainingSeconds - 1); + }, 1000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [onSubmit, remainingSeconds, shouldRun]); + + return { + submitLabel: shouldRun ? `${label} (${remainingSeconds})` : label, + }; +}; diff --git a/package/components/inline-comment/use-comment-draft-auto-submit-countdown.ts b/package/components/inline-comment/use-comment-draft-auto-submit-countdown.ts new file mode 100644 index 00000000..312040f0 --- /dev/null +++ b/package/components/inline-comment/use-comment-draft-auto-submit-countdown.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useAutoSubmitCountdown } from './use-auto-submit-countdown'; + +interface UseCommentDraftAutoSubmitCountdownProps { + draftId: string | null; + canAutoSubmit: boolean; + onSubmit: () => void; +} + +export const useCommentDraftAutoSubmitCountdown = ({ + draftId, + canAutoSubmit, + onSubmit, +}: UseCommentDraftAutoSubmitCountdownProps) => { + const [isDraftFocused, setIsDraftFocused] = useState(true); + + const handleDraftFocus = useCallback(() => { + setIsDraftFocused(true); + }, []); + + const handleDraftBlur = useCallback(() => { + setIsDraftFocused(false); + }, []); + + useEffect(() => { + setIsDraftFocused(true); + }, [draftId]); + + const { submitLabel } = useAutoSubmitCountdown({ + label: 'Send', + onSubmit, + resetKey: draftId, + shouldRun: canAutoSubmit && !isDraftFocused, + }); + + return { + handleDraftBlur, + handleDraftFocus, + submitLabel, + }; +}; diff --git a/package/components/inline-comment/use-suggestion-auto-submit-countdown.ts b/package/components/inline-comment/use-suggestion-auto-submit-countdown.ts new file mode 100644 index 00000000..75f18cb2 --- /dev/null +++ b/package/components/inline-comment/use-suggestion-auto-submit-countdown.ts @@ -0,0 +1,28 @@ +import { useCommentStore } from '../../stores/comment-store'; +import { useAutoSubmitCountdown } from './use-auto-submit-countdown'; + +interface UseSuggestionAutoSubmitCountdownProps { + suggestionId: string; + canAutoSubmit: boolean; + onSubmit: () => void; +} + +export const useSuggestionAutoSubmitCountdown = ({ + suggestionId, + canAutoSubmit, + onSubmit, +}: UseSuggestionAutoSubmitCountdownProps) => { + const activeSuggestionDraftIdAtCursor = useCommentStore( + (state) => state.activeSuggestionDraftIdAtCursor, + ); + const { submitLabel } = useAutoSubmitCountdown({ + label: 'Submit', + onSubmit, + resetKey: suggestionId, + shouldRun: canAutoSubmit && activeSuggestionDraftIdAtCursor !== suggestionId, + }); + + return { + submitLabel, + }; +}; diff --git a/package/extensions/comment/comment-decoration-plugin.ts b/package/extensions/comment/comment-decoration-plugin.ts index d2d99dd8..3bc96698 100644 --- a/package/extensions/comment/comment-decoration-plugin.ts +++ b/package/extensions/comment/comment-decoration-plugin.ts @@ -140,6 +140,20 @@ export function resolveCommentAnchorPointInState( } } +export function hasResolvableCommentAnchorInState( + anchor: Pick< + CommentAnchor, + 'anchorFrom' | 'anchorTo' | 'isSuggestion' | 'suggestionType' + >, + state: EditorState, +): boolean { + if (anchor.isSuggestion && anchor.suggestionType === 'add') { + return resolveCommentAnchorPointInState(anchor, state) !== null; + } + + return resolveCommentAnchorRangeInState(anchor, state) !== null; +} + function resolveCommentAnchorRangeFromRenderedDecorations( commentId: string, state: EditorState, @@ -173,8 +187,18 @@ function resolveCommentAnchorPointFromRenderedDecorations( state: EditorState, ): number | null { const pluginState = commentDecorationPluginKey.getState(state); + return resolveCommentAnchorPointFromDecorationSet( + commentId, + pluginState?.decorations, + ); +} + +function resolveCommentAnchorPointFromDecorationSet( + commentId: string, + decorations?: DecorationSet, +): number | null { const matchingDecorations = - pluginState?.decorations.find(undefined, undefined, (spec) => { + decorations?.find(undefined, undefined, (spec) => { return spec?.commentId === commentId; }) ?? []; @@ -518,10 +542,15 @@ function createSuggestionWidget( return span; } +const createSuggestionWidgetRenderer = + (text: string, commentId: string, isActive: boolean) => () => + createSuggestionWidget(text, commentId, isActive); + function buildDecorations( anchors: CommentAnchor[], state: EditorState, activeCommentId: string | null, + previousDecorations?: DecorationSet | null, ): DecorationSet { // Build a new set of decorations from the current anchor list. // This is called: @@ -534,7 +563,6 @@ function buildDecorations( } const decorations: Decoration[] = []; - for (const anchor of anchors) { // Skip deleted anchors entirely. if (anchor.deleted) continue; @@ -557,12 +585,21 @@ function buildDecorations( // changes (typing / backspace during an Add draft). if (anchor.isSuggestion && anchor.suggestionType === 'add') { if (!anchor.suggestedContent) continue; - const insertPoint = resolveCommentAnchorPointInState(anchor, state); + const directPoint = resolveCommentAnchorPointInState(anchor, state); + const fallbackPoint = resolveCommentAnchorPointFromDecorationSet( + anchor.id, + previousDecorations ?? undefined, + ); + const insertPoint = directPoint ?? fallbackPoint; if (insertPoint === null) continue; decorations.push( Decoration.widget( insertPoint, - createSuggestionWidget(anchor.suggestedContent, anchor.id, isActive), + createSuggestionWidgetRenderer( + anchor.suggestedContent, + anchor.id, + isActive, + ), { commentId: anchor.id, active: isActive, @@ -571,7 +608,6 @@ function buildDecorations( key: `suggestion-insert-${anchor.id}-${anchor.suggestedContent}-${ isActive ? 'active' : 'inactive' }`, - destroy: (node) => (node as HTMLElement).remove(), }, ), ); @@ -629,14 +665,18 @@ function buildDecorations( ); } - // Replace: widget showing the proposed content after the struck-through range. + // Replace: widget showing the proposed content before the struck-through range. // Key includes the content so PM redraws when the draft's suggestedContent // changes (typing / backspace during a Replace draft). if (suggestionType === 'replace' && suggestedContent) { decorations.push( Decoration.widget( - range.to, - createSuggestionWidget(suggestedContent, anchor.id, isActive), + range.from, + createSuggestionWidgetRenderer( + suggestedContent, + anchor.id, + isActive, + ), { commentId: anchor.id, active: isActive, @@ -645,7 +685,6 @@ function buildDecorations( key: `suggestion-insert-${anchor.id}-${suggestedContent}-${ isActive ? 'active' : 'inactive' }`, - destroy: (node) => (node as HTMLElement).remove(), }, ), ); @@ -723,11 +762,16 @@ export const CommentDecorationExtension = // Otherwise, map existing decorations through the transaction's mapping // to preserve visual highlights during non-doc changes (e.g., selection moves). if (tr.docChanged || tr.getMeta(commentDecorationPluginKey)) { + const previousDecorations = tr.docChanged + ? null + : pluginState.decorations; + return { decorations: buildDecorations( getAnchors(), newState, getActiveCommentId(), + previousDecorations, ), }; } diff --git a/package/extensions/page-break/page-break.ts b/package/extensions/page-break/page-break.ts index 7c012754..860f2307 100644 --- a/package/extensions/page-break/page-break.ts +++ b/package/extensions/page-break/page-break.ts @@ -121,8 +121,9 @@ export const PageBreak = Node.create({ return true; } - // Check if text input would affect a page break - const nodeAtPos = state.doc.nodeAt(from - 1); + // Check if text input would affect a previous page break. At the + // start of the document there is no previous position to inspect. + const nodeAtPos = from > 0 ? state.doc.nodeAt(from - 1) : null; if (nodeAtPos?.type.name === 'pageBreak') { return true; } diff --git a/package/extensions/suggestion/suggestion-tracking-extension.ts b/package/extensions/suggestion/suggestion-tracking-extension.ts index c09f7999..a0828fba 100644 --- a/package/extensions/suggestion/suggestion-tracking-extension.ts +++ b/package/extensions/suggestion/suggestion-tracking-extension.ts @@ -54,6 +54,9 @@ export interface SuggestionTrackingOptions { /** Viewer pastes a link over selected text. */ onPasteLink: (from: number, to: number, href: string) => void; + /** Viewer attempted an unsupported paste/drop in suggestion mode. */ + onUnsupportedPaste?: () => void; + /** * Viewer presses Backspace/Delete with a collapsed cursor. * The consumer decides whether this should shrink an active draft or create @@ -186,6 +189,58 @@ function normalizePastedHref(text: string): string | null { : `https://${href}`; } +function hasClipboardFile(dataTransfer: DataTransfer): boolean { + if (dataTransfer.files?.length) { + return true; + } + + return Array.from(dataTransfer.items ?? []).some( + (item) => item.kind === 'file', + ); +} + +function getSingleLinePlainText( + dataTransfer: DataTransfer | null | undefined, +): string | null { + // Suggestion paste v1 intentionally accepts only plain, single-line text. + // Files, multiline text, and rich clipboard payloads stay blocked so the + // immutable suggestion-mode document cannot be changed through PM fallback. + if (!dataTransfer || hasClipboardFile(dataTransfer)) { + return null; + } + + const text = dataTransfer.getData('text/plain'); + + if (!text || text.trim().length === 0 || /[\r\n]/.test(text)) { + return null; + } + + return text; +} + +function routePlainTextPaste( + opts: SuggestionTrackingOptions, + from: number, + to: number, + text: string, +) { + // Preserve the existing selected-text + URL gesture as a link suggestion; + // all other accepted plain text becomes add/replace suggestion content. + const pastedHref = normalizePastedHref(text); + + if (from < to && pastedHref) { + opts.onPasteLink(from, to, pastedHref); + return; + } + + if (from < to) { + opts.onReplaceTyping(from, to, text); + return; + } + + opts.onTextInput(text); +} + // --------------------------------------------------------------------------- // Extension // --------------------------------------------------------------------------- @@ -202,6 +257,7 @@ export const SuggestionTrackingExtension = onReplaceTyping: () => {}, onDeleteSelection: () => {}, onPasteLink: () => {}, + onUnsupportedPaste: () => {}, onDeleteAtCursor: () => {}, onDeleteRangeWithoutSelection: () => {}, onUndo: () => {}, @@ -383,28 +439,31 @@ export const SuggestionTrackingExtension = }, // ----- Paste / Drop --------------------------------------------- - // In suggestion mode, regular paste is blocked so the document - // stays immutable. The one allowed paste gesture is select text + - // paste a URL, which creates a link suggestion over that range. + // In suggestion mode, plain single-line text paste is captured as + // a suggestion draft. The document still stays immutable. handlePaste(view, event) { const opts = getOptions(); if (!opts.getIsSuggestionMode()) return false; event.preventDefault(); const { from, to } = view.state.selection; - const pastedHref = normalizePastedHref( - event.clipboardData?.getData('text/plain') ?? '', - ); + const pastedText = getSingleLinePlainText(event.clipboardData); - if (from < to && pastedHref) { - opts.onPasteLink(from, to, pastedHref); - return true; + if (pastedText) { + routePlainTextPaste(opts, from, to, pastedText); + } else { + opts.onUnsupportedPaste?.(); } return true; }, - handleDrop() { - return getOptions().getIsSuggestionMode(); + handleDrop(_view, event) { + const opts = getOptions(); + if (!opts.getIsSuggestionMode()) return false; + + event.preventDefault(); + opts.onUnsupportedPaste?.(); + return true; }, // ----- DOM beforeinput — catches deletion paths that bypass @@ -496,28 +555,40 @@ export const SuggestionTrackingExtension = return true; } - // Paste / drop routes through beforeinput too on some browsers - if ( - inputType === 'insertFromPaste' || - inputType === 'insertFromPasteAsQuotation' || - inputType === 'insertFromDrop' || - inputType === 'insertReplacementText' - ) { + // Paste routes through beforeinput too on some browsers. + if (inputType === 'insertFromPaste') { event.preventDefault(); - const pastedHref = normalizePastedHref( + const pastedText = getSingleLinePlainText( ( event as InputEvent & { dataTransfer?: DataTransfer | null; } - ).dataTransfer?.getData('text/plain') ?? '', + ).dataTransfer, ); const { from, to } = view.state.selection; - if (from < to && pastedHref) { - opts.onPasteLink(from, to, pastedHref); + if (pastedText) { + routePlainTextPaste( + opts, + targetRange && hasTargetRange ? targetRange.from : from, + targetRange && hasTargetRange ? targetRange.to : to, + pastedText, + ); + } else { + opts.onUnsupportedPaste?.(); } return true; } + if ( + inputType === 'insertFromPasteAsQuotation' || + inputType === 'insertFromDrop' || + inputType === 'insertReplacementText' + ) { + event.preventDefault(); + opts.onUnsupportedPaste?.(); + return true; + } + // insertText / insertCompositionText — let handleTextInput // deal with it. Return false so PM continues processing. return false; diff --git a/package/hooks/use-tab-editor.tsx b/package/hooks/use-tab-editor.tsx index 35ea51d0..19a876f2 100644 --- a/package/hooks/use-tab-editor.tsx +++ b/package/hooks/use-tab-editor.tsx @@ -9,6 +9,7 @@ import { MutableRefObject, SetStateAction, } from 'react'; +import { toast } from '@fileverse/ui'; import { DdocProps, DdocEditorProps, @@ -461,7 +462,6 @@ export const useTabEditor = ({ ...DdocEditorProps, handleDOMEvents: { mousedown: focusSubmittedSuggestionFromEditorEvent, - click: focusSubmittedSuggestionFromEditorEvent, mouseover: (view, event) => { if ( !isEditorViewInsideActiveRoot(view, activeEditorRef.current) @@ -1633,6 +1633,12 @@ const useEditorExtension = ({ storeApiRef.current?.getState().startDeleteDraft(from, to), onPasteLink: (from, to, href) => storeApiRef.current?.getState().startLinkDraft(from, to, href), + onUnsupportedPaste: () => + toast({ + title: + 'Suggestion mode only supports single-line plain text paste.', + toastType: 'mini', + }), onDeleteAtCursor: (direction) => storeApiRef.current ?.getState() diff --git a/package/stores/comment-store-provider.tsx b/package/stores/comment-store-provider.tsx index d859eea0..6a7dffaa 100644 --- a/package/stores/comment-store-provider.tsx +++ b/package/stores/comment-store-provider.tsx @@ -17,6 +17,7 @@ import { CommentAnchor, type CommentAnchorTransactionChange, getCommentAtPosition, + hasResolvableCommentAnchorInState, resolveCommentAnchorPointInState, resolveCommentAnchorRangeInState, resolveCommentAnchorRangeForAnalysis, @@ -42,17 +43,6 @@ import { isRangeDraft, } from './comment-store'; -const hasResolvableCommentAnchorInState = ( - anchor: CommentAnchor, - state: EditorState, -) => { - if (anchor.isSuggestion && anchor.suggestionType === 'add') { - return resolveCommentAnchorPointInState(anchor, state) !== null; - } - - return resolveCommentAnchorRangeInState(anchor, state) !== null; -}; - export interface CommentStoreProviderProps { children: React.ReactNode; editor: Editor | null; @@ -578,6 +568,7 @@ export const CommentStoreProvider = ({ // floating thread without waiting for a separate UI interaction. const updateEditorState = (transaction?: Transaction) => { const state = store.getState(); + state.syncActiveSuggestionDraftAtCursor(); const isMarkActive = editor.isActive('comment'); const activeMarkComment = isMarkActive ? { @@ -812,7 +803,7 @@ export const CommentStoreProvider = ({ const restoredAnchors: CommentAnchor[] = []; removedAnchorsRef.current.forEach((removedAnchor, commentId) => { - if (resolveCommentAnchorRangeInState(removedAnchor, editor.state)) { + if (hasResolvableCommentAnchorInState(removedAnchor, editor.state)) { restoredAnchors.push(removedAnchor); removedAnchorsRef.current.delete(commentId); } @@ -993,11 +984,11 @@ export const CommentStoreProvider = ({ // Undo can restore one removed anchor and still make the // broad history mapping report neighboring anchors as - // deleted. A valid post-transaction range means the highlight + // deleted. A valid post-transaction anchor means the comment // still exists, so do not remove it from the runtime anchor // set. Truly deleted anchors resolve to null and are removed. return currentAnchor - ? !resolveCommentAnchorRangeInState( + ? !hasResolvableCommentAnchorInState( currentAnchor, editor.state, ) @@ -1055,6 +1046,16 @@ export const CommentStoreProvider = ({ }) => { updateEditorState(transaction); }; + const syncActiveSuggestionDraftAtCursor = () => { + store.getState().syncActiveSuggestionDraftAtCursor(); + }; + const clearActiveSuggestionDraftAtCursor = () => { + if (store.getState().activeSuggestionDraftIdAtCursor === null) { + return; + } + + store.setState({ activeSuggestionDraftIdAtCursor: null }); + }; // Keep this effect subscribed to editor-driven changes only. Re-running it // for sidebar/thread focus changes lets stale editor selection win again. @@ -1068,12 +1069,16 @@ export const CommentStoreProvider = ({ // to keep this subscription stable and focused on editor changes only. updateEditorState(); editor.on('beforeTransaction', handleBeforeTransaction); + editor.on('focus', syncActiveSuggestionDraftAtCursor); + editor.on('blur', clearActiveSuggestionDraftAtCursor); editor.on('selectionUpdate', handleSelectionUpdate); editor.on('transaction', handleTransaction); return () => { preTransactionStateRef.current = null; editor.off('beforeTransaction', handleBeforeTransaction); + editor.off('focus', syncActiveSuggestionDraftAtCursor); + editor.off('blur', clearActiveSuggestionDraftAtCursor); editor.off('selectionUpdate', handleSelectionUpdate); editor.off('transaction', handleTransaction); }; diff --git a/package/stores/comment-store.ts b/package/stores/comment-store.ts index 8ba0153d..2208d1d2 100644 --- a/package/stores/comment-store.ts +++ b/package/stores/comment-store.ts @@ -6,6 +6,7 @@ import { applyAcceptedSuggestion, createCommentAnchorFromEditor, createCommentAnchorPointFromEditor, + hasResolvableCommentAnchorInState, resolveCommentAnchorPointInState, resolveCommentAnchorRangeInState, triggerDecorationRebuild, @@ -191,6 +192,7 @@ function isPureDeleteDraft(draft: DraftSuggestion): boolean { type SuggestionDraftStateSetter = (state: { drafts: Record; floatingCards: CommentFloatingCard[]; + activeSuggestionDraftIdAtCursor?: string | null; }) => void; function syncSuggestionDraftState( @@ -198,13 +200,39 @@ function syncSuggestionDraftState( nextDrafts: Record, nextCards: CommentFloatingCard[], draftAnchorsRef?: React.MutableRefObject, + activeSuggestionDraftIdAtCursor?: string | null, ) { - setState({ drafts: nextDrafts, floatingCards: nextCards }); + setState({ + drafts: nextDrafts, + floatingCards: nextCards, + ...(activeSuggestionDraftIdAtCursor !== undefined + ? { activeSuggestionDraftIdAtCursor } + : {}), + }); if (draftAnchorsRef) { draftAnchorsRef.current = Object.values(nextDrafts).map(deriveDraftAnchor); } } +function getSuggestionDraftIdAtFocusedEditorCursor( + editor: Editor | null, + drafts: Record, +): string | null { + if (!editor) { + return null; + } + + try { + if (!editor.view?.hasFocus()) { + return null; + } + } catch { + return null; + } + + return findDraftAtEditorCursor(drafts, editor.state)?.id ?? null; +} + function updateDeleteDraftRange({ setState, drafts, @@ -243,7 +271,13 @@ function updateDeleteDraftRange({ : card, ); - syncSuggestionDraftState(setState, nextDrafts, nextCards, draftAnchorsRef); + syncSuggestionDraftState( + setState, + nextDrafts, + nextCards, + draftAnchorsRef, + activeDraft.id, + ); const tr = editor.state.tr.setSelection( TextSelection.near(editor.state.doc.resolve(collapseTo)), @@ -407,25 +441,6 @@ const getHydratedThreadDecorationAnchor = ({ ); }; -const hasResolvableCommentAnchor = ({ - anchor, - editor, -}: { - anchor: CommentAnchor; - editor: Editor; -}) => { - // Add suggestions are point anchors (anchorFrom === anchorTo); range - // resolution rejects them. Use point resolution as the validity check - // for that case so the auto-spawned thread card isn't reconciled away. - if (anchor.isSuggestion && anchor.suggestionType === 'add') { - return resolveCommentAnchorPointInState(anchor, editor.state) !== null; - } - - const anchorRange = resolveCommentAnchorRangeInState(anchor, editor.state); - - return Boolean(anchorRange && anchorRange.from < anchorRange.to); -}; - const hasValidPendingPrehydrationFloatingThreadAnchor = ({ commentId, decorationAnchorById, @@ -453,7 +468,7 @@ const hasValidPendingPrehydrationFloatingThreadAnchor = ({ decorationAnchor && !decorationAnchor.deleted && !decorationAnchor.resolved && - hasResolvableCommentAnchor({ anchor: decorationAnchor, editor }), + hasResolvableCommentAnchorInState(decorationAnchor, editor.state), ); }; @@ -494,10 +509,7 @@ const hasValidHydratedThreadAnchor = ({ return false; } - return hasResolvableCommentAnchor({ - anchor: decorationAnchor, - editor, - }); + return hasResolvableCommentAnchorInState(decorationAnchor, editor.state); } } @@ -706,6 +718,8 @@ export interface CommentStoreState { pendingPrehydrationFloatingThreadIds: string[]; /** In-progress suggestion drafts — keyed by suggestionId. Viewer-local, lost on refresh. */ drafts: Record; + /** Suggestion draft currently under the focused editor cursor, if any. */ + activeSuggestionDraftIdAtCursor: string | null; inlineDrafts: InlineDraftRecordMap; activeDraftId: string | null; isDesktopFloatingEnabled: boolean; @@ -870,6 +884,11 @@ export interface CommentStoreState { suggestionId: string, currentText: string, ) => void; + /** + * Sync the draft id under the current editor cursor. Each draft card uses + * this to decide whether its own auto-submit countdown should run. + */ + syncActiveSuggestionDraftAtCursor: () => void; /** * Promote a draft to a submitted suggestion. Pushes the anchor into * commentAnchorsRef, calls onNewComment, removes the draft, and swaps the @@ -951,6 +970,7 @@ export const createCommentStore = () => floatingCards: [], pendingPrehydrationFloatingThreadIds: [], drafts: {}, + activeSuggestionDraftIdAtCursor: null, inlineDrafts: {}, activeDraftId: null, isDesktopFloatingEnabled: false, @@ -1231,7 +1251,11 @@ export const createCommentStore = () => nextFloatingCards, ), })), - clearFloatingCards: () => set({ floatingCards: [] }), + clearFloatingCards: () => + set({ + floatingCards: [], + activeSuggestionDraftIdAtCursor: null, + }), setActiveDraftId: (draftId) => set({ activeDraftId: draftId }), setIsDesktopFloatingEnabled: (enabled) => set({ isDesktopFloatingEnabled: enabled }), @@ -1765,21 +1789,19 @@ export const createCommentStore = () => openFloatingThread: (commentId) => { const { editor, setActiveCommentId } = getExtDeps(get); const state = get(); - const commentToOpen = state.tabComments.find( - (comment) => - comment.id === commentId && !comment.deleted && !comment.resolved, - ); + const commentToOpen = + state.tabComments.find( + (comment) => + comment.id === commentId && + !comment.deleted && + !comment.resolved && + (comment.isSuggestion || Boolean(comment.selectedContent)), + ) ?? null; if (!editor || !commentToOpen) { return; } - // Add suggestions have empty selectedContent (point anchor), so the old - // `!commentToOpen.selectedContent` guard would incorrectly bail out here. - if (!commentToOpen.isSuggestion && !commentToOpen.selectedContent) { - return; - } - const nextFloatingCards = upsertFloatingThreadCard(state.floatingCards, { commentId, selectedText: commentToOpen.selectedContent || '', @@ -2515,6 +2537,7 @@ export const createCommentStore = () => let nextDrafts: Record; let nextCards = get().floatingCards; + let activeSuggestionDraftIdAtCursor: string; if (activeDraft) { const extended: DraftSuggestion = { @@ -2529,6 +2552,7 @@ export const createCommentStore = () => ? { ...c, insertedText: extended.insertedText } : c, ); + activeSuggestionDraftIdAtCursor = activeDraft.id; } else { const { from } = editor.state.selection; const anchorPoint = createCommentAnchorPointFromEditor(editor, from); @@ -2558,9 +2582,14 @@ export const createCommentStore = () => ...nextCards.map((c) => ({ ...c, isFocused: false })), newCard, ]; + activeSuggestionDraftIdAtCursor = id; } - set({ drafts: nextDrafts, floatingCards: nextCards }); + set({ + drafts: nextDrafts, + floatingCards: nextCards, + activeSuggestionDraftIdAtCursor, + }); if (draftAnchorsRef) { draftAnchorsRef.current = Object.values(nextDrafts).map(deriveDraftAnchor); @@ -2607,7 +2636,7 @@ export const createCommentStore = () => newCard, ]; - syncSuggestionDraftState(set, nextDrafts, nextCards, draftAnchorsRef); + syncSuggestionDraftState(set, nextDrafts, nextCards, draftAnchorsRef, id); // Collapse the selection at the requested caret position so Backspace // stays at the end of the deleted range while forward Delete remains at @@ -2661,7 +2690,7 @@ export const createCommentStore = () => newCard, ]; - syncSuggestionDraftState(set, nextDrafts, nextCards, draftAnchorsRef); + syncSuggestionDraftState(set, nextDrafts, nextCards, draftAnchorsRef, id); triggerDecorationRebuild(editor); }, @@ -2789,7 +2818,11 @@ export const createCommentStore = () => : c, ); - set({ drafts: nextDrafts, floatingCards: nextCards }); + set({ + drafts: nextDrafts, + floatingCards: nextCards, + activeSuggestionDraftIdAtCursor: activeDraft.id, + }); if (draftAnchorsRef) { draftAnchorsRef.current = Object.values(nextDrafts).map(deriveDraftAnchor); @@ -2812,7 +2845,14 @@ export const createCommentStore = () => !(c.type === 'suggestion-draft' && c.suggestionId === suggestionId), ); - set({ drafts: nextDrafts, floatingCards: nextCards }); + set({ + drafts: nextDrafts, + floatingCards: nextCards, + activeSuggestionDraftIdAtCursor: + get().activeSuggestionDraftIdAtCursor === suggestionId + ? null + : get().activeSuggestionDraftIdAtCursor, + }); if (draftAnchorsRef) { draftAnchorsRef.current = Object.values(nextDrafts).map(deriveDraftAnchor); @@ -2846,6 +2886,24 @@ export const createCommentStore = () => } }, + syncActiveSuggestionDraftAtCursor: () => { + const { editor } = getExtDeps(get); + const nextSuggestionDraftIdAtCursor = + getSuggestionDraftIdAtFocusedEditorCursor(editor, get().drafts); + const currentState = get(); + + if ( + currentState.activeSuggestionDraftIdAtCursor === + nextSuggestionDraftIdAtCursor + ) { + return; + } + + set({ + activeSuggestionDraftIdAtCursor: nextSuggestionDraftIdAtCursor, + }); + }, + submitDraft: (suggestionId) => { const deps = getExtDeps(get); const { editor, commentAnchorsRef, draftAnchorsRef, onNewComment } = deps; @@ -2894,17 +2952,18 @@ export const createCommentStore = () => delete nextDrafts[suggestionId]; const nextCards = get().floatingCards.map((c) => { - if (c.type !== 'suggestion-draft' || c.suggestionId !== suggestionId) { - return c; + if (c.type === 'suggestion-draft' && c.suggestionId === suggestionId) { + const threadCard: CommentFloatingThreadCard = { + type: 'thread', + floatingCardId: c.floatingCardId, + commentId: draftToSubmit.id, + selectedText: draftToSubmit.originalContent, + isFocused: c.isFocused, + }; + return threadCard; } - const threadCard: CommentFloatingThreadCard = { - type: 'thread', - floatingCardId: c.floatingCardId, - commentId: draftToSubmit.id, - selectedText: draftToSubmit.originalContent, - isFocused: c.isFocused, - }; - return threadCard; + + return c; }); // Build IComment now so we can pre-populate local state and avoid a @@ -2929,6 +2988,10 @@ export const createCommentStore = () => set({ drafts: nextDrafts, floatingCards: nextCards, + activeSuggestionDraftIdAtCursor: + get().activeSuggestionDraftIdAtCursor === suggestionId + ? null + : get().activeSuggestionDraftIdAtCursor, }); if (draftAnchorsRef) { draftAnchorsRef.current = @@ -3237,6 +3300,7 @@ export const createCommentStore = () => state.floatingCards, `suggestion-draft:${suggestionId}`, ), + activeSuggestionDraftIdAtCursor: suggestionId, })); requestAnimationFrame(() => {