Enhance task input highlighting with smooth appearance animation and improved CSS keyframes for better visual feedback.

This commit is contained in:
Mo Tarbin
2026-08-09 13:19:32 -04:00
parent 2442b0c441
commit 309fd32d50
2 changed files with 68 additions and 8 deletions

View File

@@ -224,10 +224,35 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
const [taskText, setTaskText] = useState('')
const [taskTitle, setTaskTitle] = useState('')
// Highlight spans paired with the text they were computed from: the parse is
// debounced, so the input falls back to plain text whenever these are stale
// 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
@@ -239,6 +264,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
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)
@@ -474,6 +502,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)
@@ -509,10 +538,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(
<span
key={highlight.start}
className={className}
className={`${className}${isNewHighlight ? ' highlight-appear' : ''}`}
style={{
textDecoration: 'underline',
textDecorationThickness: '2px',
@@ -525,6 +557,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
lastIndex = highlight.end
}
prevHighlightKeysRef.current = seenHighlightKeys
if (lastIndex < sentence.length) {
const remainingText = sentence.substring(lastIndex)
@@ -940,6 +973,9 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
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)
@@ -1230,11 +1266,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
setTaskText(text)
if (!text) setTaskTitle('')
}}
customRenderer={
renderedParts.text === taskText
? renderedParts.parts
: [taskText]
}
customRenderer={displayedParts}
onEnterPressed={handleEnterPressed}
onShiftEnterPressed={() => {
if (!hasDescription) {

View File

@@ -57,6 +57,34 @@
color: var(--highlight-label-color);
}
/* Played once when a token is first detected: the dashed underline fades in
while a soft tint of the token's own color flashes and clears. Keyframe
values override the span's inline text-decoration while running. */
@keyframes smart-highlight-in {
from {
text-decoration-color: transparent;
background-color: color-mix(in srgb, currentColor 22%, transparent);
}
60% {
background-color: color-mix(in srgb, currentColor 12%, transparent);
}
to {
text-decoration-color: currentColor;
background-color: transparent;
}
}
.highlight-appear {
border-radius: 4px;
animation: smart-highlight-in 450ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.highlight-appear {
animation: none;
}
}
.task-input {
position: relative;
width: 100%;