Ability to have open OCR from the field

This commit is contained in:
Mo Tarbin
2026-07-04 15:22:24 -04:00
parent 3c0f4402a8
commit 4460d80b72
5 changed files with 921 additions and 263 deletions

View File

@@ -1,4 +1,4 @@
import { Add, CameraAlt } from '@mui/icons-material' import { Add } from '@mui/icons-material'
import { Box, Button, Typography } from '@mui/joy' import { Box, Button, Typography } from '@mui/joy'
import { useMediaQuery } from '@mui/material' import { useMediaQuery } from '@mui/material'
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
@@ -28,7 +28,8 @@ import DueDatePickerField from './DueDatePickerField'
import LabelsPickerField from './LabelsPickerField' import LabelsPickerField from './LabelsPickerField'
import LearnMoreButton from './LearnMore' import LearnMoreButton from './LearnMore'
import NotificationPickerField from './NotificationPickerField' import NotificationPickerField from './NotificationPickerField'
import PhotoTaskModal from './PhotoTaskModal' import ScanPanel from './ScanToTask/ScanPanel'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import PriorityPickerField from './PriorityPickerField' import PriorityPickerField from './PriorityPickerField'
import RepeatPickerField from './RepeatPickerField' import RepeatPickerField from './RepeatPickerField'
import RichTextEditor from './RichTextEditor' import RichTextEditor from './RichTextEditor'
@@ -104,7 +105,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [projectId, setProjectId] = useState(getInitialProject()) const [projectId, setProjectId] = useState(getInitialProject())
const [attachments, setAttachments] = useState([]) const [attachments, setAttachments] = useState([])
const [photoModalOpen, setPhotoModalOpen] = useState(false) const [showScan, setShowScan] = useState(false)
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
const { isNativeScanner } = useDocumentScanner()
// Priority colors // Priority colors
const priorityColors = { const priorityColors = {
@@ -564,6 +567,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
const handleCloseModal = forceRefetch => { const handleCloseModal = forceRefetch => {
onClose(forceRefetch) onClose(forceRefetch)
setShowScan(false)
setTaskText('') setTaskText('')
setTaskTitle('') setTaskTitle('')
setDueDate(null) setDueDate(null)
@@ -730,263 +734,261 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
</Box> </Box>
} }
> >
<Box> {!showScan && (
<Box <>
sx={{ <Box>
display: 'flex', <Box
flexDirection: 'row', sx={{
alignItems: 'center', display: 'flex',
}} flexDirection: 'row',
> alignItems: 'center',
<Typography level='body-sm'>Task in a sentence:</Typography> }}
<LearnMoreButton >
content={ <Typography level='body-sm'>Task in a sentence:</Typography>
<> <LearnMoreButton
<Typography level='body-sm' sx={{ mb: 1 }}> content={
This feature lets you create a task simply by typing a <>
sentence. It attempt parses the sentence to identify the <Typography level='body-sm' sx={{ mb: 1 }}>
task&apos;s due date, priority, and frequency. This feature lets you create a task simply by typing a
</Typography> sentence. It attempt parses the sentence to identify the
task&apos;s due date, priority, and frequency.
</Typography>
<Typography level='body-sm' sx={{ fontWeight: 'bold', mt: 2 }}> <Typography level='body-sm' sx={{ fontWeight: 'bold', mt: 2 }}>
Examples: Examples:
</Typography> </Typography>
<Typography <Typography
level='body-sm' level='body-sm'
component='ul' component='ul'
sx={{ pl: 2, mt: 1, listStyle: 'disc' }} sx={{ pl: 2, mt: 1, listStyle: 'disc' }}
> >
<li> <li>
<strong>Priority:</strong>For highest priority any of the <strong>Priority:</strong>For highest priority any of the
following keyword <em>P1</em>, <em>Urgent</em>,{' '} following keyword <em>P1</em>, <em>Urgent</em>,{' '}
<em>Important</em>, or <em>ASAP</em>. For lower priorities, <em>Important</em>, or <em>ASAP</em>. For lower
use <em>P2</em>, <em>P3</em>, or <em>P4</em>. priorities, use <em>P2</em>, <em>P3</em>, or <em>P4</em>.
</li> </li>
<li> <li>
<strong>Due date:</strong> Specify dates with phrases like{' '} <strong>Due date:</strong> Specify dates with phrases
<em>tomorrow</em>, <em>next week</em>, <em>Monday</em>, or{' '} like <em>tomorrow</em>, <em>next week</em>,{' '}
<em>August 1st at 12pm</em>. <em>Monday</em>, or <em>August 1st at 12pm</em>.
</li> </li>
<li> <li>
<strong>Frequency:</strong> Set recurring tasks with terms <strong>Frequency:</strong> Set recurring tasks with
like <em>daily</em>, <em>weekly</em>, <em>monthly</em>,{' '} terms like <em>daily</em>, <em>weekly</em>,{' '}
<em>yearly</em>, or patterns such as{' '} <em>monthly</em>, <em>yearly</em>, or patterns such as{' '}
<em>every Tuesday and Thursday</em>. <em>every Tuesday and Thursday</em>.
</li> </li>
</Typography> </Typography>
</> </>
} }
/> />
</Box> </Box>
<SmartTaskTitleInput <SmartTaskTitleInput
autoFocus autoFocus
value={taskText} value={taskText}
placeholder='Type your task...' isNativeScanner={isNativeScanner}
onChange={text => { onScanClick={() => setShowScan(true)}
setTaskText(text) onPhotoSelected={dataUrl => {
if (!text) setTaskTitle('') setPendingPhotoUrl(dataUrl)
}} setShowScan(true)
customRenderer={renderedParts} }}
onEnterPressed={handleEnterPressed} placeholder='Type your task...'
suggestions={{ onChange={text => {
'#': { setTaskText(text)
value: 'id', if (!text) setTaskTitle('')
display: 'name', }}
options: userLabels ? userLabels : [], customRenderer={renderedParts}
}, onEnterPressed={handleEnterPressed}
'!': { suggestions={{
value: 'id', '#': {
display: 'name', value: 'id',
options: [ display: 'name',
{ id: '1', name: 'P1' }, options: userLabels ? userLabels : [],
{ id: '2', name: 'P2' }, },
{ id: '3', name: 'P3' }, '!': {
{ id: '4', name: 'P4' }, value: 'id',
], display: 'name',
}, options: [
'@': { { id: '1', name: 'P1' },
value: 'userId', { id: '2', name: 'P2' },
display: 'displayName', { id: '3', name: 'P3' },
options: [ { id: '4', name: 'P4' },
{ userId: 'anyone', displayName: 'Anyone' }, ],
...(circleMembers?.res || []), },
], '@': {
}, value: 'userId',
'*': { display: 'displayName',
value: 'id', options: [
display: 'name', { userId: 'anyone', displayName: 'Anyone' },
options: [ ...(circleMembers?.res || []),
{ id: '1', name: '1 point' }, ],
{ id: '5', name: '5 points' }, },
{ id: '10', name: '10 points' }, '*': {
{ id: '25', name: '25 points' }, value: 'id',
{ id: '50', name: '50 points' }, display: 'name',
{ id: '100', name: '100 points' }, options: [
], { id: '1', name: '1 point' },
}, { id: '5', name: '5 points' },
}} { id: '10', name: '10 points' },
/> { id: '25', name: '25 points' },
</Box> { id: '50', name: '50 points' },
<Box { id: '100', name: '100 points' },
sx={{ ],
paddingTop: 2, },
paddingBottom: 1, }}
display: 'flex',
flexDirection: 'row',
gap: 1.5,
// scrollable horizontally but hide the scrollbar:
overflowX: 'auto',
'&::-webkit-scrollbar': {
display: 'none',
},
// if not mobile then go to next line if not enough space( show chip on next line):
flexWrap: isMobile ? 'nowrap' : 'wrap',
}}
>
<DueDatePickerField
emptyDisplay={pickerEmptyDisplay}
dueDateOnly={dueDateOnly}
dueTime={dueTime}
useCustomTime={useCustomTime}
onDueDateChange={handleDueDateChange}
onDueTimeChange={handleDueTimeChange}
onUseCustomTimeChange={handleUseCustomTimeChange}
onClear={() => {
setDueDate(null)
setDueDateOnly(null)
setDueTime(null)
setUseCustomTime(false)
}}
/>
<RepeatPickerField
emptyDisplay={pickerEmptyDisplay}
value={frequency}
onChange={setFrequency}
onClear={() => setFrequency(null)}
/>
<PriorityPickerField
value={priority}
onChange={setPriority}
onClear={() => setPriority(0)}
emptyDisplay={pickerEmptyDisplay}
priorityColors={priorityColors}
priorityLabels={priorityLabels}
/>
<AssigneePickerField
emptyDisplay={pickerEmptyDisplay}
value={assignees?.[0]?.userId || null}
onChange={userId => {
if (!userId) {
setAssignees([])
} else {
setAssignees([{ userId }])
}
}}
onClear={() => setAssignees([])}
currentUserId={userProfile?.id}
members={circleMembers?.res || []}
/>
<LabelsPickerField
emptyDisplay={pickerEmptyDisplay}
values={labelsV2 || []}
onChange={setLabelsV2}
onClear={() => setLabelsV2([])}
labels={userLabels || []}
/>
<AttachmentPickerField
attachments={attachments}
onChange={setAttachments}
onClear={() => setAttachments([])}
emptyDisplay={pickerEmptyDisplay}
entityType='chore_attachment'
/>
<NotificationPickerField
value={notificationMetadata}
onChange={setNotificationMetadata}
onClear={() => setNotificationMetadata({ templates: [] })}
emptyDisplay={pickerEmptyDisplay}
/>
</Box>
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
{!hasDescription && (
<Button
startDecorator={<Add />}
variant='outlined'
color='neutral'
size='md'
onClick={() => {
setHasDescription(true)
// Focus will be handled by the useEffect hook
}}
endDecorator={
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='E' />
}
>
Description
</Button>
)}
<Button
startDecorator={<CameraAlt />}
variant='plain'
size='sm'
onClick={() => setPhotoModalOpen(true)}
>
Scan Photo
</Button>
{!hasSubTasks && (
<Button
startDecorator={<Add />}
variant='outlined'
color='neutral'
size='md'
onClick={() => {
setHasSubTasks(true)
}}
endDecorator={
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='J' />
}
>
Subtasks
</Button>
)}
</Box>
{hasDescription && (
<Box>
<Typography level='body-sm'>Description:</Typography>
<div>
<RichTextEditor
ref={richTextEditorRef}
onChange={setDescription}
value={description || ''}
entityType={'chore_description'}
/> />
</div> </Box>
</Box>
<Box
sx={{
paddingTop: 2,
paddingBottom: 1,
display: 'flex',
flexDirection: 'row',
gap: 1.5,
overflowX: 'auto',
'&::-webkit-scrollbar': { display: 'none' },
flexWrap: isMobile ? 'nowrap' : 'wrap',
}}
>
<DueDatePickerField
emptyDisplay={pickerEmptyDisplay}
dueDateOnly={dueDateOnly}
dueTime={dueTime}
useCustomTime={useCustomTime}
onDueDateChange={handleDueDateChange}
onDueTimeChange={handleDueTimeChange}
onUseCustomTimeChange={handleUseCustomTimeChange}
onClear={() => {
setDueDate(null)
setDueDateOnly(null)
setDueTime(null)
setUseCustomTime(false)
}}
/>
<RepeatPickerField
emptyDisplay={pickerEmptyDisplay}
value={frequency}
onChange={setFrequency}
onClear={() => setFrequency(null)}
/>
<PriorityPickerField
value={priority}
onChange={setPriority}
onClear={() => setPriority(0)}
emptyDisplay={pickerEmptyDisplay}
priorityColors={priorityColors}
priorityLabels={priorityLabels}
/>
<AssigneePickerField
emptyDisplay={pickerEmptyDisplay}
value={assignees?.[0]?.userId || null}
onChange={userId => {
if (!userId) {
setAssignees([])
} else {
setAssignees([{ userId }])
}
}}
onClear={() => setAssignees([])}
currentUserId={userProfile?.id}
members={circleMembers?.res || []}
/>
<LabelsPickerField
emptyDisplay={pickerEmptyDisplay}
values={labelsV2 || []}
onChange={setLabelsV2}
onClear={() => setLabelsV2([])}
labels={userLabels || []}
/>
<AttachmentPickerField
attachments={attachments}
onChange={setAttachments}
onClear={() => setAttachments([])}
emptyDisplay={pickerEmptyDisplay}
entityType='chore_attachment'
/>
<NotificationPickerField
value={notificationMetadata}
onChange={setNotificationMetadata}
onClear={() => setNotificationMetadata({ templates: [] })}
emptyDisplay={pickerEmptyDisplay}
/>
</Box>
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
{!hasDescription && (
<Button
startDecorator={<Add />}
variant='outlined'
color='neutral'
size='md'
onClick={() => setHasDescription(true)}
endDecorator={
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='E' />
}
>
Description
</Button>
)}
{!hasSubTasks && (
<Button
startDecorator={<Add />}
variant='outlined'
color='neutral'
size='md'
onClick={() => setHasSubTasks(true)}
endDecorator={
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='J' />
}
>
Subtasks
</Button>
)}
</Box>
{hasDescription && (
<Box>
<Typography level='body-sm'>Description:</Typography>
<div>
<RichTextEditor
ref={richTextEditorRef}
onChange={setDescription}
value={description || ''}
entityType={'chore_description'}
/>
</div>
</Box>
)}
{hasSubTasks && (
<Box>
<Typography level='body-sm'>Subtasks:</Typography>
<SubTasks
editMode={true}
tasks={subTasks ? subTasks : []}
setTasks={setSubTasks}
shouldFocus={true}
/>
</Box>
)}
</>
)} )}
{hasSubTasks && (
<Box> {showScan && (
<Typography level='body-sm'>Subtasks:</Typography> <ScanPanel
<SubTasks open
editMode={true} onTaskExtracted={handleTaskExtracted}
tasks={subTasks ? subTasks : []} initialImageUrl={pendingPhotoUrl}
setTasks={setSubTasks} onClose={() => {
shouldFocus={true} setShowScan(false)
/> setPendingPhotoUrl(null)
</Box> }}
/>
)} )}
</ResponsiveModal> </ResponsiveModal>
<PhotoTaskModal
open={photoModalOpen}
onClose={() => setPhotoModalOpen(false)}
onTaskExtracted={handleTaskExtracted}
/>
</> </>
) )
} }

View File

@@ -0,0 +1,320 @@
import {
CameraAlt,
DocumentScanner,
PhotoCamera,
Replay,
WarningAmber,
} from '@mui/icons-material'
import {
Box,
Button,
CircularProgress,
LinearProgress,
Typography,
} from '@mui/joy'
import { useEffect } from 'react'
import { useScanToTask } from './useScanToTask'
/**
* Inline scan-to-task panel. Mounts inside AddTaskModal — no second modal.
*
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
* → error [retake or cancel]
*/
const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl }) => {
const {
isNativeScanner,
phase,
capturedImage,
ocrProgress,
taskResult,
errorMsg,
cameraAvailable,
videoRef,
canvasRef,
fileInputRef,
startCamera,
stopCamera,
capture,
processImage,
setCapturedImage,
handleFileSelect,
handleNativeScan,
retake,
activate,
reset,
} = useScanToTask()
// Start/stop based on open state
useEffect(() => {
if (open) {
if (initialImageUrl) {
setCapturedImage(initialImageUrl)
processImage(initialImageUrl, 'browser')
} else if (isNativeScanner) {
handleNativeScan().then(result => {
if (result?.cancelled) onClose()
})
} else {
activate()
}
} else {
reset()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
// Start/stop web camera based on capture phase
useEffect(() => {
if (phase === 'capture' && !isNativeScanner) {
startCamera()
}
if (phase !== 'capture') {
stopCamera()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, isNativeScanner])
// Auto-close and populate when done
useEffect(() => {
if (phase === 'done' && taskResult) {
onTaskExtracted(taskResult)
onClose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, taskResult])
if (!open) return null
const isProcessing = phase === 'processing'
return (
<Box
sx={{
borderRadius: 'md',
border: '1px solid',
borderColor: 'primary.outlinedBorder',
overflow: 'hidden',
bgcolor: 'background.level1',
}}
>
{/* ── Capture phase ── */}
{phase === 'capture' && (
<>
<Box
sx={{
position: 'relative',
width: '100%',
minHeight: 200,
maxHeight: 300,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'neutral.900',
overflow: 'hidden',
}}
>
{isNativeScanner && (
<Box sx={{ textAlign: 'center', p: 4 }}>
<DocumentScanner
sx={{ fontSize: 56, color: 'white', opacity: 0.5, mb: 1 }}
/>
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
Tap &quot;Scan Document&quot; to open the scanner
</Typography>
</Box>
)}
{!isNativeScanner && cameraAvailable && (
<video
ref={videoRef}
autoPlay
playsInline
muted
style={{
width: '100%',
display: 'block',
maxHeight: 300,
objectFit: 'cover',
}}
/>
)}
{!isNativeScanner && !cameraAvailable && (
<Box sx={{ textAlign: 'center', p: 4 }}>
<CameraAlt
sx={{ fontSize: 48, color: 'white', opacity: 0.4, mb: 1 }}
/>
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}>
Camera not available use Upload instead
</Typography>
</Box>
)}
</Box>
<Box
sx={{
px: 1.5,
py: 1,
display: 'flex',
alignItems: 'center',
gap: 1,
}}
>
<Button
size='sm'
variant='plain'
color='neutral'
startDecorator={<PhotoCamera fontSize='small' />}
onClick={() => fileInputRef.current?.click()}
>
Upload
</Button>
<input
ref={fileInputRef}
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
Cancel
</Button>
{isNativeScanner ? (
<Button
size='sm'
variant='solid'
color='primary'
startDecorator={<DocumentScanner fontSize='small' />}
onClick={handleNativeScan}
>
Scan Document
</Button>
) : (
cameraAvailable && (
<Button
size='sm'
variant='solid'
color='primary'
startDecorator={<CameraAlt fontSize='small' />}
onClick={capture}
>
Capture
</Button>
)
)}
</Box>
</Box>
</>
)}
{/* ── Processing phase ── */}
{isProcessing && (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
p: 2.5,
}}
>
{capturedImage && (
<Box
sx={{
position: 'relative',
width: '100%',
borderRadius: 'sm',
overflow: 'hidden',
}}
>
<img
src={capturedImage}
alt='Processing'
style={{
width: '100%',
display: 'block',
maxHeight: 180,
objectFit: 'contain',
opacity: 0.45,
}}
/>
<Box
sx={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress size='md' />
</Box>
</Box>
)}
<Typography level='body-sm' sx={{ opacity: 0.7 }}>
{ocrProgress > 0 && ocrProgress < 100
? `Reading text… ${ocrProgress}%`
: ocrProgress >= 100
? 'Identifying task with AI…'
: 'Starting…'}
</Typography>
{ocrProgress > 0 && ocrProgress < 100 && (
<LinearProgress
determinate
value={ocrProgress}
sx={{ width: '100%' }}
/>
)}
</Box>
)}
{/* ── Error phase ── */}
{phase === 'error' && (
<Box sx={{ p: 2 }}>
{capturedImage && (
<img
src={capturedImage}
alt='Failed scan'
style={{
width: '100%',
display: 'block',
borderRadius: 8,
maxHeight: 160,
objectFit: 'contain',
opacity: 0.4,
marginBottom: 12,
}}
/>
)}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}>
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
<Typography level='body-sm'>{errorMsg}</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Replay fontSize='small' />}
onClick={retake}
>
Retake
</Button>
<Button size='sm' variant='plain' color='neutral' onClick={onClose}>
Cancel
</Button>
</Box>
</Box>
)}
<canvas ref={canvasRef} style={{ display: 'none' }} />
</Box>
)
}
export default ScanPanel

View File

@@ -0,0 +1,250 @@
import { useCallback, useRef, useState } from 'react'
import { useDocumentScanner } from '../../../hooks/useDocumentScanner'
import { localAIService } from '../../../service/LocalAIService'
const SYSTEM_PROMPT = `You are helping create tasks for a household task management app.
Given OCR text extracted from a photo, identify the most useful task a person should add to their task list.
The task title should always start with an action verb when possible.
Examples:
Bill -> "Pay water bill"
Appointment -> "Attend eye doctor appointment"
Invitation -> "RSVP for wedding"
Renewal Notice -> "Renew vehicle registration"
Package Notice -> "Pick up package"
School Form -> "Complete school permission form"
Action Priority Rules:
1. Payments and bills
2. Deadlines and renewals
3. Appointments
4. Required forms
5. Informational actions (view, read, review)
Rules:
Generate at most one task.
Focus on the most important action.
Extract due dates and deadlines.
Use appointment dates as due dates when appropriate.
Do not invent information.
If the content contains no actionable item, return null values.
Include any important ID or URL or instructions in the description.
Titles must be specific and useful at a glance.
Include the organization, provider, event, or subject when available.
Avoid generic document names.
Return valid JSON only.
Output:
{
"taskName": string | null,
"description": string | null,
"dueDate": string | null,
"confidence": number
}`
async function runNativeOCR(imageSource) {
const { Ocr } = await import('@jcesarmobile/capacitor-ocr')
const image = imageSource.includes('/_capacitor_file_/')
? 'file://' + imageSource.replace(/^https?:\/\/localhost\/_capacitor_file_/, '')
: imageSource
const result = await Ocr.process({ image })
return result.results.map(r => r.text).join('\n').trim()
}
async function runBrowserOCR(imageSource, onProgress) {
const { createWorker } = await import('tesseract.js')
const worker = await createWorker('eng', 1, {
logger: m => {
if (m.status === 'recognizing text' && onProgress) {
onProgress(Math.round(m.progress * 100))
}
},
})
const { data } = await worker.recognize(imageSource)
await worker.terminate()
return data.text?.trim() || ''
}
async function extractTaskFromOCR(ocrText) {
const messages = [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: `OCR Text:\n${ocrText}` },
]
const result = await localAIService.plainChat(messages)
if (!result) return null
const jsonMatch = result.match(/\{[\s\S]*\}/)
if (!jsonMatch) return null
try {
return JSON.parse(jsonMatch[0])
} catch {
return null
}
}
// phases: idle | capture | processing | done | error
export function useScanToTask() {
const { isNativeScanner, scanDocument } = useDocumentScanner()
const videoRef = useRef(null)
const canvasRef = useRef(null)
const streamRef = useRef(null)
const fileInputRef = useRef(null)
const [phase, setPhase] = useState('idle')
const [capturedImage, setCapturedImage] = useState(null)
const [ocrProgress, setOcrProgress] = useState(0)
const [taskResult, setTaskResult] = useState(null)
const [errorMsg, setErrorMsg] = useState('')
const [cameraAvailable, setCameraAvailable] = useState(true)
const startCamera = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' },
})
streamRef.current = stream
if (videoRef.current) {
videoRef.current.srcObject = stream
}
setCameraAvailable(true)
} catch {
setCameraAvailable(false)
}
}, [])
const stopCamera = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop())
streamRef.current = null
}
}, [])
const processImage = useCallback(async (imageData, method = 'browser') => {
setPhase('processing')
setOcrProgress(0)
setErrorMsg('')
try {
let text
if (method === 'native') {
text = await runNativeOCR(imageData)
} else {
text = await runBrowserOCR(imageData, pct => setOcrProgress(pct))
}
if (!text) {
setErrorMsg('No text found in image. Try a clearer photo.')
setPhase('error')
return
}
const task = await extractTaskFromOCR(text)
if (!task || !task.taskName) {
setErrorMsg('Could not identify a task. Try a different photo.')
setPhase('error')
return
}
setTaskResult(task)
setPhase('done')
} catch (e) {
setErrorMsg(e.message || 'Processing failed.')
setPhase('error')
}
}, [])
const capture = useCallback(() => {
if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current
const canvas = canvasRef.current
canvas.width = video.videoWidth
canvas.height = video.videoHeight
canvas.getContext('2d').drawImage(video, 0, 0)
const dataUrl = canvas.toDataURL('image/jpeg', 0.9)
setCapturedImage(dataUrl)
stopCamera()
processImage(dataUrl, 'browser')
}, [stopCamera, processImage])
const handleFileSelect = useCallback(
e => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = ev => {
const dataUrl = ev.target.result
setCapturedImage(dataUrl)
stopCamera()
processImage(dataUrl, 'browser')
}
reader.readAsDataURL(file)
},
[stopCamera, processImage],
)
const handleNativeScan = useCallback(async () => {
const { image, cancelled, error } = await scanDocument()
if (cancelled) return { cancelled: true }
if (error || !image) {
setErrorMsg(error ? `Scanner error: ${error}` : 'Scan failed.')
setPhase('error')
return { cancelled: false }
}
setCapturedImage(image)
processImage(image, 'native')
return { cancelled: false }
}, [scanDocument, processImage])
const retake = useCallback(() => {
setCapturedImage(null)
setTaskResult(null)
setErrorMsg('')
setOcrProgress(0)
setPhase('capture')
}, [])
const activate = useCallback(() => {
setCapturedImage(null)
setTaskResult(null)
setErrorMsg('')
setOcrProgress(0)
setPhase('capture')
}, [])
const reset = useCallback(() => {
stopCamera()
setCapturedImage(null)
setTaskResult(null)
setErrorMsg('')
setOcrProgress(0)
setPhase('idle')
}, [stopCamera])
return {
isNativeScanner,
phase,
capturedImage,
setCapturedImage,
ocrProgress,
taskResult,
errorMsg,
cameraAvailable,
videoRef,
canvasRef,
fileInputRef,
startCamera,
stopCamera,
capture,
processImage,
handleFileSelect,
handleNativeScan,
retake,
activate,
reset,
}
}

View File

@@ -31,7 +31,6 @@
font-size: 1.2em; font-size: 1.2em;
line-height: 1.2em; line-height: 1.2em;
font-family: inherit; font-family: inherit;
caret-color: var(--highlight-date-color);
} }
.highlight-date { .highlight-date {
@@ -61,8 +60,18 @@
.task-input { .task-input {
position: relative; position: relative;
width: 100%; width: 100%;
border-radius: 8px; border-radius: 10px;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #d0d5dd); border: none;
background-color: var(--joy-palette-neutral-softBg, #f0f4f8);
overflow: auto; overflow: auto;
transition: box-shadow 0.15s ease, background-color 0.15s ease;
}
.task-input:focus-within {
box-shadow: 0 0 0 1px var(--joy-palette-primary-outlinedBorder, rgba(11, 107, 203, 0.15));
}
.task-input:hover:not(:focus-within) {
background-color: var(--joy-palette-neutral-softHoverBg, #dde7ee);
} }

View File

@@ -1,4 +1,5 @@
import { useColorScheme } from '@mui/joy' import { CameraEnhance, PhotoFilter } from '@mui/icons-material'
import { IconButton, Tooltip, useColorScheme } from '@mui/joy'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import AutocompleteDropdown from '../TestView/AutocompleteDropdown' import AutocompleteDropdown from '../TestView/AutocompleteDropdown'
import './SmartTaskTitleInput.css' import './SmartTaskTitleInput.css'
@@ -52,9 +53,13 @@ const SmartTaskTitleInput = ({
suggestions, suggestions,
onEnterPressed, onEnterPressed,
customRenderer, customRenderer,
isNativeScanner,
onScanClick,
onPhotoSelected,
}) => { }) => {
const { mode, setMode } = useColorScheme() const { mode, setMode } = useColorScheme()
const titleInputRef = useRef(null) const titleInputRef = useRef(null)
const photoInputRef = useRef(null)
const [cursorPosition, setCursorPosition] = useState(value?.length) const [cursorPosition, setCursorPosition] = useState(value?.length)
const dropdownRef = useRef(null) const dropdownRef = useRef(null)
const [lastWord, setLastWord] = useState('') const [lastWord, setLastWord] = useState('')
@@ -181,9 +186,26 @@ const SmartTaskTitleInput = ({
} }
} }
const handlePhotoInputChange = e => {
const file = e.target.files?.[0]
if (!file || !onPhotoSelected) return
const reader = new FileReader()
reader.onload = ev => onPhotoSelected(ev.target.result)
reader.readAsDataURL(file)
e.target.value = ''
}
const showNativeButtons = isNativeScanner && !value
const MIC_BUTTON_WIDTH =
showNativeButtons && onPhotoSelected && onScanClick
? '5rem'
: showNativeButtons
? '2.5rem'
: '0rem'
return ( return (
<div> <div>
<div className='task-input' style={{ minHeight: '2.4em' }}> <div className='task-input' style={{ minHeight: '2.8em' }}>
<textarea <textarea
ref={titleInputRef} ref={titleInputRef}
autoFocus={autoFocus} autoFocus={autoFocus}
@@ -196,13 +218,12 @@ const SmartTaskTitleInput = ({
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: `calc(100% - ${MIC_BUTTON_WIDTH})`,
height: '100%', height: '100%',
// opacity: 100,
zIndex: 1, zIndex: 1,
resize: 'none', resize: 'none',
overflow: 'hidden', overflow: 'hidden',
padding: '0.5rem', padding: '0.6rem 0.75rem',
boxSizing: 'border-box', boxSizing: 'border-box',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
@@ -212,6 +233,8 @@ const SmartTaskTitleInput = ({
backgroundColor: 'transparent', backgroundColor: 'transparent',
color: 'transparent', color: 'transparent',
caretColor: mode === 'dark' ? '#fff' : '#000', caretColor: mode === 'dark' ? '#fff' : '#000',
border: 'none',
outline: 'none',
}} }}
/> />
<div <div
@@ -221,7 +244,7 @@ const SmartTaskTitleInput = ({
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
minHeight: '1.2em', minHeight: '1.2em',
padding: '0.5rem', padding: '0.6rem 0.75rem',
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
fontFamily: 'inherit', fontFamily: 'inherit',
@@ -231,7 +254,12 @@ const SmartTaskTitleInput = ({
onClick={handleDisplayClick} onClick={handleDisplayClick}
> >
{placeholder && !value && ( {placeholder && !value && (
<span className='pointer-events-none text-gray-400'> <span
style={{
pointerEvents: 'none',
color: 'var(--joy-palette-text-tertiary, #9fa6ad)',
}}
>
{placeholder} {placeholder}
</span> </span>
)} )}
@@ -241,6 +269,55 @@ const SmartTaskTitleInput = ({
{/* Zero-width space to maintain consistent height */} {/* Zero-width space to maintain consistent height */}
&#8203; &#8203;
</div> </div>
{showNativeButtons && (
<span
style={{
position: 'absolute',
top: '50%',
right: '0.3rem',
transform: 'translateY(-50%)',
zIndex: 2,
display: 'flex',
gap: '0.1rem',
}}
>
{onPhotoSelected && (
<>
<Tooltip title='Select photo' placement='top' size='sm'>
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={() => photoInputRef.current?.click()}
sx={{ borderRadius: 'xl' }}
>
<PhotoFilter fontSize='small' />
</IconButton>
</Tooltip>
<input
ref={photoInputRef}
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handlePhotoInputChange}
/>
</>
)}
{onScanClick && (
<Tooltip title='Scan to create task' placement='top' size='sm'>
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={onScanClick}
sx={{ borderRadius: 'xl' }}
>
<CameraEnhance fontSize='small' />
</IconButton>
</Tooltip>
)}
</span>
)}
</div> </div>
{showSuggestions && ( {showSuggestions && (
<AutocompleteDropdown <AutocompleteDropdown