refact: broke out canvases

This commit is contained in:
Joshua Shoemaker 2023-05-25 16:40:05 -05:00
parent 51c5141f99
commit 554cfa14ba
16 changed files with 794 additions and 416 deletions

View File

@ -3,6 +3,7 @@
"*.css": "tailwindcss"
},
"cSpell.words": [
"heroicons"
"heroicons",
"wailsjs"
]
}

View File

@ -0,0 +1,69 @@
'use client'
import React, { useEffect, useRef } from 'react'
import { useProject } from '../../context/Project/provider'
type Props = {
width: number,
height: number
zoomLevel: number
}
const AreaCanvas = (props: Props) => {
const { getSelectedDocument, selectedAreaId, } = useProject()
const canvas = useRef<HTMLCanvasElement>(null)
const areas = getSelectedDocument()?.areas
const { width, height, zoomLevel } = props
const applyAreasToCanvas = (zoomLevel: number) => {
if (!areas || !areas.length) return
const canvasContext = canvas.current!.getContext('2d')!
areas.forEach(a => {
canvasContext.beginPath()
if (a.id !== selectedAreaId) {
canvasContext.setLineDash([4])
canvasContext.lineWidth = 2
canvasContext.strokeStyle = '#010101'
} else {
canvasContext.setLineDash([])
canvasContext.lineWidth = 3
canvasContext.strokeStyle = '#dc8dec'
}
const width = (a.endX - a.startX) * zoomLevel
const height = (a.endY - a.startY) * zoomLevel
const x = a.startX * zoomLevel
const y = a.startY * zoomLevel
canvasContext.roundRect(x, y, width, height, 4)
canvasContext.stroke()
canvasContext.closePath()
})
}
const clearCanvas = () => {
const canvasInstance = canvas.current!
const context = canvasInstance.getContext('2d')!
context.clearRect(0, 0, canvasInstance.width, canvasInstance.height)
}
const updateSize = (size: { width: number, height: number }) => {
const canvasInstance = canvas.current!
const { width, height } = size
canvasInstance.width = width
canvasInstance.height = height
}
useEffect(() => {
clearCanvas()
updateSize({ width, height })
applyAreasToCanvas(zoomLevel)
}, [width, height, zoomLevel, areas])
return <canvas className="absolute" ref={canvas} />
}
export default AreaCanvas

View File

@ -0,0 +1,42 @@
import React from 'react'
import { ipc } from '../../wailsjs/wailsjs/go/models'
import classNames from '../../utils/classNames'
type Props = {
areas: ipc.Area[]
processedArea?: ipc.ProcessedArea
zoomLevel: number
setWordToEdit: (props: { word: ipc.ProcessedWord, areaId: string }) => void
}
const AreaTextPreview = ({ areas, processedArea, zoomLevel, setWordToEdit }: Props) => {
if (!processedArea) return <></>
return <div>
{
processedArea.lines?.map(l => l.words).flat().map((w, i) => {
const width = Math.floor((w.boundingBox.x1 - w.boundingBox.x0) * zoomLevel) + 2
const height = Math.floor((w.boundingBox.y1 - w.boundingBox.y0) * zoomLevel) + 2
return <span
key={i}
dir={w.direction === 'RIGHT_TO_LEFT' ? 'rtl' : 'ltr'}
className={classNames('absolute text-center inline-block p-1 rounded-md shadow-zinc-900 shadow-2xl',
'hover:bg-opacity-60 hover:bg-black hover:text-white',
'bg-opacity-80 bg-slate-300 text-slate-500'
)}
style={{
fontSize: `${3.4 * zoomLevel}vmin`,
width,
top: Math.floor(w.boundingBox.y0 * zoomLevel) + height,
left: Math.floor(w.boundingBox.x0 * zoomLevel)
}}
onDoubleClick={() => setWordToEdit({ word: w, areaId: processedArea.id })}>
{w.fullText}
</span>
})
}
</div>
}
export default AreaTextPreview

View File

@ -0,0 +1,82 @@
import React, { useRef } from 'react'
import { ipc } from '../../wailsjs/wailsjs/go/models'
import classNames from '../../utils/classNames'
import onEnterHandler from '../../utils/onEnterHandler'
import { useProject } from '../../context/Project/provider'
type Props = {
zoomLevel: number
processedArea?: ipc.ProcessedArea
wordToEdit?: ipc.ProcessedWord
setWordToEdit: (props?: { word: ipc.ProcessedWord, areaId: string }) => void
setHoveredProcessedArea: (area?: ipc.ProcessedArea) => void
}
const EditProcessedWord = ({ setWordToEdit, zoomLevel, wordToEdit, processedArea, setHoveredProcessedArea }: Props) => {
const {
requestUpdateProcessedWordById,
getProcessedAreaById,
} = useProject()
const editWordInput = useRef<HTMLInputElement>(null)
if (!wordToEdit || !processedArea) return <></>
const width = Math.floor((wordToEdit.boundingBox.x1 - wordToEdit.boundingBox.x0) * zoomLevel) + 2
const height = Math.floor(((wordToEdit.boundingBox.y1 - wordToEdit.boundingBox.y0) * zoomLevel) * 2) + 4
const handleWordCorrectionSubmit = (wordId: string, newWordValue: string) => {
requestUpdateProcessedWordById(wordId, newWordValue)
.then(res => {
getProcessedAreaById(processedArea.id || '').then(response => {
setHoveredProcessedArea(response)
})
})
.catch(console.error)
setWordToEdit(undefined)
}
return <div
dir={wordToEdit.direction === 'RIGHT_TO_LEFT' ? 'rtl' : 'ltr'}
className={classNames('absolute inline-block p-1 rounded-md',
'bg-opacity-60 bg-black text-white',
)}
style={{
width,
height,
top: Math.floor(wordToEdit.boundingBox.y0 * zoomLevel) + (height / 2),
left: Math.floor(wordToEdit.boundingBox.x0 * zoomLevel)
}}
onBlur={() => setWordToEdit(undefined)}
>
<div
className={classNames('text-center align-middle block p-1 rounded-md shadow-zinc-900 shadow-2xl',
'bg-opacity-60 bg-black text-white',
)}
style={{
fontSize: `${3.4 * zoomLevel}vmin`,
height: height / 2,
}}>
{wordToEdit.fullText}
</div>
<input
type='text'
className='inline-block text-slate-900 p-0 m-0 w-full'
autoFocus
width={width}
ref={editWordInput}
placeholder={wordToEdit.fullText}
defaultValue={wordToEdit.fullText}
style={{
fontSize: `${3.4 * zoomLevel}vmin`,
height: height / 2,
}}
onFocus={(e) => e.currentTarget.select()}
onBlur={(e) => handleWordCorrectionSubmit(wordToEdit.id, e.currentTarget.value)}
onKeyDown={(e) => onEnterHandler(e, () => handleWordCorrectionSubmit(wordToEdit.id, e.currentTarget.value))}
/>
</div>
}
export default EditProcessedWord

View File

@ -0,0 +1,55 @@
'use client'
import React, { useEffect, useRef } from 'react'
import loadImage from '../../useCases/loadImage'
type Props = {
zoomLevel: number,
imagePath?: string,
setSize: (size: { width: number, height: number }) => void
}
const ImageCanvas = (props: Props) => {
const canvas = useRef<HTMLCanvasElement>(null)
const { imagePath, zoomLevel, setSize } = props
const applyImageToCanvas = async (path: string) => {
const canvasContext = canvas.current!.getContext('2d')!
let image: HTMLImageElement
try {
image = await loadImage(path)
} catch (err) {
return
}
const width = image.naturalWidth * zoomLevel
const height = image.naturalHeight * zoomLevel
updateSize({ width, height })
canvasContext.drawImage(image, 0, 0, width, height)
}
const clearCanvas = () => {
const canvasInstance = canvas.current!
const context = canvasInstance.getContext('2d')!
context.clearRect(0, 0, canvasInstance.width, canvasInstance.height)
}
const updateSize = (size: { width: number, height: number }) => {
const canvasInstance = canvas.current!
const { width, height } = size
canvasInstance.width = width
canvasInstance.height = height
setSize(size)
}
useEffect(() => {
if (imagePath) applyImageToCanvas(imagePath)
}, [imagePath, zoomLevel])
return <canvas className="absolute" ref={canvas} />
}
export default ImageCanvas

View File

@ -0,0 +1,186 @@
'use client'
import React, { WheelEvent, useEffect, useRef, useState } from 'react'
import { useProject } from '../../context/Project/provider'
import { ipc } from '../../wailsjs/wailsjs/go/models'
import createUiCanvasInteractions from './createUiCanvasInteractions'
import processImageArea from '../../useCases/processImageArea'
import AreaTextPreview from './AreaTextPreview'
import EditProcessedWord from './EditProcessedWord'
type Props = {
width: number,
height: number
zoomDetails: { currentZoomLevel: number, zoomStep: number, maxZoomLevel: number }
setZoomLevel: (value: number) => void
}
let interactions: ReturnType<typeof createUiCanvasInteractions> | null = null
let downClickX = 0
let downClickY = 0
let isDrawing = false
const UiCanvas = (props: Props) => {
const {
getSelectedDocument,
getProcessedAreaById,
requestAddArea,
setSelectedAreaId,
} = useProject()
const canvas = useRef<HTMLCanvasElement>(null)
const [hoverOverAreaId, setHoverOverAreaId] = useState('')
const [wordToEdit, setWordToEdit] = useState<{ word: ipc.ProcessedWord, areaId: string } | undefined>()
const [hoveredProcessedArea, setHoveredProcessedArea] = useState<ipc.ProcessedArea | undefined>()
const areas = getSelectedDocument()?.areas || []
const { width, height, zoomDetails, setZoomLevel } = props
const { currentZoomLevel } = zoomDetails
const applyUiCanvasUpdates = () => {
const canvasContext = canvas.current!.getContext('2d')!
if (!areas || !areas.length) return
const hoverArea = areas.find(a => a.id === hoverOverAreaId)
if (!hoverArea) return
canvasContext.beginPath()
canvasContext.setLineDash([])
canvasContext.lineWidth = 6
canvasContext.strokeStyle = '#dc8dec'
const width = (hoverArea.endX - hoverArea.startX) * currentZoomLevel
const height = (hoverArea.endY - hoverArea.startY) * currentZoomLevel
const x = hoverArea.startX * currentZoomLevel
const y = hoverArea.startY * currentZoomLevel
canvasContext.roundRect(x, y, width, height, 4)
canvasContext.stroke()
canvasContext.closePath()
}
const clearCanvas = () => {
const canvasInstance = canvas.current!
const context = canvasInstance.getContext('2d')!
context.clearRect(0, 0, canvasInstance.width, canvasInstance.height)
}
const handleMouseDown = (e: React.MouseEvent) => {
if (e.nativeEvent.shiftKey) {
downClickX = e.nativeEvent.offsetX
downClickY = e.nativeEvent.offsetY
isDrawing = true
}
}
const handleMouseMove = (e: React.MouseEvent) => {
if (isDrawing) interactions?.onActivelyDrawArea({
startX: downClickX,
startY: downClickY,
endX: e.nativeEvent.offsetX,
endY: e.nativeEvent.offsetY,
})
else interactions?.onHoverOverArea(
e.clientX,
e.clientY,
currentZoomLevel,
areas,
(areaId) => {
if (areaId === hoverOverAreaId) return
setHoverOverAreaId(areaId || '')
getProcessedAreaById(areaId || '').then(response => {
setHoveredProcessedArea(response)
})
}
)
}
const handleMouseUp = async (e: React.MouseEvent) => {
if (isDrawing) {
const coordinates = {
startMouseX: downClickX,
startMouseY: downClickY,
endMouseX: e.nativeEvent.offsetX,
endMouseY: e.nativeEvent.offsetY,
}
interactions?.onFinishDrawArea(coordinates, currentZoomLevel,
async (startX, startY, endX, endY) => {
const canvasInstance = canvas.current
if (!canvasInstance) return
const selectedDocumentId = getSelectedDocument()?.id
if (selectedDocumentId) {
const addedArea = await requestAddArea(selectedDocumentId, { startX, startY, endX, endY })
setSelectedAreaId(addedArea.id)
processImageArea(selectedDocumentId, addedArea.id)
}
const context = canvasInstance.getContext('2d')
context?.clearRect(0, 0, canvasInstance.width, canvasInstance.height)
isDrawing = false
downClickX = 0
downClickY = 0
}
)
}
}
const handleWheelEvent = (e: WheelEvent<HTMLCanvasElement>) => {
if (e.ctrlKey) interactions?.onZoom(e.deltaY, zoomDetails, setZoomLevel)
}
const updateSize = (size: { width: number, height: number }) => {
const canvasInstance = canvas.current!
const { width, height } = size
canvasInstance.width = width
canvasInstance.height = height
}
useEffect(() => {
if (!interactions && canvas.current) {
interactions = createUiCanvasInteractions(canvas.current)
}
}, [canvas.current])
useEffect(() => {
clearCanvas()
updateSize({ width, height })
applyUiCanvasUpdates()
}, [width, height, currentZoomLevel, areas])
useEffect(() => {
clearCanvas()
applyUiCanvasUpdates()
}, [hoverOverAreaId])
return <>
<canvas
className="absolute"
ref={canvas}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onWheel={handleWheelEvent}
/>
<AreaTextPreview
setWordToEdit={setWordToEdit}
processedArea={hoveredProcessedArea}
zoomLevel={currentZoomLevel}
areas={areas}
/>
<EditProcessedWord
zoomLevel={currentZoomLevel}
processedArea={hoveredProcessedArea}
wordToEdit={wordToEdit?.word}
setWordToEdit={setWordToEdit}
setHoveredProcessedArea={setHoveredProcessedArea}
/>
</>
}
export default UiCanvas

View File

@ -0,0 +1,101 @@
import isInBounds from '../../utils/isInBounds'
import { ipc } from '../../wailsjs/wailsjs/go/models'
type MouseCoordinates = {
startMouseX: number, startMouseY: number, endMouseX: number, endMouseY: number
}
type RectangleCoordinates = {
startX: number, startY: number, endX: number, endY: number
}
type AddAreaToStoreCallback =
(startX: number, startY: number, endX: number, endY: number)
=> Promise<void>
type SetZoomCallback = (newZoomLevel: number) => void
type ZoomDetails = {
currentZoomLevel: number,
maxZoomLevel: number,
zoomStep: number
}
type HoverOverAreaCallback = (areaId?: string) => void
/**
* @param uiCanvas
* @returns Various methods to be called during events on the `UiCanvas`.
* Dependencies must be injected, such as state change callbacks.
*/
const createUiCanvasInteractions = (uiCanvas: HTMLCanvasElement) => {
const uiCanvasContext = uiCanvas.getContext('2d')!
return {
onActivelyDrawArea: (coordinates: RectangleCoordinates) => {
const { startX, startY, endX, endY } = coordinates
uiCanvasContext.clearRect(0, 0, uiCanvas.width, uiCanvas.height)
uiCanvasContext.beginPath()
const width = endX - startX
const height = endY - startY
uiCanvasContext.rect(startX, startY, width, height)
uiCanvasContext.strokeStyle = '#000'
uiCanvasContext.lineWidth = 2
uiCanvasContext.stroke()
},
onFinishDrawArea: (coordinates: MouseCoordinates, zoomLevel: number, addAreaToStoreCallback: AddAreaToStoreCallback) => {
let { startMouseX, endMouseX, startMouseY, endMouseY } = coordinates
let startX: number, endX: number
if (startMouseX < endMouseX) {
startX = Math.floor(startMouseX / zoomLevel)
endX = Math.floor(endMouseX / zoomLevel)
} else {
startX = Math.floor(endMouseX / zoomLevel)
endX = Math.floor(startMouseX / zoomLevel)
}
let startY: number, endY: number
if (startMouseY < endMouseY) {
startY = Math.floor(startMouseY / zoomLevel)
endY = Math.floor(endMouseY / zoomLevel)
} else {
startY = Math.floor(endMouseY / zoomLevel)
endY = Math.floor(startMouseY / zoomLevel)
}
addAreaToStoreCallback(startX, startY, endX, endY)
},
onZoom: (wheelDelta: number, zoomDetails: ZoomDetails, setZoomCallBack: SetZoomCallback) => {
const { currentZoomLevel, maxZoomLevel, zoomStep } = zoomDetails
const shouldAttemptToZoomIn = (wheelDelta < 0) && currentZoomLevel < maxZoomLevel
if (shouldAttemptToZoomIn) setZoomCallBack(currentZoomLevel + zoomStep)
else if (currentZoomLevel > (zoomStep * 2)) setZoomCallBack(currentZoomLevel - zoomStep)
},
onHoverOverArea: (mouseX: number, mouseY: number, zoomLevel: number, areas: ipc.Area[], callback: HoverOverAreaCallback) => {
if (!areas.length) return
const domRect = uiCanvas.getBoundingClientRect()
const x = mouseX - domRect.left
const y = mouseY - domRect.top
const point = { x, y }
const areaContainingCoords = areas.find(a => {
const bounds = {
startX: a.startX,
startY: a.startY,
endX: a.endX,
endY: a.endY
}
return isInBounds(point, bounds, zoomLevel)
})
callback(areaContainingCoords?.id)
},
}
}
export default createUiCanvasInteractions

View File

@ -0,0 +1,58 @@
'use client'
import React, { useState } from 'react'
import { useProject, } from '../../context/Project/provider'
import { MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon } from '@heroicons/react/24/outline'
import classNames from '../../utils/classNames'
import LanguageSelect from '../workspace/LanguageSelect'
import ImageCanvas from './ImageCanvas'
import AreaCanvas from './AreaCanvas'
import UiCanvas from './UiCanvas'
const zoomStep = 0.025
const maxZoomLevel = 4
const DocumentCanvas = () => {
const { getSelectedDocument } = useProject()
const selectedDocument = getSelectedDocument()
const [zoomLevel, setZoomLevel] = useState(1)
const [size, setSize] = useState({ width: 0, height: 0 })
const { width, height } = size
return <div className='relative'>
<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>
<div className={classNames('relative mt-2 overflow-scroll',
'w-[calc(100vw-320px)] h-[calc(100vh-174px)] border-4',
'border-dashed border-gray-200')}>
<ImageCanvas imagePath={selectedDocument?.path} zoomLevel={zoomLevel} setSize={setSize} />
<AreaCanvas width={width} height={height} zoomLevel={zoomLevel} />
<UiCanvas
width={width}
height={height}
setZoomLevel={setZoomLevel}
zoomDetails={{
currentZoomLevel: zoomLevel,
maxZoomLevel: maxZoomLevel,
zoomStep: zoomStep,
}} />
</div>
</div >
}
export default DocumentCanvas

View File

@ -1,412 +0,0 @@
'use client'
import { MagnifyingGlassMinusIcon, MagnifyingGlassPlusIcon } from '@heroicons/react/24/outline'
import React, { useEffect, useRef, useState, WheelEvent } from 'react'
import { useProject } from '../../context/Project/provider'
import loadImage from '../../useCases/loadImage'
import processImageArea from '../../useCases/processImageArea'
import classNames from '../../utils/classNames'
import LanguageSelect from './LanguageSelect'
import isInBounds from '../../utils/isInBounds'
import { ipc } from '../../wailsjs/wailsjs/go/models'
import onEnterHandler from '../../utils/onEnterHandler'
const zoomStep = 0.025
const maxZoomLevel = 4
const DocumentRenderer = () => {
const {
getSelectedDocument,
requestAddArea,
selectedAreaId,
setSelectedAreaId,
getProcessedAreasByDocumentId,
requestUpdateProcessedWordById
} = useProject()
const selectedDocument = getSelectedDocument()
const areas = selectedDocument?.areas
const documentCanvas = useRef<HTMLCanvasElement>(null)
const areaCanvas = useRef<HTMLCanvasElement>(null)
const uiCanvas = useRef<HTMLCanvasElement>(null)
const drawingCanvas = useRef<HTMLCanvasElement>(null)
const editWordInput = useRef<HTMLInputElement>(null)
const [zoomLevel, setZoomLevel] = useState(1)
const [hoverOverAreaId, setHoverOverAreaId] = useState('')
const [hoveredProcessedArea, setHoveredProcessedArea] = useState<ipc.ProcessedArea | undefined>()
const [wordToEdit, setWordToEdit] = useState<{ word: ipc.ProcessedWord, areaId: string } | undefined>()
let downClickX = 0
let downClickY = 0
let isDrawing = false
const applyCanvasSizes = (size: { width: number, height: number }) => {
const documentCanvasInstance = documentCanvas.current
if (!documentCanvasInstance) return
documentCanvasInstance.width = size.width
documentCanvasInstance.height = size.height
const areaCanvasInstance = areaCanvas.current
if (!areaCanvasInstance) return
areaCanvasInstance.width = size.width
areaCanvasInstance.height = size.height
const uiCanvasInstance = uiCanvas.current
if (!uiCanvasInstance) return
uiCanvasInstance.width = size.width
uiCanvasInstance.height = size.height
const drawingCanvasInstance = drawingCanvas.current
if (!drawingCanvasInstance) return
drawingCanvasInstance.width = size.width
drawingCanvasInstance.height = size.height
}
const applyDocumentToCanvas = async (path: string) => {
let image: HTMLImageElement
try {
image = await loadImage(path)
} catch (err) {
return
}
const width = image.naturalWidth * zoomLevel
const height = image.naturalHeight * zoomLevel
applyCanvasSizes({ width, height })
const documentCanvasInstance = documentCanvas.current
if (!documentCanvasInstance) return
const context = documentCanvasInstance.getContext('2d')
if (!context) return
context.drawImage(image, 0, 0, width, height)
if (areas) applyAreasToCanvas()
applyUiCanvasUpdates()
}
const applyAreasToCanvas = () => {
const areaCanvasInstance = areaCanvas.current
if (!areaCanvasInstance) return
const context = areaCanvasInstance.getContext('2d')!
if (!context) return
context.clearRect(0, 0, areaCanvasInstance.width, areaCanvasInstance.height)
if (!areas || !areas.length) return
areas.forEach(a => {
context.beginPath()
if (a.id !== selectedAreaId) {
context.setLineDash([4])
context.lineWidth = 2
context.strokeStyle = '#010101'
} else {
context.setLineDash([])
context.lineWidth = 3
context.strokeStyle = '#dc8dec'
}
const width = (a.endX - a.startX) * zoomLevel
const height = (a.endY - a.startY) * zoomLevel
const x = a.startX * zoomLevel
const y = a.startY * zoomLevel
context.roundRect(x, y, width, height, 4)
context.stroke()
context.closePath()
})
}
const applyUiCanvasUpdates = () => {
const uiCanvasInstance = uiCanvas.current
if (!uiCanvasInstance) return
const context = uiCanvasInstance.getContext('2d')!
if (!context) return
context.clearRect(0, 0, uiCanvasInstance.width, uiCanvasInstance.height)
if (!areas || !areas.length) return
const hoverArea = areas.find(a => a.id === hoverOverAreaId)
if (!hoverArea) return
context.beginPath()
context.setLineDash([])
context.lineWidth = 6
context.strokeStyle = '#dc8dec'
const width = (hoverArea.endX - hoverArea.startX) * zoomLevel
const height = (hoverArea.endY - hoverArea.startY) * zoomLevel
const x = hoverArea.startX * zoomLevel
const y = hoverArea.startY * zoomLevel
context.roundRect(x, y, width, height, 4)
context.stroke()
context.closePath()
}
const getProcessedAreaById = async (areaId: string) => {
try {
if (!selectedDocument || !selectedDocument.id || !areaId) return
const processedAreas = await getProcessedAreasByDocumentId(selectedDocument.id)
const foundProcessedArea = processedAreas.find(a => a.id === areaId)
console.log(foundProcessedArea)
return foundProcessedArea
} catch (err) {
console.error(err)
return
}
}
const handleHoverOverArea = (e: React.MouseEvent) => {
const domRect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - domRect.left
const y = e.clientY - domRect.top
const point = { x, y }
const areaContainingCoords = areas?.find(a => {
const bounds = { startX: a.startX, startY: a.startY, endX: a.endX, endY: a.endY }
return isInBounds(point, bounds, zoomLevel)
})
if (areaContainingCoords?.id === hoverOverAreaId) return
setHoverOverAreaId(areaContainingCoords?.id || '')
getProcessedAreaById(areaContainingCoords?.id || '').then(response => {
setHoveredProcessedArea(response)
})
}
const handleMouseDrawArea = (x: number, y: number) => {
const drawingCanvasInstance = drawingCanvas.current
if (!drawingCanvasInstance) return
const context = drawingCanvasInstance.getContext('2d')
if (!context) return
context.clearRect(0, 0, drawingCanvasInstance.width, drawingCanvasInstance.height)
context.beginPath()
const width = x - downClickX
const height = y - downClickY
context.rect(downClickX, downClickY, width, height)
context.strokeStyle = '#000'
context.lineWidth = 2
context.stroke()
}
const handleMouseDown = (e: React.MouseEvent) => {
console.log(e)
if (e.nativeEvent.shiftKey) {
const drawingCanvasInstance = drawingCanvas.current
if (!drawingCanvasInstance) return
downClickX = e.nativeEvent.offsetX
downClickY = e.nativeEvent.offsetY
isDrawing = true
}
}
const handleMouseUp = async (e: React.MouseEvent) => {
if (isDrawing) {
const drawingCanvasInstance = drawingCanvas.current
if (!drawingCanvasInstance) return
const mouseX = e.nativeEvent.offsetX
const mouseY = e.nativeEvent.offsetY
let startX: number, endX: number
if (downClickX < mouseX) {
startX = Math.floor(downClickX / zoomLevel)
endX = Math.floor(mouseX / zoomLevel)
} else {
startX = Math.floor(mouseX / zoomLevel)
endX = Math.floor(downClickX / zoomLevel)
}
let startY: number, endY: number
if (downClickY < mouseY) {
startY = Math.floor(downClickY / zoomLevel)
endY = Math.floor(mouseY / zoomLevel)
} else {
startY = Math.floor(mouseY / zoomLevel)
endY = Math.floor(downClickY / zoomLevel)
}
if (selectedDocument?.id) {
const addedArea = await requestAddArea(selectedDocument.id, { startX, startY, endX, endY })
setSelectedAreaId(addedArea.id)
processImageArea(selectedDocument.id, addedArea.id)
}
const context = drawingCanvasInstance.getContext('2d')
context?.clearRect(0, 0, drawingCanvasInstance.width, drawingCanvasInstance.height)
isDrawing = false
downClickX = 0
downClickY = 0
}
}
const handleMouseMove = (e: React.MouseEvent) => {
let mouseX = e.nativeEvent.offsetX
let mouseY = e.nativeEvent.offsetY
if (isDrawing) handleMouseDrawArea(mouseX, mouseY)
else handleHoverOverArea(e)
}
const handleWheelEvent = (e: WheelEvent<HTMLDivElement>) => {
if (!e.ctrlKey) return
const shouldAttemptToZoomIn = (e.deltaY < 0) && zoomLevel < maxZoomLevel
if (shouldAttemptToZoomIn) setZoomLevel(zoomLevel + zoomStep)
else if (zoomLevel > (zoomStep * 2)) setZoomLevel(zoomLevel - zoomStep)
}
const handleWordCorrectionSubmit = (wordId: string, newWordValue: string) => {
console.log(newWordValue)
requestUpdateProcessedWordById(wordId, newWordValue)
.then(res => {
console.log('res', res)
getProcessedAreaById(hoverOverAreaId || '').then(response => {
setHoveredProcessedArea(response)
})
})
.catch(console.error)
setWordToEdit(undefined)
}
useEffect(() => {
if (selectedDocument?.path) applyDocumentToCanvas(selectedDocument.path)
})
useEffect(() => {
applyUiCanvasUpdates()
}, [hoverOverAreaId])
const renderAreaPreview = () => {
if (!areas || !areas.length || !hoveredProcessedArea) return <></>
const hoverArea = areas.find(a => a.id === hoverOverAreaId)
if (!hoverArea) return <></>
return <div>
{
hoveredProcessedArea.lines?.map(l => l.words).flat().map((w, i) => {
const width = Math.floor((w.boundingBox.x1 - w.boundingBox.x0) * zoomLevel) + 2
const height = Math.floor((w.boundingBox.y1 - w.boundingBox.y0) * zoomLevel) + 2
return <span
key={i}
dir={w.direction === 'RIGHT_TO_LEFT' ? 'rtl' : 'ltr'}
className={classNames('absolute text-center inline-block p-1 rounded-md shadow-zinc-900 shadow-2xl',
'hover:bg-opacity-60 hover:bg-black hover:text-white',
'bg-opacity-80 bg-slate-300 text-slate-500'
)}
style={{
fontSize: `${3.4 * zoomLevel}vmin`,
width,
top: Math.floor(w.boundingBox.y0 * zoomLevel) + height,
left: Math.floor(w.boundingBox.x0 * zoomLevel)
}}
onDoubleClick={() => setWordToEdit({ word: w, areaId: hoverArea.id })}>
{w.fullText}
</span>
})
}
</div>
}
const renderEditWord = () => {
if (!wordToEdit) return <></>
const { word, areaId } = wordToEdit
const width = Math.floor((word.boundingBox.x1 - word.boundingBox.x0) * zoomLevel) + 2
const height = Math.floor(((word.boundingBox.y1 - word.boundingBox.y0) * zoomLevel) * 2) + 4
return <div
dir={word.direction === 'RIGHT_TO_LEFT' ? 'rtl' : 'ltr'}
className={classNames('absolute inline-block p-1 rounded-md',
'bg-opacity-60 bg-black text-white',
)}
style={{
width,
height,
top: Math.floor(word.boundingBox.y0 * zoomLevel) + (height / 2),
left: Math.floor(word.boundingBox.x0 * zoomLevel)
}}
onBlur={() => setWordToEdit(undefined)}
>
<div
className={classNames('text-center align-middle block p-1 rounded-md shadow-zinc-900 shadow-2xl',
'bg-opacity-60 bg-black text-white',
)}
style={{
fontSize: `${3.4 * zoomLevel}vmin`,
height: height / 2,
}}>
{word.fullText}
</div>
<input
type='text'
className='inline-block text-slate-900 p-0 m-0 w-full'
autoFocus
width={width}
ref={editWordInput}
placeholder={word.fullText}
defaultValue={word.fullText}
style={{
fontSize: `${3.4 * zoomLevel}vmin`,
height: height / 2,
}}
onFocus={(e) => e.currentTarget.select()}
onBlur={(e) => handleWordCorrectionSubmit(word.id, e.currentTarget.value)}
onKeyDown={(e) => onEnterHandler(e, () => handleWordCorrectionSubmit(word.id, e.currentTarget.value))}
/>
</div>
}
return <div className='relative'>
<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">{getSelectedDocument()?.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>
<div
onWheelCapture={handleWheelEvent}
className={classNames('relative mt-2 overflow-scroll',
'w-[calc(100vw-320px)] h-[calc(100vh-174px)] border-4',
'border-dashed border-gray-200')}>
<canvas
className="absolute"
ref={documentCanvas}
/>
<canvas
className="absolute"
ref={areaCanvas}
/>
<canvas
className="absolute"
ref={uiCanvas}
/>
<canvas
className="absolute"
ref={drawingCanvas}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseMove={handleMouseMove}
/>
{renderAreaPreview()}
{renderEditWord()}
</div>
</div >
}
export default DocumentRenderer

View File

@ -3,7 +3,7 @@
import { useNavigation } from '../../context/Navigation/provider'
import { workspaces } from '../../context/Navigation/types'
import { useProject } from '../../context/Project/provider'
import DocumentRenderer from './DocumentRenderer'
import DocumentCanvas from '../DocumentCanvas'
import NoSelectedDocument from './NoSelectedDocument'
import TextEditor from './TextEditor'
@ -13,7 +13,7 @@ const MainWorkspace = () => {
const renderSelectedWorkSpace = () => {
if (selectedWorkspace === workspaces.TEXTEDITOR) return <TextEditor />
else return !selectedDocumentId ? <NoSelectedDocument /> : <DocumentRenderer />
else return !selectedDocumentId ? <NoSelectedDocument /> : <DocumentCanvas />
}
return <main className=" bg-gray-100 min-h-[calc(100vh-118px)] ml-64 overflow-y-scroll">

View File

@ -31,6 +31,7 @@ const makeDefaultProject = (): ProjectContextType => ({
getGroupById: (groupId) => undefined,
requestSelectProjectByName: (projectName) => Promise.resolve(false),
requestUpdateProcessedWordById: (wordId, newTestValue) => Promise.resolve(false),
getProcessedAreaById: (areaId) => Promise.resolve(undefined),
})
export default makeDefaultProject

View File

@ -189,6 +189,18 @@ export function ProjectProvider({ children, projectProps }: Props) {
return successfulResponse
}
const getProcessedAreaById = async (areaId: string) => {
try {
if (!selectedDocumentId || !areaId) return
const processedAreas = await getProcessedAreasByDocumentId(selectedDocumentId)
const foundProcessedArea = processedAreas.find(a => a.id === areaId)
return foundProcessedArea
} catch (err) {
console.error(err)
return Promise.resolve(undefined)
}
}
useEffect(() => {
if (!documents.length && !groups.length) updateDocuments()
@ -231,6 +243,7 @@ export function ProjectProvider({ children, projectProps }: Props) {
getGroupById,
requestSelectProjectByName,
requestUpdateProcessedWordById,
getProcessedAreaById,
}
return <ProjectContext.Provider value={value}>

View File

@ -63,4 +63,5 @@ export type ProjectContextType = {
getGroupById: (groupId: string) => ipc.Group | undefined
requestSelectProjectByName: (projectName: string) => Promise<boolean>
requestUpdateProcessedWordById: (wordId: string, newTextValue: string) => Promise<boolean>
getProcessedAreaById: (areaId: string) => Promise<ipc.ProcessedArea | undefined>
} & ProjectProps

View File

@ -32,6 +32,7 @@
"postcss": "^8.4.19",
"server-only": "^0.0.1",
"tailwindcss": "^3.2.4",
"typedoc": "^0.24.7",
"typescript": "^4.9.4"
}
},
@ -971,6 +972,12 @@
"node": ">=8"
}
},
"node_modules/ansi-sequence-parser": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.0.tgz",
"integrity": "sha512-lEm8mt52to2fT8GhciPCGeCXACSz2UwIN4X2e2LJSnZ5uAbn2/dsYdOmUXq0AtWS5cpAupysIneExOgH0Vd2TQ==",
"dev": true
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@ -3050,6 +3057,12 @@
"json5": "lib/cli.js"
}
},
"node_modules/jsonc-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==",
"dev": true
},
"node_modules/jsx-ast-utils": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz",
@ -3148,6 +3161,24 @@
"node": ">=10"
}
},
"node_modules/lunr": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
"dev": true
},
"node_modules/marked": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
"dev": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 12"
}
},
"node_modules/mdast-util-definitions": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz",
@ -4601,6 +4632,18 @@
"node": ">=8"
}
},
"node_modules/shiki": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.2.tgz",
"integrity": "sha512-ltSZlSLOuSY0M0Y75KA+ieRaZ0Trf5Wl3gutE7jzLuIcWxLp5i/uEnLoQWNvgKXQ5OMpGkJnVMRLAuzjc0LJ2A==",
"dev": true,
"dependencies": {
"ansi-sequence-parser": "^1.1.0",
"jsonc-parser": "^3.2.0",
"vscode-oniguruma": "^1.7.0",
"vscode-textmate": "^8.0.0"
}
},
"node_modules/side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
@ -4984,6 +5027,51 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typedoc": {
"version": "0.24.7",
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.24.7.tgz",
"integrity": "sha512-zzfKDFIZADA+XRIp2rMzLe9xZ6pt12yQOhCr7cD7/PBTjhPmMyMvGrkZ2lPNJitg3Hj1SeiYFNzCsSDrlpxpKw==",
"dev": true,
"dependencies": {
"lunr": "^2.3.9",
"marked": "^4.3.0",
"minimatch": "^9.0.0",
"shiki": "^0.14.1"
},
"bin": {
"typedoc": "bin/typedoc"
},
"engines": {
"node": ">= 14.14"
},
"peerDependencies": {
"typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x"
}
},
"node_modules/typedoc/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/typedoc/node_modules/minimatch": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/typescript": {
"version": "4.9.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",
@ -5204,6 +5292,18 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/vscode-oniguruma": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz",
"integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==",
"dev": true
},
"node_modules/vscode-textmate": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz",
"integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==",
"dev": true
},
"node_modules/wasm-feature-detect": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.3.0.tgz",
@ -5954,6 +6054,12 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
"ansi-sequence-parser": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/ansi-sequence-parser/-/ansi-sequence-parser-1.1.0.tgz",
"integrity": "sha512-lEm8mt52to2fT8GhciPCGeCXACSz2UwIN4X2e2LJSnZ5uAbn2/dsYdOmUXq0AtWS5cpAupysIneExOgH0Vd2TQ==",
"dev": true
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
@ -7428,6 +7534,12 @@
"minimist": "^1.2.0"
}
},
"jsonc-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
"integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==",
"dev": true
},
"jsx-ast-utils": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.3.tgz",
@ -7502,6 +7614,18 @@
"yallist": "^4.0.0"
}
},
"lunr": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
"integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==",
"dev": true
},
"marked": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz",
"integrity": "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==",
"dev": true
},
"mdast-util-definitions": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz",
@ -8385,6 +8509,18 @@
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
},
"shiki": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-0.14.2.tgz",
"integrity": "sha512-ltSZlSLOuSY0M0Y75KA+ieRaZ0Trf5Wl3gutE7jzLuIcWxLp5i/uEnLoQWNvgKXQ5OMpGkJnVMRLAuzjc0LJ2A==",
"dev": true,
"requires": {
"ansi-sequence-parser": "^1.1.0",
"jsonc-parser": "^3.2.0",
"vscode-oniguruma": "^1.7.0",
"vscode-textmate": "^8.0.0"
}
},
"side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
@ -8664,6 +8800,38 @@
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="
},
"typedoc": {
"version": "0.24.7",
"resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.24.7.tgz",
"integrity": "sha512-zzfKDFIZADA+XRIp2rMzLe9xZ6pt12yQOhCr7cD7/PBTjhPmMyMvGrkZ2lPNJitg3Hj1SeiYFNzCsSDrlpxpKw==",
"dev": true,
"requires": {
"lunr": "^2.3.9",
"marked": "^4.3.0",
"minimatch": "^9.0.0",
"shiki": "^0.14.1"
},
"dependencies": {
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0"
}
},
"minimatch": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
"dev": true,
"requires": {
"brace-expansion": "^2.0.1"
}
}
}
},
"typescript": {
"version": "4.9.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz",
@ -8809,6 +8977,18 @@
"unist-util-stringify-position": "^3.0.0"
}
},
"vscode-oniguruma": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-1.7.0.tgz",
"integrity": "sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==",
"dev": true
},
"vscode-textmate": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-8.0.0.tgz",
"integrity": "sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==",
"dev": true
},
"wasm-feature-detect": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.3.0.tgz",

View File

@ -37,6 +37,7 @@
"postcss": "^8.4.19",
"server-only": "^0.0.1",
"tailwindcss": "^3.2.4",
"typedoc": "^0.24.7",
"typescript": "^4.9.4"
}
}

View File

@ -1 +1 @@
e8bab26469d3f6b725d33cef9359048b
2415a78ef8f325df057b22f577cbbe50