Ability to have open OCR from the field
This commit is contained in:
@@ -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'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'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}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
320
src/views/components/ScanToTask/ScanPanel.jsx
Normal file
320
src/views/components/ScanToTask/ScanPanel.jsx
Normal 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 "Scan Document" 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
|
||||||
250
src/views/components/ScanToTask/useScanToTask.js
Normal file
250
src/views/components/ScanToTask/useScanToTask.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
​
|
​
|
||||||
</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
|
||||||
|
|||||||
Reference in New Issue
Block a user