feat: context menu & notification system (#3)

This commit is contained in:
Yehoshua Sandler 2023-07-01 12:31:25 -05:00 committed by GitHub
parent 1631271b93
commit 917662e9ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 531 additions and 180 deletions

View File

@ -19,7 +19,7 @@ type Props = {
type coordinates = { x: number, y: number }
const Area = (props: Props) => {
const { getProcessedAreaById, setSelectedAreaId } = useProject()
const { getProcessedAreaById, selectedAreaId, setSelectedAreaId } = useProject()
const shapeRef = React.useRef<Konva.Rect>(null)
const [isAreaContextMenuOpen, setIsAreaContextMenuOpen] = useState(false)
const [areaContextMenuPosition, setAreaContextMenuPosition] = useState<coordinates>()
@ -56,6 +56,11 @@ const Area = (props: Props) => {
setIsAreaContextMenuOpen(true)
}
const handleAreaClick = (areaId: string) => {
if (areaId === selectedAreaId) setSelectedAreaId('')
else setSelectedAreaId(areaId)
}
return <Group>
<Rect
ref={shapeRef}
@ -72,7 +77,7 @@ const Area = (props: Props) => {
shadowForStrokeEnabled={false}
onMouseEnter={handleEnterOrLeave}
onMouseLeave={handleEnterOrLeave}
onDblClick={() => setSelectedAreaId(a.id)}
onClick={() => handleAreaClick(a.id)}
onContextMenu={handleContextMenu}
isArea />
{!isAreaContextMenuOpen

View File

@ -1,12 +1,16 @@
'use client'
import React from 'react'
import React, { useRef, useState } from 'react'
import { entities } from '../../../wailsjs/wailsjs/go/models'
import { Html } from 'react-konva-utils'
import { copyButtonColors, deleteButtonColors, makeFormStyles, makeSharedButtonStyles, reprocessButtonColors, setMutableStylesOnElement, setPosition, setScale } from './styles'
import { ClipboardIcon, ArrowPathIcon, TrashIcon, LanguageIcon } from '@heroicons/react/24/outline'
import { getScaled, makeFormStyles, makeIconStyles } from './styles'
import { useProject } from '../../../context/Project/provider'
import asyncClick from '../../../utils/asyncClick'
import processImageArea from '../../../useCases/processImageArea'
import classNames from '../../../utils/classNames'
import { useNotification } from '../../../context/Notification/provider'
import LanguageSelect from '../../utils/LanguageSelect'
type Props = {
x: number,
@ -16,76 +20,152 @@ type Props = {
setIsAreaContextMenuOpen: Function
}
/**
* This uses Knova's HTML portal which does not support CSS classes.
* Because of this limitation we have to hack some UX with inline styles.
* @param {Props} props
*/
const AreaContextMenu = (props: Props) => {
const { getProcessedAreaById, requestDeleteAreaById, getSelectedDocument } = useProject()
const { area, setIsAreaContextMenuOpen, scale, x, y } = props
setPosition(x, y)
setScale(scale)
const sharedButtonStyles = makeSharedButtonStyles()
const handleBlur = (e: React.FocusEvent) => {
console.log(e.relatedTarget)
if (!e.currentTarget.contains(e.relatedTarget)) setIsAreaContextMenuOpen(false)
}
const AreaContextMenu = (props: Props) => {
const { getProcessedAreaById, requestDeleteAreaById, getSelectedDocument, requestUpdateArea } = useProject()
const { addNotificationToQueue } = useNotification()
const formRef = useRef<HTMLFormElement>(null)
const [shouldShowProcessLanguageSelect, setShouldShowProcessLanguageSelect] = useState(false)
const { area, setIsAreaContextMenuOpen, scale, x, y } = props
const handleCopyButtonClick = async () => {
setIsAreaContextMenuOpen(false)
const processedArea = await getProcessedAreaById(area.id)
const wordsOfProcessedArea = processedArea?.lines.flatMap(l => l.words.map(w => w.fullText))
const fullText = wordsOfProcessedArea?.join(' ')
if (!fullText) return // TODO: change to show notification when copy fails
if (!fullText) {
addNotificationToQueue({ message: 'No text found to copy.', level: 'warning' })
return
}
await navigator.clipboard.writeText(fullText)
setIsAreaContextMenuOpen(false)
try {
await navigator.clipboard.writeText(fullText)
addNotificationToQueue({ message: 'Copied area to clipboard' })
} catch (err) {
addNotificationToQueue({ message: 'Error copying area', level: 'error' })
}
}
const handleDeleteButtonClick = async () => {
const response = await requestDeleteAreaById(area.id)
if (!response) return // TODO: change to show notification when copy fails
setIsAreaContextMenuOpen(false)
try {
const response = await requestDeleteAreaById(area.id)
if (!response) addNotificationToQueue({ message: 'Could not delete area', level: 'warning' })
} catch (err) {
addNotificationToQueue({ message: 'Error deleting area', level: 'error' })
}
}
const handleReprocessButtonClick = async () => {
const documentId = getSelectedDocument()?.id
if (!documentId) return // TODO: change to show notification when copy fails
setIsAreaContextMenuOpen(false)
setIsAreaContextMenuOpen(false) // TODO: possibly have loading animation and wait until after process
await processImageArea(documentId, area.id)
const documentId = getSelectedDocument()?.id
if (!documentId) {
addNotificationToQueue({ message: 'Issue finding selected document', level: 'warning' })
return
}
try {
addNotificationToQueue({ message: 'Processing test of area' })
const response = await processImageArea(documentId, area.id)
if (response) addNotificationToQueue({ message: 'Area successfully processed' })
else addNotificationToQueue({ message: 'No text result from processing area', level: 'warning' })
} catch (err) {
addNotificationToQueue({ message: 'Error processing area', level: 'error' })
}
}
const handleProcessLanguageSelect = async (selectedLanguage: entities.Language) => {
setIsAreaContextMenuOpen(false)
let successfullyUpdatedLanguageOnArea = false
try {
successfullyUpdatedLanguageOnArea = await requestUpdateArea({...area, ...{language: selectedLanguage}})
} catch (err) {
addNotificationToQueue({ message: 'Error updating area language', level: 'error' })
return
}
const selectedDocumentId = getSelectedDocument()?.id
if (!successfullyUpdatedLanguageOnArea || !selectedDocumentId) {
addNotificationToQueue({ message: 'Did not successfully update area language', level: 'warning' })
return
}
try {
await processImageArea(selectedDocumentId, area.id)
addNotificationToQueue({ message: 'Finished processing area', level: 'info' })
} catch (err) {
addNotificationToQueue({ message: 'Error processing area', level: 'error' })
}
}
const handleOnBlur = (e: React.FocusEvent) => {
console.log(e.relatedTarget)
e.preventDefault()
if (e.relatedTarget === null) setIsAreaContextMenuOpen(false)
}
const baseMenuItemClassNames = 'flex items-center justify-between w-full px-3 py-1 flex-shrink-0 text-left cursor-pointer focus:outline-none'
return <Html>
<form style={makeFormStyles()} onBlur={handleBlur}>
<a
tabIndex={-1}
style={{ ...sharedButtonStyles, ...reprocessButtonColors.normal}}
onClick={(e) => asyncClick(e, handleCopyButtonClick)}
onMouseEnter={(e) => {setMutableStylesOnElement(e, copyButtonColors.hover)} }
onMouseLeave={(e) => {setMutableStylesOnElement(e, copyButtonColors.normal)} }>
Copy Area
</a>
<a
tabIndex={-1}
style={{ ...sharedButtonStyles, ...reprocessButtonColors.normal}}
onClick={(e) => asyncClick(e, handleReprocessButtonClick)}
onMouseEnter={(e) => {setMutableStylesOnElement(e, reprocessButtonColors.hover)} }
onMouseLeave={(e) => {setMutableStylesOnElement(e, reprocessButtonColors.normal)} }>
Reprocess
</a>
<a
tabIndex={-1}
style={{ ...sharedButtonStyles, ...deleteButtonColors.normal}}
onClick={(e) => asyncClick(e, handleDeleteButtonClick)}
onMouseEnter={(e) => {setMutableStylesOnElement(e, deleteButtonColors.hover)} }
onMouseLeave={(e) => {setMutableStylesOnElement(e, deleteButtonColors.normal)} }>
Delete
</a>
</form>
</Html>
<div style={makeFormStyles(x, y, scale)} tabIndex={1} onBlur={handleOnBlur}>
<div className={classNames(
'z-40 min-w-max py-1 rounded-md shadow-sm outline-none font-light',
'bg-white border border-gray-200',)}
>
<button autoFocus tabIndex={2}
onClick={(e) => asyncClick(e, handleCopyButtonClick)} className={
classNames(baseMenuItemClassNames,
'focus:bg-neutral-100 hover:bg-slate-300',
)}>
<span className="mr-2">Copy Area</span>
<ClipboardIcon className="ml-2" aria-hidden="true" style={{ ...makeIconStyles(scale) }} />
</button>
<button tabIndex={3}
onClick={(e) => asyncClick(e, handleReprocessButtonClick)} className={
classNames(baseMenuItemClassNames,
'focus:bg-neutral-100 hover:bg-slate-300',
)}>
<span className="mr-2">Reprocess Area</span>
<ArrowPathIcon className="ml-2" aria-hidden="true" style={{ ...makeIconStyles(scale) }} />
</button>
<button tabIndex={4}
onClick={(e) => asyncClick(e, handleDeleteButtonClick)} className={
classNames(baseMenuItemClassNames,
'focus:bg-neutral-100 bg-red-100 text-gray-900 hover:text-gray-100 hover:bg-red-600',
)}>
<span className="mr-2">Delete Area</span>
<TrashIcon className="ml-2" aria-hidden="true" style={{ ...makeIconStyles(scale) }} />
</button>
{shouldShowProcessLanguageSelect
? <LanguageSelect
defaultLanguage={area.language || getSelectedDocument()?.defaultLanguage}
styles={{ fontSize: `${getScaled(14, scale)}px` }}
onSelect={handleProcessLanguageSelect}
/>
: <button tabIndex={5}
onClick={(e) => setShouldShowProcessLanguageSelect(true)}
className={classNames(
baseMenuItemClassNames,
'focus:bg-neutral-100 hover:bg-slate-300',
)}>
<span className="mr-2">
{area.language?.displayName || getSelectedDocument()?.defaultLanguage.displayName}
</span>
<LanguageIcon className="ml-2" aria-hidden="true" style={{ ...makeIconStyles(scale) }} />
</button>
}
</div>
</div>
</Html >
}

View File

@ -1,90 +1,31 @@
import { DetailedHTMLProps, FormHTMLAttributes } from 'react'
let scale = 1
const setScale = (newScale: number) => scale = newScale
const getScaled = (value: number) => Math.floor(value / scale)
const getScaled = (value: number, scale: number) => Math.floor(value / scale)
let left = 0
let top = 0
const setPosition = (x: number, y: number) => {
left = x
top = y
}
const makeProportionalStyles = () => ({
fontSize: getScaled(18),
radius: getScaled(6),
formPadding: getScaled(12),
buttonPadding: getScaled(4),
verticalMargin: getScaled(4),
shadowOffset: {
x: getScaled(4),
y: getScaled(4),
color: 'rgba(50, 50, 50, 0.4)',
blur: getScaled(20),
}
})
const makeFormStyles = (x: number, y: number, scale: number) => {
const shadowOffset = { x: getScaled(4, scale), y: getScaled(4, scale), color: 'rgba(50, 50, 50, 0.4)', blur: getScaled(20, scale) }
const makeFormStyles = () => {
const proportionalStyles = makeProportionalStyles()
return {
position: 'absolute',
left: `${left}px`,
top: `${top}px`,
textAlign: 'center',
display: 'block',
fontSize: `${proportionalStyles.fontSize}px`,
backgroundColor: 'rgb(229, 231, 235)',
borderRadius: `${proportionalStyles.radius}px`,
borderTopLeftRadius: '0px',
padding: `${proportionalStyles.formPadding}px`,
boxShadow: `${proportionalStyles.shadowOffset.x}px ${proportionalStyles.shadowOffset.y}px ${proportionalStyles.shadowOffset.blur}px ${proportionalStyles.shadowOffset.color}`
fontSize: `${getScaled(16, scale)}px`,
width: `${getScaled(224, scale)}px`,
left: `${x}px`,
top: `${y}px`,
boxShadow: `${shadowOffset.x}px ${shadowOffset.y}px ${shadowOffset.blur}px ${shadowOffset.color}`
} as DetailedHTMLProps<FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>
}
const makeSharedButtonStyles = () => {
const proportionalStyles = makeProportionalStyles()
const makeIconStyles = (scale: number) => {
return {
display: 'block',
margin: `${proportionalStyles.verticalMargin}px auto`,
width: '100%',
border: 'solid 1px',
borderColor: 'rgb(31, 41, 55)',
borderRadius: `${proportionalStyles.radius}px`,
padding: `${proportionalStyles.buttonPadding}px`,
width: `${getScaled(14, scale)}px`,
height: `${getScaled(14, scale)}px`
}
}
const reprocessButtonColors = {
normal: { color: '#414C61', backgroundColor: '#E5E7EB' },
hover: { color: '#E5E7EB', backgroundColor: '#9AB3E6' },
}
const copyButtonColors = {
normal: { color: '#414C61', backgroundColor: '#E5E7EB' },
hover: { color: '#E5E7EB', backgroundColor: '#9AB3E6' },
}
const deleteButtonColors = {
normal: { color: '#DADCE0', backgroundColor: '#f87171' },
hover: { color: '#E5E7EB', backgroundColor: '#AD5050' },
}
// Awful TS hackery
type styleDeclaration = Partial<CSSStyleDeclaration> & { [propName: string]: string };
const setMutableStylesOnElement = (e: React.MouseEvent<HTMLElement, MouseEvent>, stylesToSet: styleDeclaration) => {
for (const style in stylesToSet) {
(e.currentTarget.style as styleDeclaration)[style] = stylesToSet[style]
}
}
export {
setScale,
setPosition,
makeFormStyles,
makeSharedButtonStyles,
copyButtonColors,
deleteButtonColors,
reprocessButtonColors,
setMutableStylesOnElement,
makeIconStyles,
getScaled,
}

View File

@ -36,7 +36,7 @@ const EditingWord = (props: Props) => {
display: 'block',
width: `${width}px`,
height: `${height}px`,
fontSize: `${Math.floor(48 * scale)}px`,
fontSize: `${Math.floor(24 * scale)}px`,
alignContent: 'center',
alignItems: 'center',
lineHeight: 0,

View File

@ -27,7 +27,7 @@ const ProcessedWord = (props: Props) => {
height={y1 - y0}
scale={{ x: scale, y: scale }}
x={x0 * scale}
y={y1 * scale}
y={y0 * scale}
strokeEnabled={false}
shadowForStrokeEnabled={false}
strokeScaleEnabled={false}
@ -42,7 +42,7 @@ const ProcessedWord = (props: Props) => {
height={y1 - y0}
scale={{ x: scale, y: scale }}
x={x0 * scale}
y={y1 * scale}
y={y0 * scale}
align='center'
verticalAlign='middle'
fontSize={36}

View File

@ -4,7 +4,8 @@ import dynamic from 'next/dynamic'
import React, { useEffect, useRef, useState } from 'react'
import { useProject, } from '../../context/Project/provider'
import { MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon } from '@heroicons/react/24/outline'
import LanguageSelect from '../workspace/LanguageSelect'
import LanguageSelect from '../utils/LanguageSelect'
import { entities } from '../../wailsjs/wailsjs/go/models'
const CanvasStage = dynamic(() => import('./CanvasStage'), {
ssr: false,
@ -14,8 +15,9 @@ const zoomStep = 0.01
const maxZoomLevel = 4
const DocumentCanvas = () => {
const { getSelectedDocument } = useProject()
const { getSelectedDocument, selectedAreaId, } = useProject()
const selectedDocument = getSelectedDocument()
const [ selectedArea, setSelectedArea ] = useState<entities.Area | undefined>()
const [zoomLevel, setZoomLevel] = useState(1)
const [size, setSize] = useState({ width: 0, height: 0 })
@ -34,25 +36,33 @@ const DocumentCanvas = () => {
return () => window.removeEventListener('resize', handleWindowResize)
}, [thisRef?.current?.clientWidth, thisRef?.current?.clientHeight])
return <div ref={thisRef} className='relative' style={{ height: 'calc(100vh - 200px)' }}>
<div className='flex justify-between align-top mb-2'>
<div className='flex align-top'>
<h1 className="text-xl font-semibold text-gray-900 inline-block mr-2">{selectedDocument?.name}</h1>
<LanguageSelect shouldUpdateDocument defaultLanguage={selectedDocument?.defaultLanguage} />
</div>
<div className='flex justify-evenly items-center'>
<MagnifyingGlassMinusIcon className='w-4 h-4' />
<input
id="zoomRange" type="range" min={zoomStep} max={maxZoomLevel} step={zoomStep}
value={zoomLevel} className="w-[calc(100%-50px)] h-2 bg-indigo-200 rounded-lg appearance-none cursor-pointer p-0"
onChange={(e) => { setZoomLevel(e.currentTarget.valueAsNumber) }}
/>
<MagnifyingGlassPlusIcon className='w-4 h-4' />
</div>
</div>
useEffect(() => {
setSelectedArea(selectedDocument?.areas.find(a => a.id == selectedAreaId))
}, [selectedAreaId])
return <div ref={thisRef} className='relative' style={{ height: 'calc(100vh - 140px)' }}>
<div className='h-full overflow-hidden rounded-lg border-4 border-dashed border-gray-200'>
<CanvasStage size={size} scale={zoomLevel} scaleStep={zoomStep} setScale={setZoomLevel} maxScale={maxZoomLevel} />
<div className='absolute flex justify-between align-top top-2 p-2 drop-shadow-2xl pointer-events-none shadow-slate-100' style={{ width: 'calc(100% - 0.5rem)' }}>
<div className='align-top pointer-events-auto w-1/3'>
<h1 className="text-lg font-medium text-gray-900 block mr-2 drop-shadow-2xl shadow-slate-100 drop truncate">
{selectedArea?.name
? `${selectedDocument?.name} / ${selectedArea?.name}`
: selectedDocument?.name
}
</h1>
<LanguageSelect styles={{fontSize: '16px', borderRadius: '2px'}} defaultLanguage={selectedArea?.language || selectedDocument?.defaultLanguage} />
</div>
<div className='flex mt-4 justify-evenly align-top pointer-events-auto'>
<MagnifyingGlassMinusIcon className='w-4 h-4' />
<input
id="zoomRange" type="range" min={zoomStep} max={maxZoomLevel} step={zoomStep}
value={zoomLevel} className="w-[calc(100%-50px)] h-2 bg-indigo-200 rounded-lg appearance-none cursor-pointer p-0"
onChange={(e) => { setZoomLevel(e.currentTarget.valueAsNumber) }}
/>
<MagnifyingGlassPlusIcon className='w-4 h-4' />
</div>
</div>
</div>
</div >
}

View File

@ -0,0 +1,87 @@
import { Combobox } from '@headlessui/react'
import { LanguageIcon } from '@heroicons/react/20/solid'
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/24/outline'
import { useEffect, useState } from 'react'
import classNames from '../../utils/classNames'
import getSupportedLanguages from '../../utils/getSupportedLanguages'
import { entities } from '../../wailsjs/wailsjs/go/models'
type Props = {
defaultLanguage?: entities.Language,
onSelect?: Function
styles?: Partial<React.CSSProperties>
}
const LanguageSelect = (props?: Props) => {
const [languages, setLanguages] = useState<entities.Language[]>([])
const [selectedLanguage, setSelectedLanguage] = useState<entities.Language | undefined>(props?.defaultLanguage)
const [query, setQuery] = useState('')
const filteredLanguages = query !== ''
? languages.filter(l => l.displayName.toLowerCase().includes(query.toLowerCase()))
: languages
useEffect(() => {
if (languages.length === 0) {
getSupportedLanguages().then(response => {
setLanguages(response)
})
}
})
useEffect(() => {
setSelectedLanguage(props?.defaultLanguage)
}, [props?.defaultLanguage])
const handleLanguageChange = (language: entities.Language) => {
if (props?.onSelect) props.onSelect(language)
setSelectedLanguage(language)
}
return <Combobox as="div" value={selectedLanguage} onChange={handleLanguageChange} className='block w-full'>
<div className="block relative">
<Combobox.Input
className="w-full border-none bg-white shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
onChange={(event) => setQuery(event.target.value)}
displayValue={(language: entities.Language) => language?.displayName}
placeholder='Document Language'
style={props?.styles}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none">
<LanguageIcon className="text-gray-400" style={props?.styles ? {width: props.styles.fontSize} : {}} />
<ChevronUpDownIcon className=" text-gray-400" aria-hidden="true" style={props?.styles ? {width: props.styles.fontSize} : {}} />
</Combobox.Button>
{filteredLanguages.length > 0 && (
<Combobox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{filteredLanguages.map((l) => (
<Combobox.Option
style={props?.styles}
key={l.displayName}
value={l}
className={({ active }) => classNames(
'relative cursor-default select-none py-2 pl-3 pr-9',
active ? 'bg-indigo-600 text-white' : 'text-gray-900'
)}>
{({ active, selected }) => <>
<span className={classNames('block truncate', selected && 'font-semibold')}>{l.displayName}</span>
{selected && (
<span className={classNames(
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-indigo-600'
)}>
<CheckIcon aria-hidden="true" style={props?.styles ? {width: props.styles.fontSize} : {}} />
</span>
)}
</>
}
</Combobox.Option>
))}
</Combobox.Options>
)}
</div>
</Combobox>
}
export default LanguageSelect

View File

@ -3,7 +3,7 @@
import React, { useRef } from 'react'
import { useProject } from '../../../context/Project/provider'
import classNames from '../../../utils/classNames'
import { ArrowPathIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { ArrowPathIcon, TrashIcon } from '@heroicons/react/24/outline'
import { SidebarArea } from './types'
import { useSidebar } from './provider'
import onEnterHandler from '../../../utils/onEnterHandler'
@ -30,7 +30,6 @@ const AreaLineItem = (props: { area: SidebarArea, documentId: string, index: num
const editAreaNameTextInput = useRef<HTMLInputElement>(null)
const onConfirmAreaNameChangeHandler = async (areaDetails: { areaId: string, areaName: string }) => {
const { areaId, areaName } = areaDetails
@ -126,7 +125,7 @@ const AreaLineItem = (props: { area: SidebarArea, documentId: string, index: num
aria-hidden="true"
onClick={handleReprocessAreaButtonClick}
/>
<XMarkIcon
<TrashIcon
className='w-6 h-5 mr-2 text-white hover:bg-red-400 hover:text-gray-100 rounded-full p-0.5'
onClick={() => handleAreaDeleteButtonClick(props.area.id)} />
</div>

View File

@ -11,7 +11,7 @@ export function useNavigation() {
}
type Props = { children: ReactNode, navigationProps: NavigationProps }
export function NavigationProvidor({ children, navigationProps }: Props) {
export function NavigationProvider({ children, navigationProps }: Props) {
const [selectedWorkspace, setSelectedWorkspace] = useState<workspaces>(navigationProps.selectedWorkspace)
const [selectedMainPage, setSelectedMainPage] = useState<mainPages>(navigationProps.selectedMainPage)

View File

@ -0,0 +1,7 @@
import { NotificationContextType } from './types';
const makeDefaultNotification = (): NotificationContextType => ({
addNotificationToQueue: (_) => {},
})
export default makeDefaultNotification

View File

@ -0,0 +1,113 @@
'use client'
import { createContext, Fragment, ReactNode, useContext, useEffect, useState } from 'react'
import { XMarkIcon, InformationCircleIcon, ExclamationTriangleIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline'
import { NotificationContextType, NotificationProps } from './types'
import makeDefaultNotification from './makeDefaultNotification'
import { Transition } from '@headlessui/react'
const NotificationContext = createContext<NotificationContextType>(makeDefaultNotification())
export function useNotification() {
return useContext(NotificationContext)
}
const notificationTimeInMilliseconds = 3000
const queue: NotificationProps[] = []
const renderIcon = (level: NotificationProps['level'] = 'info') => {
switch (level) {
default: return <InformationCircleIcon className='w-6 h-6 text-blue-400' />
case 'info': return <InformationCircleIcon className='w-6 h-6 text-blue-400' />
case 'warning': return <ExclamationTriangleIcon className='w-6 h-6 text-orange-400' />
case 'error': return <ExclamationCircleIcon className='w-6 h-6 text-red-600' />
}
}
type Props = { children: ReactNode }
export function NotificationProvider({ children }: Props) {
const [currentNotification, setCurrentNotification] = useState<NotificationProps | undefined>()
const addNotificationToQueue = (notificationProps: NotificationProps) => {
if (!queue.length) {
queue.push(notificationProps)
setCurrentNotification(notificationProps)
} else {
queue.push(notificationProps)
}
}
const dismissCurrentNotification = () => {
queue.shift()
setCurrentNotification(queue[0])
}
useEffect(() => {
if (!queue.length) return
setTimeout(dismissCurrentNotification, notificationTimeInMilliseconds)
}, [currentNotification])
const handleOnClick = () => {
if (currentNotification?.onActionClickCallback) currentNotification?.onActionClickCallback()
if (currentNotification?.closeOnAction) dismissCurrentNotification()
}
const value = { addNotificationToQueue }
return <NotificationContext.Provider value={value}>
{ children }
<>
<div
aria-live="assertive"
className="pointer-events-none absolute block top-0 left-0 w-full h-full"
>
<div className="absolute items-center" style={{ bottom: '12px', right: '16px' }}>
<Transition
show={!!currentNotification}
as={Fragment}
enter="transform ease-out duration-1300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5">
<div className="p-4">
<div className="flex items-center">
{renderIcon(currentNotification?.level)}
<div className="flex w-0 content-center flex-1 justify-between">
<p className="w-0 flex-1 text-sm font-medium text-gray-900 ml-2">{currentNotification?.message}</p>
{currentNotification?.actionButtonText ? <button
type="button"
className="ml-3 flex-shrink-0 rounded-md bg-white text-sm font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
onClick={() => handleOnClick()}
>
{currentNotification?.actionButtonText}
</button>
: <></>
}
</div>
<div className="ml-4 flex flex-shrink-0">
<button
type="button"
className="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
onClick={() => {
dismissCurrentNotification()
}}
>
<span className="sr-only">Close</span>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
</>
</NotificationContext.Provider>
}

View File

@ -0,0 +1,14 @@
export type NotificationLevel = 'info' | 'warning' | 'error'
export type NotificationProps = {
shouldShow?: boolean,
message: string,
actionButtonText?: string,
onActionClickCallback?: Function,
closeOnAction?: boolean,
level?: NotificationLevel,
}
export type NotificationContextType = {
addNotificationToQueue: (notificationProps: NotificationProps) => void
}

View File

@ -1,5 +1,5 @@
import { saveDocuments } from '../../useCases/saveData'
import { GetProcessedAreasByDocumentId, RequestAddArea, RequestAddProcessedArea, RequestChangeAreaOrder, RequestDeleteAreaById, RequestUpdateArea } from '../../wailsjs/wailsjs/go/ipc/Channel'
import { GetProcessedAreasByDocumentId, RequestAddArea, RequestAddProcessedArea, RequestChangeAreaOrder, RequestDeleteAreaById, RequestUpdateArea, RequestUpdateProcessedArea, } from '../../wailsjs/wailsjs/go/ipc/Channel'
import { entities, ipc } from '../../wailsjs/wailsjs/go/models'
import { AddAreaProps, AreaProps } from './types'
@ -45,12 +45,13 @@ const createAreaProviderMethods = (dependencies: Dependencies) => {
return response
}
const requestUpdateArea = async (updatedArea: AreaProps): Promise<entities.Area> => {
const response = await RequestUpdateArea(new entities.Area(updatedArea))
const requestUpdateArea = async (updatedArea: AreaProps): Promise<boolean> => {
console.log('requestUpdateArea', updatedArea)
const wasSuccessful = await RequestUpdateArea(new entities.Area(updatedArea))
if (response.id) await updateDocuments()
if (wasSuccessful) await updateDocuments()
saveDocuments()
return response
return wasSuccessful
}
const requestDeleteAreaById = async (areaId: string): Promise<boolean> => {
@ -62,6 +63,8 @@ const createAreaProviderMethods = (dependencies: Dependencies) => {
const requestAddProcessedArea = async (processedArea: entities.ProcessedArea) => await RequestAddProcessedArea(processedArea)
const requestUpdateProcessedArea = async (updatedProcessedArea: entities.ProcessedArea) => await RequestUpdateProcessedArea(updatedProcessedArea)
const requestChangeAreaOrder = async (areaId: string, newOrder: number) => {
const response = await RequestChangeAreaOrder(areaId, newOrder)
await updateDocuments()
@ -76,6 +79,7 @@ const createAreaProviderMethods = (dependencies: Dependencies) => {
requestDeleteAreaById,
getProcessedAreasByDocumentId,
requestAddProcessedArea,
requestUpdateProcessedArea,
requestChangeAreaOrder,
getProcessedAreaById,
}

View File

@ -12,7 +12,7 @@ const makeDefaultProject = (): ProjectContextType => ({
getProcessedAreasByDocumentId: (documentId) => Promise.resolve([new entities.ProcessedArea()]),
requestAddProcessedArea: (processesArea) => Promise.resolve(new entities.ProcessedArea()),
requestAddArea: (documentId, area) => Promise.resolve(new entities.Area()),
requestUpdateArea: (updatedArea) => Promise.resolve(new entities.Area()),
requestUpdateArea: (updatedArea) => Promise.resolve(false),
requestDeleteAreaById: (areaId) => Promise.resolve(false),
requestAddDocument: (groupId, documentName) => Promise.resolve(new entities.Document()),
requestDeleteDocumentById: (documentId) => Promise.resolve(false),
@ -32,6 +32,7 @@ const makeDefaultProject = (): ProjectContextType => ({
requestSelectProjectByName: (projectName) => Promise.resolve(false),
requestUpdateProcessedWordById: (wordId, newTestValue) => Promise.resolve(false),
getProcessedAreaById: (areaId) => Promise.resolve(undefined),
requestUpdateProcessedArea: updatedProcessedArea => Promise.resolve(false),
})
export default makeDefaultProject

View File

@ -40,9 +40,9 @@ export type ProjectContextType = {
getSelectedDocument: () => entities.Document | undefined
getAreaById: (areaId: string) => entities.Area | undefined
getProcessedAreasByDocumentId: (documentId: string) => Promise<entities.ProcessedArea[]>
requestAddProcessedArea: (processedArea: entities.ProcessedArea) => Promise<entities.ProcessedArea>
requestAddProcessedArea: (processedArea: entities.ProcessedArea) => Promise<boolean>
requestAddArea: (documentId: string, area: AddAreaProps) => Promise<entities.Area>
requestUpdateArea: (area: AreaProps) => Promise<entities.Area>
requestUpdateArea: (area: AreaProps) => Promise<boolean>
requestDeleteAreaById: (areaId: string) => Promise<boolean>
requestAddDocument: (groupId: string, documentName: string) => Promise<entities.Document>
requestDeleteDocumentById: (documentId: string) => Promise<boolean>
@ -64,4 +64,5 @@ export type ProjectContextType = {
requestSelectProjectByName: (projectName: string) => Promise<boolean>
requestUpdateProcessedWordById: (wordId: string, newTextValue: string) => Promise<boolean>
getProcessedAreaById: (areaId: string) => Promise<entities.ProcessedArea | undefined>
requestUpdateProcessedArea: (updatedProcessedArea: entities.ProcessedArea) => Promise<boolean>
} & ProjectProps

View File

@ -5,8 +5,9 @@ import { ProjectProvider } from '../context/Project/provider'
import '../styles/globals.css'
import { entities } from '../wailsjs/wailsjs/go/models'
import '../styles/globals.css'
import { NavigationProvidor } from '../context/Navigation/provider'
import { NavigationProvider } from '../context/Navigation/provider'
import { mainPages, workspaces } from '../context/Navigation/types'
import { NotificationProvider } from '../context/Notification/provider'
const initialProjectProps = {
id: '',
@ -21,10 +22,12 @@ const initialNavigationProps = {
export default function MainAppLayout({ Component, pageProps }: AppProps) {
return <div className='min-h-screen' >
<NavigationProvidor navigationProps={initialNavigationProps}>
<NavigationProvider navigationProps={initialNavigationProps}>
<ProjectProvider projectProps={initialProjectProps}>
<Component {...pageProps} />
<NotificationProvider>
<Component {...pageProps} />
</NotificationProvider>
</ProjectProvider>
</NavigationProvidor>
</NavigationProvider>
</div>
}

View File

@ -1,5 +1,4 @@
import { NextPage } from 'next'
import { useEffect, useState } from 'react'
import MainHead from '../components/head'
import MainProject from '../components/project/Main'
import User from '../components/settings/User'

View File

@ -1,5 +1,5 @@
import { createScheduler, createWorker } from 'tesseract.js'
import { GetAreaById, GetDocumentById, RequestAddProcessedArea, RequestSaveProcessedTextCollection } from '../wailsjs/wailsjs/go/ipc/Channel'
import { GetAreaById, GetDocumentById, GetProcessedAreaById, RequestAddProcessedArea, RequestSaveProcessedTextCollection, RequestUpdateProcessedArea } from '../wailsjs/wailsjs/go/ipc/Channel'
import { entities } from '../wailsjs/wailsjs/go/models'
import loadImage from './loadImage'
import { saveProcessedText } from './saveData'
@ -9,7 +9,9 @@ const processImageArea = async (documentId: string, areaId: string) => {
const foundArea = await GetAreaById(areaId)
if (!foundDocument.path || !foundDocument.areas?.length || !foundArea.id) return
const processLanguage = foundDocument.defaultLanguage.processCode
console.log(foundArea)
const processLanguage = foundArea.language.processCode || foundDocument.defaultLanguage.processCode
if (!processLanguage) return console.error('No process language selected')
@ -41,7 +43,7 @@ const processImageArea = async (documentId: string, areaId: string) => {
}
})
const addProcessesAreaRequest = await RequestAddProcessedArea(new entities.ProcessedArea({
const newProcessedArea = new entities.ProcessedArea({
id: foundArea.id,
documentId,
order: foundArea.order,
@ -70,11 +72,18 @@ const processImageArea = async (documentId: string, areaId: string) => {
}))
}))
}))
}))
})
console.log(newProcessedArea)
const existingProcessedArea = await GetProcessedAreaById(areaId)
let didSuccessfullyProcess = false
if (existingProcessedArea.id !== areaId) didSuccessfullyProcess = await RequestAddProcessedArea(newProcessedArea)
else await RequestUpdateProcessedArea(newProcessedArea)
saveProcessedText()
return addProcessesAreaRequest
return didSuccessfullyProcess
}
export default processImageArea

View File

@ -17,6 +17,8 @@ export function GetDocumentById(arg1:string):Promise<entities.Document>;
export function GetDocuments():Promise<ipc.GetDocumentsResponse>;
export function GetProcessedAreaById(arg1:string):Promise<entities.ProcessedArea>;
export function GetProcessedAreasByDocumentId(arg1:string):Promise<Array<entities.ProcessedArea>>;
export function GetProjectByName(arg1:string):Promise<entities.Project>;
@ -31,7 +33,7 @@ export function RequestAddDocument(arg1:string,arg2:string):Promise<entities.Doc
export function RequestAddDocumentGroup(arg1:string):Promise<entities.Group>;
export function RequestAddProcessedArea(arg1:entities.ProcessedArea):Promise<entities.ProcessedArea>;
export function RequestAddProcessedArea(arg1:entities.ProcessedArea):Promise<boolean>;
export function RequestChangeAreaOrder(arg1:string,arg2:number):Promise<entities.Document>;
@ -45,6 +47,8 @@ export function RequestDeleteAreaById(arg1:string):Promise<boolean>;
export function RequestDeleteDocumentAndChildren(arg1:string):Promise<boolean>;
export function RequestDeleteProcessedAreaById(arg1:string):Promise<boolean>;
export function RequestSaveDocumentCollection():Promise<boolean>;
export function RequestSaveGroupCollection():Promise<boolean>;
@ -53,7 +57,7 @@ export function RequestSaveLocalUserProcessedMarkdownCollection():Promise<boolea
export function RequestSaveProcessedTextCollection():Promise<boolean>;
export function RequestUpdateArea(arg1:entities.Area):Promise<entities.Area>;
export function RequestUpdateArea(arg1:entities.Area):Promise<boolean>;
export function RequestUpdateCurrentUser(arg1:entities.User):Promise<entities.User>;
@ -61,4 +65,6 @@ export function RequestUpdateDocument(arg1:entities.Document):Promise<entities.D
export function RequestUpdateDocumentUserMarkdown(arg1:string,arg2:string):Promise<entities.UserMarkdown>;
export function RequestUpdateProcessedArea(arg1:entities.ProcessedArea):Promise<boolean>;
export function RequestUpdateProcessedWordById(arg1:string,arg2:string):Promise<boolean>;

View File

@ -30,6 +30,10 @@ export function GetDocuments() {
return window['go']['ipc']['Channel']['GetDocuments']();
}
export function GetProcessedAreaById(arg1) {
return window['go']['ipc']['Channel']['GetProcessedAreaById'](arg1);
}
export function GetProcessedAreasByDocumentId(arg1) {
return window['go']['ipc']['Channel']['GetProcessedAreasByDocumentId'](arg1);
}
@ -86,6 +90,10 @@ export function RequestDeleteDocumentAndChildren(arg1) {
return window['go']['ipc']['Channel']['RequestDeleteDocumentAndChildren'](arg1);
}
export function RequestDeleteProcessedAreaById(arg1) {
return window['go']['ipc']['Channel']['RequestDeleteProcessedAreaById'](arg1);
}
export function RequestSaveDocumentCollection() {
return window['go']['ipc']['Channel']['RequestSaveDocumentCollection']();
}
@ -118,6 +126,10 @@ export function RequestUpdateDocumentUserMarkdown(arg1, arg2) {
return window['go']['ipc']['Channel']['RequestUpdateDocumentUserMarkdown'](arg1, arg2);
}
export function RequestUpdateProcessedArea(arg1) {
return window['go']['ipc']['Channel']['RequestUpdateProcessedArea'](arg1);
}
export function RequestUpdateProcessedWordById(arg1, arg2) {
return window['go']['ipc']['Channel']['RequestUpdateProcessedWordById'](arg1, arg2);
}

View File

@ -1,6 +1,7 @@
package ipc
import (
"fmt"
"sort"
app "textualize/core/App"
document "textualize/core/Document"
@ -221,17 +222,17 @@ func (c *Channel) RequestAddArea(documentId string, area entities.Area) entities
return newArea
}
func (c *Channel) RequestUpdateArea(updatedArea entities.Area) entities.Area {
func (c *Channel) RequestUpdateArea(updatedArea entities.Area) bool {
documentOfArea := document.GetDocumentCollection().GetDocumentByAreaId(updatedArea.Id)
if documentOfArea.Id == "" {
return entities.Area{}
return false
}
areaToUpdate := documentOfArea.GetAreaById(updatedArea.Id)
if areaToUpdate.Id == "" {
return entities.Area{}
return false
}
if updatedArea.Name != "" {
@ -240,8 +241,14 @@ func (c *Channel) RequestUpdateArea(updatedArea entities.Area) entities.Area {
if updatedArea.Order != areaToUpdate.Order {
areaToUpdate.Order = updatedArea.Order
}
if updatedArea.Language.ProcessCode != "" {
areaToUpdate.Language = updatedArea.Language
}
return *areaToUpdate
fmt.Println(areaToUpdate.Language)
fmt.Println(documentOfArea.GetAreaById(updatedArea.Id))
return true
}
func (c *Channel) RequestDeleteAreaById(areaId string) bool {

View File

@ -1,6 +1,7 @@
package ipc
import (
"fmt"
"sort"
document "textualize/core/Document"
"textualize/entities"
@ -8,6 +9,10 @@ import (
"github.com/google/uuid"
)
func (c *Channel) GetProcessedAreaById(id string) entities.ProcessedArea {
return *document.GetProcessedAreaCollection().GetAreaById(id)
}
func (c *Channel) GetProcessedAreasByDocumentId(id string) []entities.ProcessedArea {
areas := document.GetProcessedAreaCollection().GetAreasByDocumentId(id)
@ -25,7 +30,7 @@ func (c *Channel) GetProcessedAreasByDocumentId(id string) []entities.ProcessedA
return sortedAreas
}
func (c *Channel) RequestAddProcessedArea(processedArea entities.ProcessedArea) entities.ProcessedArea {
func (c *Channel) RequestAddProcessedArea(processedArea entities.ProcessedArea) bool {
for lineIndex, line := range processedArea.Lines {
for wordIndex, word := range line.Words {
@ -36,7 +41,55 @@ func (c *Channel) RequestAddProcessedArea(processedArea entities.ProcessedArea)
}
document.GetProcessedAreaCollection().AddProcessedArea(processedArea)
return processedArea
return true
}
func (c *Channel) RequestDeleteProcessedAreaById(id string) bool {
processedAreas := document.GetProcessedAreaCollection().Areas
areaToUpdate := document.GetProcessedAreaCollection().GetAreaById(id)
if areaToUpdate.Id == "" {
return false
}
areaToDeleteIndex := -1
for i, a := range processedAreas {
if a.Id == id {
areaToDeleteIndex = i
break
}
}
if areaToDeleteIndex < 0 {
return false
}
processedAreas[areaToDeleteIndex] = processedAreas[len(processedAreas)-1]
// processedAreas = processedAreas[:len(processedAreas)-1]
return true
}
func (c *Channel) RequestUpdateProcessedArea(updatedProcessedArea entities.ProcessedArea) bool {
fmt.Println("updatedProcessedArea")
fmt.Println(&updatedProcessedArea)
fmt.Println()
if updatedProcessedArea.Id == "" {
return false
}
successfulDelete := c.RequestDeleteProcessedAreaById(updatedProcessedArea.Id)
if !successfulDelete {
return false
}
successfulAdd := c.RequestAddProcessedArea(updatedProcessedArea)
if !successfulAdd {
return false
}
fmt.Println("document.GetProcessedAreaCollection().GetAreaById(updatedProcessedArea.Id)")
fmt.Println(document.GetProcessedAreaCollection().GetAreaById(updatedProcessedArea.Id))
return true
}
func (c *Channel) RequestUpdateProcessedWordById(wordId string, newTextValue string) bool {