diff --git a/src/constants/zIndex.js b/src/constants/zIndex.js index cc41e4d..59ab5e1 100644 --- a/src/constants/zIndex.js +++ b/src/constants/zIndex.js @@ -22,6 +22,8 @@ export const Z_INDEX = { MODAL_BACKDROP: 2000, MODAL_CONTENT: 2001, MODAL_CLOSE_BUTTON: 2002, + // Popups that must float above open modals (portaled to document.body) + MODAL_POPOVER: 2100, TOAST: 3000, // Critical System UI (9000-9999) diff --git a/src/views/TestView/AutocompleteDropdown.jsx b/src/views/TestView/AutocompleteDropdown.jsx index 685800e..aeb77a9 100644 --- a/src/views/TestView/AutocompleteDropdown.jsx +++ b/src/views/TestView/AutocompleteDropdown.jsx @@ -3,14 +3,16 @@ import { Add } from '@mui/icons-material' import { Divider, Menu, MenuItem } from '@mui/joy' import React, { useEffect } from 'react' +import { Z_INDEX } from '../../constants/zIndex' + const AutocompleteDropdown = ({ currentValue, - suggestions, - selectedIndex, - onSelectSuggestion, - onMouseEnterSuggestion, // Added for hover selection onCreateSuggestion, // Called when the "Create new" row is chosen + onMouseEnterSuggestion, // Added for hover selection + onSelectSuggestion, parentRefer, // Ref to the dropdown element + selectedIndex, + suggestions, }) => { // Scroll selected item into view const dropdownMenuRef = React.useRef(null) @@ -60,7 +62,7 @@ const AutocompleteDropdown = ({ position: 'relative', bottom: 0, left: 0, - zIndex: 1300, + zIndex: Z_INDEX.MODAL_POPOVER, }} > {filteredOptions.map((option, index) => ( diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 500f7a9..255fb7d 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query' import * as chrono from 'chrono-node' import moment from 'moment' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { flushSync } from 'react-dom' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import ModalActions from '../../components/common/ModalActions' @@ -37,7 +38,6 @@ import { } from './CustomParsers' import DueDatePickerField from './DueDatePickerField' import LabelsPickerField from './LabelsPickerField' -import LearnMoreButton from './LearnMore' import NotificationPickerField from './NotificationPickerField' import PriorityPickerField from './PriorityPickerField' import RepeatPickerField from './RepeatPickerField' @@ -108,6 +108,66 @@ const getDefaultNotification = () => { return DEFAULT_NOTIFICATION_TEMPLATES } +// Get initial project from localStorage (current active project) +const getInitialProject = () => { + const saved = localStorage.getItem('selectedProject') + if (saved) { + try { + const project = JSON.parse(saved) + return project?.id || 'default' + } catch { + return 'default' + } + } + return 'default' +} + +const PRIORITY_COLORS = { + 0: TASK_COLOR.NO_PRIORITY, + 1: TASK_COLOR.PRIORITY_1, + 2: TASK_COLOR.PRIORITY_2, + 3: TASK_COLOR.PRIORITY_3, + 4: TASK_COLOR.PRIORITY_4, +} + +const PRIORITY_LABELS = { + 0: '--', + 1: 'P1', + 2: 'P2', + 3: 'P3', + 4: 'P4', +} + +// Static option sets for the smart input's trigger suggestions +const PRIORITY_SUGGESTIONS = { + value: 'id', + display: 'name', + options: [ + { id: '1', name: 'P1' }, + { id: '2', name: 'P2' }, + { id: '3', name: 'P3' }, + { id: '4', name: 'P4' }, + ], +} + +const POINTS_SUGGESTIONS = { + value: 'id', + display: 'name', + options: [ + { id: '1', name: '1 point' }, + { id: '5', name: '5 points' }, + { id: '10', name: '10 points' }, + { id: '25', name: '25 points' }, + { id: '50', name: '50 points' }, + { id: '100', name: '100 points' }, + ], +} + +// Delay between the last keystroke and the smart-input parse. Parsing (chrono +// especially) is too heavy to run per keystroke; submitChore flushes a pending +// parse so a fast type-then-Enter never creates from stale parsed state. +const PARSE_DEBOUNCE_MS = 150 + const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { const { ResponsiveModal } = useResponsiveModal() const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm')) @@ -138,38 +198,85 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { [queryClient], ) - // Get initial project from localStorage (current active project) - const getInitialProject = () => { - const saved = localStorage.getItem('selectedProject') - if (saved) { - try { - const project = JSON.parse(saved) - return project?.id || 'default' - } catch { - return 'default' - } - } - return 'default' - } + const smartInputSuggestions = useMemo( + () => ({ + '#': { + value: 'id', + display: 'name', + options: userLabels || [], + creatable: true, + onCreate: handleCreateLabel, + }, + '!': PRIORITY_SUGGESTIONS, + '@': { + value: 'userId', + display: 'displayName', + options: [ + { userId: 'anyone', displayName: 'Anyone' }, + ...(circleMembers?.res || []), + ], + }, + '*': POINTS_SUGGESTIONS, + }), + [userLabels, circleMembers, handleCreateLabel], + ) const [taskText, setTaskText] = useState('') const [taskTitle, setTaskTitle] = useState('') - const [renderedParts, setRenderedParts] = useState([]) + // Highlight spans paired with the text they were computed from: the parse + // is debounced, so while typing these lag behind taskText + const [renderedParts, setRenderedParts] = useState({ text: '', parts: [] }) + + // What the smart input overlay shows. While a parse is pending, keep every + // highlight span that precedes the edit point and render the rest as plain + // text — existing token styles must not flicker away on each keystroke. + const displayedParts = useMemo(() => { + const { parts, text } = renderedParts + if (text === taskText) return parts + + let prefixLen = 0 + const max = Math.min(text.length, taskText.length) + while (prefixLen < max && text[prefixLen] === taskText[prefixLen]) { + prefixLen++ + } + + const kept = [] + let consumed = 0 + for (const part of parts) { + const partText = typeof part === 'string' ? part : part.props.children + if (consumed + partText.length > prefixLen) break + kept.push(part) + consumed += partText.length + } + kept.push(taskText.slice(consumed)) + return kept + }, [renderedParts, taskText]) const richTextEditorRef = useRef(null) const latestRef = useRef({}) // Picker edits made on a voice task card, applied once after the reparse // that follows landing the spoken text in the smart input const pendingVoiceOverridesRef = useRef(null) + // True while the current assignees came from an @mention in the text, so a + // reparse without mentions only resets what a mention set — never a + // selection made directly in the assignee picker + const assigneesFromMentionRef = useRef(false) + // Pending debounced parse of the smart input text, if any + const parseTimerRef = useRef(null) + // Identities (type + text) of the highlights from the previous parse, so + // the appear animation only plays for tokens detected just now + const prevHighlightKeysRef = useRef(new Set()) const [priority, setPriority] = useState(0) const [dueDate, setDueDate] = useState(null) const [description, setDescription] = useState(null) const [assignees, setAssignees] = useState([]) const [labelsV2, setLabelsV2] = useState([]) const [frequency, setFrequency] = useState(null) - const [notificationMetadata, setNotificationMetadata] = useState({ + // Lazy initializers: these read localStorage, which must not happen on + // every render + const [notificationMetadata, setNotificationMetadata] = useState(() => ({ templates: getDefaultNotification(), - }) + })) const [subTasks, setSubTasks] = useState(null) const [points, setPoints] = useState(-1) const [isAnyoneTask, setIsAnyoneTask] = useState(false) @@ -185,7 +292,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { const [dueTime, setDueTime] = useState(null) const [useCustomTime, setUseCustomTime] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) - const [projectId, setProjectId] = useState(getInitialProject()) + const [projectId, setProjectId] = useState(getInitialProject) const [attachments, setAttachments] = useState([]) const [draftId, setDraftId] = useState(() => generateUUID()) @@ -251,23 +358,6 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { } }, [isModalOpen, initialMode, voiceAvailable, llmAvailable]) - // Priority colors - const priorityColors = { - 0: TASK_COLOR.NO_PRIORITY, - 1: TASK_COLOR.PRIORITY_1, - 2: TASK_COLOR.PRIORITY_2, - 3: TASK_COLOR.PRIORITY_3, - 4: TASK_COLOR.PRIORITY_4, - } - - const priorityLabels = { - 0: '--', - 1: 'P1', - 2: 'P2', - 3: 'P3', - 4: 'P4', - } - // set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key: useEffect(() => { if (hasDescription && richTextEditorRef.current) { @@ -281,11 +371,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { useEffect(() => { const handleKeyDown = event => { const { - createChore, dueDate, handleCloseModal, hasDescription, isModalOpen, + submitChore, } = latestRef.current const isHoldingCmd = event.ctrlKey || event.metaKey if (isHoldingCmd) { @@ -323,7 +413,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { isModalOpen ) { event.preventDefault() - createChore() + submitChore() return } if (event.key === 'Escape' && isModalOpen) { @@ -411,6 +501,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { } } + const seenHighlightKeys = new Set() for (const highlight of resolvedHighlights) { if (highlight.start > lastIndex) { const textBefore = sentence.substring(lastIndex, highlight.start) @@ -446,10 +537,13 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { highlight.start, highlight.end, ) + const highlightKey = `${highlight.type}:${highlightedText.toLowerCase()}` + const isNewHighlight = !prevHighlightKeysRef.current.has(highlightKey) + seenHighlightKeys.add(highlightKey) parts.push( { lastIndex = highlight.end } + prevHighlightKeysRef.current = seenHighlightKeys if (lastIndex < sentence.length) { const remainingText = sentence.substring(lastIndex) @@ -477,14 +572,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { [], ) - const processText = useCallback( - sentence => { - const priority = parsePriority(sentence) - const pointsParsed = parsePoints(sentence) - const labels = parseLabels(sentence, userLabels || []) - - const circleMembersList = circleMembers?.res || [] - const assigneesForParsing = circleMembersList.map(member => ({ + // Rebuilt only when the member list actually changes, so a query refetch + // with identical data doesn't re-trigger the parse effect below + const assigneesForParsing = useMemo( + () => + (circleMembers?.res || []).map(member => ({ userId: member.userId, username: member.username || @@ -492,7 +584,15 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { displayName: member.displayName, name: member.displayName, id: member.userId, - })) + })), + [circleMembers], + ) + + const processText = useCallback( + sentence => { + const priority = parsePriority(sentence) + const pointsParsed = parsePoints(sentence) + const labels = parseLabels(sentence, userLabels || []) const assigneesResult = parseAssignees(sentence, assigneesForParsing) const repeat = parseRepeatV2(sentence) @@ -510,14 +610,18 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { // @Anyone was used - set empty assignees (anyone can do the task) setIsAnyoneTask(true) setAssignees([]) + assigneesFromMentionRef.current = true } else if (assigneesResult.result && assigneesResult.result.length > 0) { setIsAnyoneTask(false) const parsedAssignees = assigneesResult.result.map(assignee => ({ userId: assignee.userId, })) setAssignees(parsedAssignees) - } else { - // Only assign to current user if no @ mentions found and userProfile exists + assigneesFromMentionRef.current = true + } else if (assigneesFromMentionRef.current) { + // The @mention that set the current assignees was deleted — fall back + // to the implicit self default. Picker selections stay untouched. + assigneesFromMentionRef.current = false setIsAnyoneTask(false) if (userProfile?.id) { setAssignees([ @@ -555,39 +659,47 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { syncDueDateStates(repeat.dueDate) } - // Create the cleaned sentence by sequentially applying all cleanups + // Create the cleaned sentence by sequentially applying all cleanups. + // Each stage only needs a reparse when an earlier cleanup actually + // changed the sentence; otherwise the first-pass result (computed on the + // identical string) is reused as-is. let cleanedSentence = sentence if (priority.result) cleanedSentence = priority.cleanedSentence if (pointsParsed.result) { - // Apply points cleaning to the current cleaned sentence - const pointsReparse = parsePoints(cleanedSentence) + const pointsReparse = + cleanedSentence === sentence + ? pointsParsed + : parsePoints(cleanedSentence) if (pointsReparse.result) cleanedSentence = pointsReparse.cleanedSentence } if (labels.result) { - // Apply labels cleaning to the current cleaned sentence - const labelsReparse = parseLabels(cleanedSentence, userLabels || []) + const labelsReparse = + cleanedSentence === sentence + ? labels + : parseLabels(cleanedSentence, userLabels || []) if (labelsReparse.result) cleanedSentence = labelsReparse.cleanedSentence } if (assigneesResult.result) { - // Apply assignees cleaning to the current cleaned sentence - const assigneesReparse = parseAssignees( - cleanedSentence, - assigneesForParsing, - ) + const assigneesReparse = + cleanedSentence === sentence + ? assigneesResult + : parseAssignees(cleanedSentence, assigneesForParsing) if (assigneesReparse.result) cleanedSentence = assigneesReparse.cleanedSentence } if (repeat.result) { - // Apply repeat cleaning to the current cleaned sentence - const repeatReparse = parseRepeatV2(cleanedSentence) + const repeatReparse = + cleanedSentence === sentence ? repeat : parseRepeatV2(cleanedSentence) if (repeatReparse.result) cleanedSentence = repeatReparse.cleanedSentence } if (dueDateParsed.result) { - // Apply date cleaning to the current cleaned sentence - const dueDateReparse = parseDueDate(cleanedSentence, chrono) + const dueDateReparse = + cleanedSentence === sentence + ? dueDateParsed + : parseDueDate(cleanedSentence, chrono) if (dueDateReparse.result) cleanedSentence = dueDateReparse.cleanedSentence } @@ -606,7 +718,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { assigneesResult.highlight, ) - setRenderedParts(parts) + setRenderedParts({ text: sentence, parts }) const overrides = pendingVoiceOverridesRef.current if (overrides) { @@ -622,6 +734,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { if ('assignees' in overrides || 'isAnyone' in overrides) { setIsAnyoneTask(!!overrides.isAnyone) setAssignees(overrides.assignees || []) + assigneesFromMentionRef.current = false } if ('dueDate' in overrides) { if (overrides.dueDate) { @@ -635,7 +748,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { } } }, - [userLabels, renderHighlightedSentence, circleMembers, userProfile], + [userLabels, renderHighlightedSentence, assigneesForParsing, userProfile], ) useEffect(() => { @@ -648,7 +761,16 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { return } - processText(taskText) + // Debounced so fast typing doesn't run the full parse pipeline per + // keystroke; submitChore flushes a pending parse before creating. + parseTimerRef.current = setTimeout(() => { + parseTimerRef.current = null + processText(taskText) + }, PARSE_DEBOUNCE_MS) + return () => { + clearTimeout(parseTimerRef.current) + parseTimerRef.current = null + } }, [ taskText, userLabelsLoading, @@ -713,7 +835,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { } const handleEnterPressed = () => { - createChore() + submitChore() } // The scan keeps its source image when asked: upload it against the draft so @@ -849,6 +971,10 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { setHasSubTasks(false) setLabelsV2([]) setAssignees([]) + assigneesFromMentionRef.current = false + // The modal closes without a final parse, so drop the highlight identities + // here or nothing would animate on the next open + prevHighlightKeysRef.current = new Set() setProjectId(getInitialProject()) setDeadlineOffset(-1) setRequireApproval(false) @@ -954,11 +1080,26 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { handleCloseModal(false) } + // All submit paths (Enter, Cmd+Enter, footer button) go through here: a + // debounce may still be holding the parse of the latest text, and creating + // from pre-parse state would drop the tail of what the user typed. + const submitChore = () => { + if (parseTimerRef.current) { + clearTimeout(parseTimerRef.current) + parseTimerRef.current = null + flushSync(() => processText(taskText)) + } + // Read through latestRef: after the flush, this render's createChore + // closure is stale + latestRef.current.createChore() + } + latestRef.current = { isModalOpen, hasDescription, dueDate, createChore, + submitChore, handleCloseModal, } @@ -1028,7 +1169,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { color='primary' loading={isAttachingScan} disabled={!taskTitle.trim() || isAttachingScan} - onClick={createChore} + onClick={submitChore} > Create {showKeyboardShortcuts && ( @@ -1041,8 +1182,8 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { > {!showScan && !showVoice && ( <> - - + {/* { } /> - + */} { setTaskText(text) if (!text) setTaskTitle('') }} - customRenderer={renderedParts} + customRenderer={displayedParts} onEnterPressed={handleEnterPressed} onShiftEnterPressed={() => { if (!hasDescription) { @@ -1132,45 +1273,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { } setTimeout(() => richTextEditorRef.current?.focus(), 50) }} - suggestions={{ - '#': { - value: 'id', - display: 'name', - options: userLabels ? userLabels : [], - creatable: true, - onCreate: handleCreateLabel, - }, - '!': { - value: 'id', - display: 'name', - options: [ - { id: '1', name: 'P1' }, - { id: '2', name: 'P2' }, - { id: '3', name: 'P3' }, - { id: '4', name: 'P4' }, - ], - }, - '@': { - value: 'userId', - display: 'displayName', - options: [ - { userId: 'anyone', displayName: 'Anyone' }, - ...(circleMembers?.res || []), - ], - }, - '*': { - value: 'id', - display: 'name', - options: [ - { id: '1', name: '1 point' }, - { id: '5', name: '5 points' }, - { id: '10', name: '10 points' }, - { id: '25', name: '25 points' }, - { id: '50', name: '50 points' }, - { id: '100', name: '100 points' }, - ], - }, - }} + suggestions={smartInputSuggestions} /> @@ -1212,8 +1315,8 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { onChange={setPriority} onClear={() => setPriority(0)} emptyDisplay={pickerEmptyDisplay} - priorityColors={priorityColors} - priorityLabels={priorityLabels} + priorityColors={PRIORITY_COLORS} + priorityLabels={PRIORITY_LABELS} /> { }} > - - Description - + Description )} {!hasSubTasks && ( @@ -1317,9 +1418,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => { }} > - - Subtasks - + Subtasks )} { onAssignStrategyChange={setAssignStrategy} hasDueDate={!!dueDate} hasMultipleAssignees={assignees.length > 1} - hasAssignees={assignees.length > 0} + // Empty assignees still implicitly assigns the current user at + // create time; only an "Anyone" task truly has no assignee + hasAssignees={!isAnyoneTask} isPrivate={isPrivate} onIsPrivateChange={setIsPrivate} /> diff --git a/src/views/components/AdvancedOptionsSection.jsx b/src/views/components/AdvancedOptionsSection.jsx index 11801eb..e31b019 100644 --- a/src/views/components/AdvancedOptionsSection.jsx +++ b/src/views/components/AdvancedOptionsSection.jsx @@ -90,7 +90,6 @@ export const AdvancedOptionsTrigger = ({ { - // if the last word start with '@' or '#' or 'P': - const lastWord = text.split(' ').pop() - if ( - lastWord.startsWith('@') || - lastWord.startsWith('#') || - lastWord.startsWith('!') - ) { + // show the menu when the last word starts with a configured trigger + // character (e.g. '@', '#', '!', '*') + const lastWord = text.split(/\s+/).pop() + if (lastWord && suggestions?.[lastWord[0]]) { setSuggestionTrigger(lastWord[0]) // last word without the first character: setLastWord(lastWord.slice(1)) - + setSelectedSuggestionIndex(0) setShowSuggestions(true) } else { setShowSuggestions(false) @@ -121,6 +118,7 @@ const SmartTaskTitleInput = ({ const newCursorPosition = cursorPosition - lastWord.length + suggestionValue.length + 1 + setCursorPosition(newCursorPosition) titleInputRef.current.setSelectionRange( newCursorPosition, newCursorPosition, @@ -376,18 +374,10 @@ const SmartTaskTitleInput = ({ const suggestionValue = suggestions[suggestionTrigger].display ? suggestion[suggestions[suggestionTrigger].display] : suggestion - const newValue = `${value.slice(0, cursorPosition)}${suggestionValue}${value.slice(cursorPosition)}` - - onChange(newValue) + // Same insertion path as keyboard selection: replace the partial + // word typed after the trigger instead of inserting alongside it titleInputRef?.current?.focus() - - setCursorPosition(cursorPosition + suggestion.length) - titleInputRef.current.value = newValue - titleInputRef.current.setSelectionRange( - cursorPosition + suggestionValue.length, - cursorPosition + suggestionValue.length, - ) - setShowSuggestions(false) + selectSuggestionText(suggestionValue) }} onCreateSuggestion={name => { selectSuggestionText(name)