Mayo commited on
refactor(ui): rewrite hooks around the new scene snapshot + selection store
Browse files- ui/hooks/useBlobData.ts +5 -16
- ui/hooks/useBlockContextMenu.ts +34 -30
- ui/hooks/useBlockDrafting.ts +45 -60
- ui/hooks/useBrushCursor.ts +3 -2
- ui/hooks/useBrushLayerDisplay.ts +16 -28
- ui/hooks/useCanvasDrawing.ts +37 -43
- ui/hooks/useCanvasZoom.ts +7 -15
- ui/hooks/useCurrentPage.ts +127 -0
- ui/hooks/useKeyboardShortcuts.ts +21 -2
- ui/hooks/useMaskDrawing.ts +46 -49
- ui/hooks/useRenderBrushDrawing.ts +27 -24
- ui/hooks/useScene.ts +32 -0
- ui/hooks/useTextBlocks.ts +0 -183
ui/hooks/useBlobData.ts
CHANGED
|
@@ -2,14 +2,14 @@
|
|
| 2 |
|
| 3 |
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
| 4 |
|
| 5 |
-
import { getBlob } from '@/lib/api/
|
| 6 |
-
import { convertToBlob } from '@/lib/
|
| 7 |
|
| 8 |
const blobQueryOptions = (hash: string) => ({
|
| 9 |
queryKey: ['blob', hash] as const,
|
| 10 |
queryFn: async () => {
|
| 11 |
const blob = await getBlob(hash)
|
| 12 |
-
const buf = await blob.arrayBuffer()
|
| 13 |
return new Uint8Array(buf)
|
| 14 |
},
|
| 15 |
staleTime: Infinity,
|
|
@@ -31,11 +31,10 @@ const blobImageQueryOptions = (hash: string) => ({
|
|
| 31 |
queryKey: ['blobImage', hash] as const,
|
| 32 |
queryFn: async () => {
|
| 33 |
const response = await getBlob(hash)
|
| 34 |
-
const buf = await response.arrayBuffer()
|
| 35 |
const bytes = new Uint8Array(buf)
|
| 36 |
const blob = await convertToBlob(bytes)
|
| 37 |
const url = URL.createObjectURL(blob)
|
| 38 |
-
// Preload: wait until the browser has fully decoded the image.
|
| 39 |
await new Promise<void>((resolve, reject) => {
|
| 40 |
const img = new Image()
|
| 41 |
img.onload = () => resolve()
|
|
@@ -51,8 +50,7 @@ const blobImageQueryOptions = (hash: string) => ({
|
|
| 51 |
|
| 52 |
/**
|
| 53 |
* Fetch blob, convert to displayable format, and preload — returns a
|
| 54 |
-
* ready-to-paint object URL. Keeps the previous URL while a new one loads
|
| 55 |
-
* with `isPlaceholderData` indicating whether it's stale.
|
| 56 |
*/
|
| 57 |
export function useBlobImage(hash: string | undefined) {
|
| 58 |
return useQuery({
|
|
@@ -61,12 +59,3 @@ export function useBlobImage(hash: string | undefined) {
|
|
| 61 |
placeholderData: keepPreviousData,
|
| 62 |
})
|
| 63 |
}
|
| 64 |
-
|
| 65 |
-
/** Fetch a document layer's raw bytes by its blob hash. */
|
| 66 |
-
export function useDocumentLayer(
|
| 67 |
-
_documentId: string | undefined,
|
| 68 |
-
_layer: string,
|
| 69 |
-
hash: string | undefined,
|
| 70 |
-
): Uint8Array | undefined {
|
| 71 |
-
return useBlobData(hash)
|
| 72 |
-
}
|
|
|
|
| 2 |
|
| 3 |
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
| 4 |
|
| 5 |
+
import { getBlob } from '@/lib/api/default/default'
|
| 6 |
+
import { convertToBlob } from '@/lib/io/blobConvert'
|
| 7 |
|
| 8 |
const blobQueryOptions = (hash: string) => ({
|
| 9 |
queryKey: ['blob', hash] as const,
|
| 10 |
queryFn: async () => {
|
| 11 |
const blob = await getBlob(hash)
|
| 12 |
+
const buf = await (blob as Blob).arrayBuffer()
|
| 13 |
return new Uint8Array(buf)
|
| 14 |
},
|
| 15 |
staleTime: Infinity,
|
|
|
|
| 31 |
queryKey: ['blobImage', hash] as const,
|
| 32 |
queryFn: async () => {
|
| 33 |
const response = await getBlob(hash)
|
| 34 |
+
const buf = await (response as Blob).arrayBuffer()
|
| 35 |
const bytes = new Uint8Array(buf)
|
| 36 |
const blob = await convertToBlob(bytes)
|
| 37 |
const url = URL.createObjectURL(blob)
|
|
|
|
| 38 |
await new Promise<void>((resolve, reject) => {
|
| 39 |
const img = new Image()
|
| 40 |
img.onload = () => resolve()
|
|
|
|
| 50 |
|
| 51 |
/**
|
| 52 |
* Fetch blob, convert to displayable format, and preload — returns a
|
| 53 |
+
* ready-to-paint object URL. Keeps the previous URL while a new one loads.
|
|
|
|
| 54 |
*/
|
| 55 |
export function useBlobImage(hash: string | undefined) {
|
| 56 |
return useQuery({
|
|
|
|
| 59 |
placeholderData: keepPreviousData,
|
| 60 |
})
|
| 61 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ui/hooks/useBlockContextMenu.ts
CHANGED
|
@@ -3,62 +3,66 @@
|
|
| 3 |
import { useState } from 'react'
|
| 4 |
import type React from 'react'
|
| 5 |
|
|
|
|
| 6 |
import type { PointerToDocumentFn } from '@/hooks/usePointerToDocument'
|
| 7 |
-
import type {
|
| 8 |
|
| 9 |
type BlockContextMenuOptions = {
|
| 10 |
-
|
| 11 |
pointerToDocument: PointerToDocumentFn
|
| 12 |
-
|
| 13 |
-
|
| 14 |
}
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
export function useBlockContextMenu({
|
| 17 |
-
|
| 18 |
pointerToDocument,
|
| 19 |
-
|
| 20 |
-
|
| 21 |
}: BlockContextMenuOptions) {
|
| 22 |
-
const [
|
| 23 |
|
| 24 |
const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
|
| 25 |
-
if (!
|
| 26 |
const point = pointerToDocument(event)
|
| 27 |
if (!point) {
|
| 28 |
event.preventDefault()
|
| 29 |
-
|
| 30 |
-
|
| 31 |
return
|
| 32 |
}
|
| 33 |
-
const
|
| 34 |
-
(
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
point.y <=
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
| 43 |
} else {
|
| 44 |
event.preventDefault()
|
| 45 |
-
|
| 46 |
-
|
| 47 |
}
|
| 48 |
}
|
| 49 |
|
| 50 |
const handleDeleteBlock = () => {
|
| 51 |
-
if (
|
| 52 |
-
|
| 53 |
-
|
| 54 |
}
|
| 55 |
|
| 56 |
-
const clearContextMenu = () =>
|
| 57 |
-
setContextMenuBlockIndex(undefined)
|
| 58 |
-
}
|
| 59 |
|
| 60 |
return {
|
| 61 |
-
|
| 62 |
handleContextMenu,
|
| 63 |
handleDeleteBlock,
|
| 64 |
clearContextMenu,
|
|
|
|
| 3 |
import { useState } from 'react'
|
| 4 |
import type React from 'react'
|
| 5 |
|
| 6 |
+
import { isTextNode } from '@/hooks/useCurrentPage'
|
| 7 |
import type { PointerToDocumentFn } from '@/hooks/usePointerToDocument'
|
| 8 |
+
import type { Page } from '@/lib/api/schemas'
|
| 9 |
|
| 10 |
type BlockContextMenuOptions = {
|
| 11 |
+
page: Page | null
|
| 12 |
pointerToDocument: PointerToDocumentFn
|
| 13 |
+
onSelect: (nodeId: string | null) => void
|
| 14 |
+
onRemove: (nodeId: string) => void
|
| 15 |
}
|
| 16 |
|
| 17 |
+
/**
|
| 18 |
+
* Right-click on a text node pops the context menu. `onSelect(null)` / clear
|
| 19 |
+
* on empty-space right-click. `onRemove(id)` triggered by menu's delete item.
|
| 20 |
+
*/
|
| 21 |
export function useBlockContextMenu({
|
| 22 |
+
page,
|
| 23 |
pointerToDocument,
|
| 24 |
+
onSelect,
|
| 25 |
+
onRemove,
|
| 26 |
}: BlockContextMenuOptions) {
|
| 27 |
+
const [contextMenuNodeId, setContextMenuNodeId] = useState<string | null>(null)
|
| 28 |
|
| 29 |
const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
|
| 30 |
+
if (!page) return
|
| 31 |
const point = pointerToDocument(event)
|
| 32 |
if (!point) {
|
| 33 |
event.preventDefault()
|
| 34 |
+
setContextMenuNodeId(null)
|
| 35 |
+
onSelect(null)
|
| 36 |
return
|
| 37 |
}
|
| 38 |
+
const hitId = Object.entries(page.nodes).find(([, n]) => {
|
| 39 |
+
if (!isTextNode(n)) return false
|
| 40 |
+
const t = n.transform
|
| 41 |
+
if (!t) return false
|
| 42 |
+
return (
|
| 43 |
+
point.x >= t.x && point.x <= t.x + t.width && point.y >= t.y && point.y <= t.y + t.height
|
| 44 |
+
)
|
| 45 |
+
})?.[0]
|
| 46 |
+
if (hitId) {
|
| 47 |
+
onSelect(hitId)
|
| 48 |
+
setContextMenuNodeId(hitId)
|
| 49 |
} else {
|
| 50 |
event.preventDefault()
|
| 51 |
+
setContextMenuNodeId(null)
|
| 52 |
+
onSelect(null)
|
| 53 |
}
|
| 54 |
}
|
| 55 |
|
| 56 |
const handleDeleteBlock = () => {
|
| 57 |
+
if (!contextMenuNodeId) return
|
| 58 |
+
onRemove(contextMenuNodeId)
|
| 59 |
+
setContextMenuNodeId(null)
|
| 60 |
}
|
| 61 |
|
| 62 |
+
const clearContextMenu = () => setContextMenuNodeId(null)
|
|
|
|
|
|
|
| 63 |
|
| 64 |
return {
|
| 65 |
+
contextMenuNodeId,
|
| 66 |
handleContextMenu,
|
| 67 |
handleDeleteBlock,
|
| 68 |
clearContextMenu,
|
ui/hooks/useBlockDrafting.ts
CHANGED
|
@@ -3,82 +3,79 @@
|
|
| 3 |
import { useDrag } from '@use-gesture/react'
|
| 4 |
import { useRef, useState } from 'react'
|
| 5 |
|
| 6 |
-
import type {
|
| 7 |
-
import type {
|
| 8 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
type BlockDraftingOptions = {
|
| 11 |
mode: ToolMode
|
| 12 |
-
|
| 13 |
pointerToDocument: PointerToDocumentFn
|
| 14 |
clearSelection: () => void
|
| 15 |
-
onCreateBlock: (
|
| 16 |
}
|
| 17 |
|
| 18 |
export function useBlockDrafting({
|
| 19 |
mode,
|
| 20 |
-
|
| 21 |
pointerToDocument,
|
| 22 |
clearSelection,
|
| 23 |
onCreateBlock,
|
| 24 |
}: BlockDraftingOptions) {
|
| 25 |
const dragStartRef = useRef<DocumentPointer | null>(null)
|
| 26 |
-
const
|
| 27 |
-
const [
|
| 28 |
|
| 29 |
-
const
|
| 30 |
dragStartRef.current = null
|
| 31 |
-
|
| 32 |
-
|
| 33 |
}
|
| 34 |
|
| 35 |
-
const
|
| 36 |
if (mode !== 'block') {
|
| 37 |
-
|
| 38 |
return
|
| 39 |
}
|
| 40 |
-
const
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
if (
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
height: Math.round(block.height),
|
| 52 |
-
confidence: block.confidence ?? 1,
|
| 53 |
-
text: block.text,
|
| 54 |
-
translation: block.translation,
|
| 55 |
-
}
|
| 56 |
-
onCreateBlock(normalized)
|
| 57 |
}
|
| 58 |
|
| 59 |
const bind = useDrag(
|
| 60 |
({ first, last, event, active }) => {
|
| 61 |
-
if (!
|
| 62 |
const sourceEvent = event as MouseEvent
|
| 63 |
const point = pointerToDocument(sourceEvent)
|
| 64 |
if (!point) {
|
| 65 |
-
if ((last || !active) &&
|
| 66 |
-
finalizeDraft()
|
| 67 |
-
}
|
| 68 |
return
|
| 69 |
}
|
| 70 |
|
| 71 |
if (first) {
|
| 72 |
dragStartRef.current = point
|
| 73 |
-
const
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
width: 0,
|
| 77 |
-
height: 0,
|
| 78 |
-
confidence: 1,
|
| 79 |
-
}
|
| 80 |
-
draftBlockRef.current = nextDraft
|
| 81 |
-
setDraftBlock(nextDraft)
|
| 82 |
clearSelection()
|
| 83 |
return
|
| 84 |
}
|
|
@@ -89,19 +86,11 @@ export function useBlockDrafting({
|
|
| 89 |
const y = Math.min(start.y, point.y)
|
| 90 |
const width = Math.abs(point.x - start.x)
|
| 91 |
const height = Math.abs(point.y - start.y)
|
| 92 |
-
const
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
width,
|
| 96 |
-
height,
|
| 97 |
-
confidence: 1,
|
| 98 |
-
}
|
| 99 |
-
draftBlockRef.current = nextDraft
|
| 100 |
-
setDraftBlock(nextDraft)
|
| 101 |
|
| 102 |
-
if (last || !active)
|
| 103 |
-
finalizeDraft()
|
| 104 |
-
}
|
| 105 |
},
|
| 106 |
{
|
| 107 |
pointer: { buttons: 1, touch: true },
|
|
@@ -111,9 +100,5 @@ export function useBlockDrafting({
|
|
| 111 |
},
|
| 112 |
)
|
| 113 |
|
| 114 |
-
return {
|
| 115 |
-
draftBlock,
|
| 116 |
-
bind,
|
| 117 |
-
resetDraft,
|
| 118 |
-
}
|
| 119 |
}
|
|
|
|
| 3 |
import { useDrag } from '@use-gesture/react'
|
| 4 |
import { useRef, useState } from 'react'
|
| 5 |
|
| 6 |
+
import type { DocumentPointer, PointerToDocumentFn } from '@/hooks/usePointerToDocument'
|
| 7 |
+
import type { Page } from '@/lib/api/schemas'
|
| 8 |
+
import type { ToolMode } from '@/lib/types'
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Rectangle a user is drawing while `mode === 'block'`. Committed on stroke
|
| 12 |
+
* end via `onCreateBlock` (which dispatches `Op::AddNode` with a text node).
|
| 13 |
+
*/
|
| 14 |
+
export type BlockDraft = {
|
| 15 |
+
x: number
|
| 16 |
+
y: number
|
| 17 |
+
width: number
|
| 18 |
+
height: number
|
| 19 |
+
}
|
| 20 |
|
| 21 |
type BlockDraftingOptions = {
|
| 22 |
mode: ToolMode
|
| 23 |
+
page: Page | null
|
| 24 |
pointerToDocument: PointerToDocumentFn
|
| 25 |
clearSelection: () => void
|
| 26 |
+
onCreateBlock: (draft: BlockDraft) => void
|
| 27 |
}
|
| 28 |
|
| 29 |
export function useBlockDrafting({
|
| 30 |
mode,
|
| 31 |
+
page,
|
| 32 |
pointerToDocument,
|
| 33 |
clearSelection,
|
| 34 |
onCreateBlock,
|
| 35 |
}: BlockDraftingOptions) {
|
| 36 |
const dragStartRef = useRef<DocumentPointer | null>(null)
|
| 37 |
+
const draftRef = useRef<BlockDraft | null>(null)
|
| 38 |
+
const [draft, setDraft] = useState<BlockDraft | null>(null)
|
| 39 |
|
| 40 |
+
const reset = () => {
|
| 41 |
dragStartRef.current = null
|
| 42 |
+
draftRef.current = null
|
| 43 |
+
setDraft(null)
|
| 44 |
}
|
| 45 |
|
| 46 |
+
const finalize = () => {
|
| 47 |
if (mode !== 'block') {
|
| 48 |
+
reset()
|
| 49 |
return
|
| 50 |
}
|
| 51 |
+
const d = draftRef.current
|
| 52 |
+
reset()
|
| 53 |
+
if (!d || !page) return
|
| 54 |
+
const MIN = 4
|
| 55 |
+
if (d.width < MIN || d.height < MIN) return
|
| 56 |
+
onCreateBlock({
|
| 57 |
+
x: Math.round(d.x),
|
| 58 |
+
y: Math.round(d.y),
|
| 59 |
+
width: Math.round(d.width),
|
| 60 |
+
height: Math.round(d.height),
|
| 61 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
|
| 64 |
const bind = useDrag(
|
| 65 |
({ first, last, event, active }) => {
|
| 66 |
+
if (!page || mode !== 'block') return
|
| 67 |
const sourceEvent = event as MouseEvent
|
| 68 |
const point = pointerToDocument(sourceEvent)
|
| 69 |
if (!point) {
|
| 70 |
+
if ((last || !active) && draftRef.current) finalize()
|
|
|
|
|
|
|
| 71 |
return
|
| 72 |
}
|
| 73 |
|
| 74 |
if (first) {
|
| 75 |
dragStartRef.current = point
|
| 76 |
+
const next: BlockDraft = { x: point.x, y: point.y, width: 0, height: 0 }
|
| 77 |
+
draftRef.current = next
|
| 78 |
+
setDraft(next)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
clearSelection()
|
| 80 |
return
|
| 81 |
}
|
|
|
|
| 86 |
const y = Math.min(start.y, point.y)
|
| 87 |
const width = Math.abs(point.x - start.x)
|
| 88 |
const height = Math.abs(point.y - start.y)
|
| 89 |
+
const next: BlockDraft = { x, y, width, height }
|
| 90 |
+
draftRef.current = next
|
| 91 |
+
setDraft(next)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
+
if (last || !active) finalize()
|
|
|
|
|
|
|
| 94 |
},
|
| 95 |
{
|
| 96 |
pointer: { buttons: 1, touch: true },
|
|
|
|
| 100 |
},
|
| 101 |
)
|
| 102 |
|
| 103 |
+
return { draftBlock: draft, bind, resetDraft: reset }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
ui/hooks/useBrushCursor.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
|
| 8 |
export function useBrushCursor(
|
| 9 |
canvasRef: React.RefObject<HTMLDivElement | null>,
|
| 10 |
mode: string,
|
| 11 |
-
|
| 12 |
) {
|
| 13 |
const brushCursorRef = useRef<HTMLDivElement>(null)
|
| 14 |
const cachedRectRef = useRef<DOMRect | null>(null)
|
|
@@ -88,7 +88,8 @@ export function useBrushCursor(
|
|
| 88 |
window.removeEventListener('scroll', refresh, true)
|
| 89 |
window.removeEventListener('resize', refresh)
|
| 90 |
}
|
| 91 |
-
|
|
|
|
| 92 |
|
| 93 |
// Separate effect for visibility to avoid re-attaching listeners
|
| 94 |
useEffect(() => {
|
|
|
|
| 8 |
export function useBrushCursor(
|
| 9 |
canvasRef: React.RefObject<HTMLDivElement | null>,
|
| 10 |
mode: string,
|
| 11 |
+
pageKey?: string,
|
| 12 |
) {
|
| 13 |
const brushCursorRef = useRef<HTMLDivElement>(null)
|
| 14 |
const cachedRectRef = useRef<DOMRect | null>(null)
|
|
|
|
| 88 |
window.removeEventListener('scroll', refresh, true)
|
| 89 |
window.removeEventListener('resize', refresh)
|
| 90 |
}
|
| 91 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 92 |
+
}, [canvasRef, pageKey])
|
| 93 |
|
| 94 |
// Separate effect for visibility to avoid re-attaching listeners
|
| 95 |
useEffect(() => {
|
ui/hooks/useBrushLayerDisplay.ts
CHANGED
|
@@ -2,20 +2,20 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useRef } from 'react'
|
| 4 |
|
| 5 |
-
import type {
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
type BrushLayerDisplayOptions = {
|
| 9 |
-
|
| 10 |
brushLayerData?: Uint8Array
|
| 11 |
visible: boolean
|
| 12 |
}
|
| 13 |
|
| 14 |
-
export function useBrushLayerDisplay({
|
| 15 |
-
currentDocument,
|
| 16 |
-
brushLayerData,
|
| 17 |
-
visible,
|
| 18 |
-
}: BrushLayerDisplayOptions) {
|
| 19 |
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
| 20 |
const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
|
| 21 |
|
|
@@ -25,34 +25,31 @@ export function useBrushLayerDisplay({
|
|
| 25 |
const ctx = canvas.getContext('2d')
|
| 26 |
ctxRef.current = ctx
|
| 27 |
|
| 28 |
-
if (!
|
| 29 |
canvas.width = 0
|
| 30 |
canvas.height = 0
|
| 31 |
ctx?.clearRect(0, 0, canvas.width, canvas.height)
|
| 32 |
return
|
| 33 |
}
|
| 34 |
|
| 35 |
-
const needsResize =
|
| 36 |
-
canvas.width !== currentDocument.width || canvas.height !== currentDocument.height
|
| 37 |
-
|
| 38 |
if (needsResize) {
|
| 39 |
-
canvas.width =
|
| 40 |
-
canvas.height =
|
| 41 |
}
|
| 42 |
|
| 43 |
let cancelled = false
|
| 44 |
-
|
| 45 |
if (visible && brushLayerData) {
|
| 46 |
void (async () => {
|
| 47 |
try {
|
| 48 |
-
const bitmap = await
|
| 49 |
if (cancelled) {
|
| 50 |
bitmap.close()
|
| 51 |
return
|
| 52 |
}
|
| 53 |
ctx?.save()
|
| 54 |
ctx?.clearRect(0, 0, canvas.width, canvas.height)
|
| 55 |
-
ctx?.drawImage(bitmap, 0, 0,
|
| 56 |
ctx?.restore()
|
| 57 |
bitmap.close()
|
| 58 |
} catch (error) {
|
|
@@ -66,16 +63,7 @@ export function useBrushLayerDisplay({
|
|
| 66 |
return () => {
|
| 67 |
cancelled = true
|
| 68 |
}
|
| 69 |
-
}, [
|
| 70 |
-
currentDocument?.id,
|
| 71 |
-
currentDocument?.width,
|
| 72 |
-
currentDocument?.height,
|
| 73 |
-
brushLayerData,
|
| 74 |
-
visible,
|
| 75 |
-
])
|
| 76 |
|
| 77 |
-
return {
|
| 78 |
-
canvasRef,
|
| 79 |
-
visible,
|
| 80 |
-
}
|
| 81 |
}
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useRef } from 'react'
|
| 4 |
|
| 5 |
+
import type { Page } from '@/lib/api/schemas'
|
| 6 |
+
|
| 7 |
+
async function bytesToBitmap(bytes: Uint8Array): Promise<ImageBitmap> {
|
| 8 |
+
const blob = new Blob([bytes as unknown as BlobPart])
|
| 9 |
+
return createImageBitmap(blob)
|
| 10 |
+
}
|
| 11 |
|
| 12 |
type BrushLayerDisplayOptions = {
|
| 13 |
+
page: Page | null
|
| 14 |
brushLayerData?: Uint8Array
|
| 15 |
visible: boolean
|
| 16 |
}
|
| 17 |
|
| 18 |
+
export function useBrushLayerDisplay({ page, brushLayerData, visible }: BrushLayerDisplayOptions) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
| 20 |
const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
|
| 21 |
|
|
|
|
| 25 |
const ctx = canvas.getContext('2d')
|
| 26 |
ctxRef.current = ctx
|
| 27 |
|
| 28 |
+
if (!page) {
|
| 29 |
canvas.width = 0
|
| 30 |
canvas.height = 0
|
| 31 |
ctx?.clearRect(0, 0, canvas.width, canvas.height)
|
| 32 |
return
|
| 33 |
}
|
| 34 |
|
| 35 |
+
const needsResize = canvas.width !== page.width || canvas.height !== page.height
|
|
|
|
|
|
|
| 36 |
if (needsResize) {
|
| 37 |
+
canvas.width = page.width
|
| 38 |
+
canvas.height = page.height
|
| 39 |
}
|
| 40 |
|
| 41 |
let cancelled = false
|
|
|
|
| 42 |
if (visible && brushLayerData) {
|
| 43 |
void (async () => {
|
| 44 |
try {
|
| 45 |
+
const bitmap = await bytesToBitmap(brushLayerData)
|
| 46 |
if (cancelled) {
|
| 47 |
bitmap.close()
|
| 48 |
return
|
| 49 |
}
|
| 50 |
ctx?.save()
|
| 51 |
ctx?.clearRect(0, 0, canvas.width, canvas.height)
|
| 52 |
+
ctx?.drawImage(bitmap, 0, 0, page.width, page.height)
|
| 53 |
ctx?.restore()
|
| 54 |
bitmap.close()
|
| 55 |
} catch (error) {
|
|
|
|
| 63 |
return () => {
|
| 64 |
cancelled = true
|
| 65 |
}
|
| 66 |
+
}, [page?.id, page?.width, page?.height, brushLayerData, visible])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
+
return { canvasRef, visible }
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
ui/hooks/useCanvasDrawing.ts
CHANGED
|
@@ -3,47 +3,43 @@
|
|
| 3 |
import { useDrag } from '@use-gesture/react'
|
| 4 |
import { useEffect, useRef, type RefObject } from 'react'
|
| 5 |
|
| 6 |
-
import { type
|
| 7 |
-
import type {
|
| 8 |
-
|
| 9 |
-
|
|
|
|
| 10 |
|
| 11 |
// ---------------------------------------------------------------------------
|
| 12 |
// Types
|
| 13 |
// ---------------------------------------------------------------------------
|
| 14 |
|
| 15 |
-
type Bounds = {
|
| 16 |
-
minX: number
|
| 17 |
-
minY: number
|
| 18 |
-
maxX: number
|
| 19 |
-
maxY: number
|
| 20 |
-
}
|
| 21 |
|
| 22 |
export type CanvasDrawingConfig = {
|
| 23 |
getColor: () => string
|
| 24 |
blendMode: GlobalCompositeOperation
|
| 25 |
getBrushSize: () => number
|
| 26 |
-
onFinalize: (patch: Uint8Array, region:
|
| 27 |
/** Called after finalize with the full-canvas PNG and the patch region. */
|
| 28 |
-
onFinalizeFullCanvas?: (fullPng: Uint8Array, patchRegion:
|
| 29 |
enabled: boolean
|
| 30 |
/** Optional second canvas to mirror strokes to. */
|
| 31 |
targetCanvasRef?: RefObject<HTMLCanvasElement | null>
|
| 32 |
/** When true, clear the drawing canvas after each stroke finalize. */
|
| 33 |
clearAfterStroke?: boolean
|
| 34 |
-
/**
|
| 35 |
-
onCanvasInit?: (ctx: CanvasRenderingContext2D,
|
| 36 |
}
|
| 37 |
|
| 38 |
// ---------------------------------------------------------------------------
|
| 39 |
// Helpers
|
| 40 |
// ---------------------------------------------------------------------------
|
| 41 |
|
| 42 |
-
const
|
| 43 |
-
if (!
|
| 44 |
return {
|
| 45 |
-
x: Math.max(0, Math.min(
|
| 46 |
-
y: Math.max(0, Math.min(
|
| 47 |
}
|
| 48 |
}
|
| 49 |
|
|
@@ -54,11 +50,11 @@ const expandBounds = (bounds: Bounds, point: DocumentPointer, radius: number): B
|
|
| 54 |
maxY: Math.max(bounds.maxY, point.y + radius),
|
| 55 |
})
|
| 56 |
|
| 57 |
-
const boundsToRegion = (bounds: Bounds,
|
| 58 |
const x0 = Math.max(0, Math.floor(bounds.minX))
|
| 59 |
const y0 = Math.max(0, Math.floor(bounds.minY))
|
| 60 |
-
const x1 = Math.min(
|
| 61 |
-
const y1 = Math.min(
|
| 62 |
return {
|
| 63 |
x: x0,
|
| 64 |
y: y0,
|
|
@@ -67,9 +63,13 @@ const boundsToRegion = (bounds: Bounds, doc: MappedDocument): InpaintRegion => {
|
|
| 67 |
}
|
| 68 |
}
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
const exportCanvasRegion = async (
|
| 71 |
canvas: HTMLCanvasElement,
|
| 72 |
-
region:
|
| 73 |
): Promise<Uint8Array | null> => {
|
| 74 |
if (region.width <= 0 || region.height <= 0) return null
|
| 75 |
const tmp = document.createElement('canvas')
|
|
@@ -89,12 +89,12 @@ const exportCanvasRegion = async (
|
|
| 89 |
region.height,
|
| 90 |
)
|
| 91 |
const blob = await new Promise<Blob | null>((r) => tmp.toBlob(r, 'image/png'))
|
| 92 |
-
return blob ?
|
| 93 |
}
|
| 94 |
|
| 95 |
const exportFullCanvas = async (canvas: HTMLCanvasElement): Promise<Uint8Array | null> => {
|
| 96 |
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png'))
|
| 97 |
-
return blob ?
|
| 98 |
}
|
| 99 |
|
| 100 |
const initBounds = (point: DocumentPointer, radius: number): Bounds => ({
|
|
@@ -109,7 +109,7 @@ const initBounds = (point: DocumentPointer, radius: number): Bounds => ({
|
|
| 109 |
// ---------------------------------------------------------------------------
|
| 110 |
|
| 111 |
export function useCanvasDrawing(
|
| 112 |
-
|
| 113 |
pointerToDocument: PointerToDocumentFn,
|
| 114 |
config: CanvasDrawingConfig,
|
| 115 |
) {
|
|
@@ -119,7 +119,6 @@ export function useCanvasDrawing(
|
|
| 119 |
const lastPointRef = useRef<DocumentPointer | null>(null)
|
| 120 |
const boundsRef = useRef<Bounds | null>(null)
|
| 121 |
|
| 122 |
-
// Reset drawing state when disabled
|
| 123 |
useEffect(() => {
|
| 124 |
if (config.enabled) return
|
| 125 |
drawingRef.current = false
|
|
@@ -127,14 +126,13 @@ export function useCanvasDrawing(
|
|
| 127 |
boundsRef.current = null
|
| 128 |
}, [config.enabled])
|
| 129 |
|
| 130 |
-
// Canvas setup and resize
|
| 131 |
useEffect(() => {
|
| 132 |
const canvas = canvasRef.current
|
| 133 |
if (!canvas) return
|
| 134 |
const ctx = canvas.getContext('2d')
|
| 135 |
ctxRef.current = ctx
|
| 136 |
|
| 137 |
-
if (!
|
| 138 |
canvas.width = 0
|
| 139 |
canvas.height = 0
|
| 140 |
ctx?.clearRect(0, 0, canvas.width, canvas.height)
|
|
@@ -145,18 +143,16 @@ export function useCanvasDrawing(
|
|
| 145 |
}
|
| 146 |
}
|
| 147 |
|
| 148 |
-
const needsResize =
|
| 149 |
-
canvas.width !== currentDocument.width || canvas.height !== currentDocument.height
|
| 150 |
-
|
| 151 |
if (needsResize) {
|
| 152 |
-
canvas.width =
|
| 153 |
-
canvas.height =
|
| 154 |
}
|
| 155 |
ctx?.clearRect(0, 0, canvas.width, canvas.height)
|
| 156 |
|
| 157 |
if (config.onCanvasInit && ctx) {
|
| 158 |
-
const result = config.onCanvasInit(ctx,
|
| 159 |
-
if (result && typeof (result as
|
| 160 |
void (result as Promise<void>).catch(console.error)
|
| 161 |
}
|
| 162 |
}
|
|
@@ -166,7 +162,8 @@ export function useCanvasDrawing(
|
|
| 166 |
lastPointRef.current = null
|
| 167 |
boundsRef.current = null
|
| 168 |
}
|
| 169 |
-
|
|
|
|
| 170 |
|
| 171 |
const drawStroke = (from: DocumentPointer, to: DocumentPointer) => {
|
| 172 |
const color = config.getColor()
|
|
@@ -189,7 +186,6 @@ export function useCanvasDrawing(
|
|
| 189 |
|
| 190 |
const ctx = ctxRef.current
|
| 191 |
if (ctx) stroke(ctx)
|
| 192 |
-
|
| 193 |
const targetCtx = config.targetCanvasRef?.current?.getContext('2d')
|
| 194 |
if (targetCtx) stroke(targetCtx)
|
| 195 |
}
|
|
@@ -197,8 +193,8 @@ export function useCanvasDrawing(
|
|
| 197 |
const finalizeStroke = () => {
|
| 198 |
if (!config.enabled) return
|
| 199 |
const strokeBounds = boundsRef.current
|
| 200 |
-
if (!
|
| 201 |
-
const patchRegion = boundsToRegion(strokeBounds,
|
| 202 |
boundsRef.current = null
|
| 203 |
drawingRef.current = false
|
| 204 |
lastPointRef.current = null
|
|
@@ -237,14 +233,14 @@ export function useCanvasDrawing(
|
|
| 237 |
|
| 238 |
const bind = useDrag(
|
| 239 |
({ first, last, event, active }) => {
|
| 240 |
-
if (!config.enabled || !
|
| 241 |
const sourceEvent = event as MouseEvent
|
| 242 |
const point = pointerToDocument(sourceEvent)
|
| 243 |
if (!point) {
|
| 244 |
if ((last || !active) && drawingRef.current) finalizeStroke()
|
| 245 |
return
|
| 246 |
}
|
| 247 |
-
const clamped =
|
| 248 |
const brushSize = config.getBrushSize()
|
| 249 |
const radius = brushSize / 2
|
| 250 |
|
|
@@ -255,7 +251,6 @@ export function useCanvasDrawing(
|
|
| 255 |
drawStroke(clamped, clamped)
|
| 256 |
return
|
| 257 |
}
|
| 258 |
-
|
| 259 |
if (!drawingRef.current) return
|
| 260 |
const lastPoint = lastPointRef.current ?? clamped
|
| 261 |
drawStroke(lastPoint, clamped)
|
|
@@ -263,7 +258,6 @@ export function useCanvasDrawing(
|
|
| 263 |
boundsRef.current = boundsRef.current
|
| 264 |
? expandBounds(boundsRef.current, clamped, radius)
|
| 265 |
: initBounds(clamped, radius)
|
| 266 |
-
|
| 267 |
if (last || !active) finalizeStroke()
|
| 268 |
},
|
| 269 |
{
|
|
|
|
| 3 |
import { useDrag } from '@use-gesture/react'
|
| 4 |
import { useEffect, useRef, type RefObject } from 'react'
|
| 5 |
|
| 6 |
+
import { type DocumentPointer, type PointerToDocumentFn } from '@/hooks/usePointerToDocument'
|
| 7 |
+
import type { Region } from '@/lib/api/schemas'
|
| 8 |
+
|
| 9 |
+
/** Minimum canvas context needed by the drawing loop. */
|
| 10 |
+
export type CanvasDims = { width: number; height: number; key?: string }
|
| 11 |
|
| 12 |
// ---------------------------------------------------------------------------
|
| 13 |
// Types
|
| 14 |
// ---------------------------------------------------------------------------
|
| 15 |
|
| 16 |
+
type Bounds = { minX: number; minY: number; maxX: number; maxY: number }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
export type CanvasDrawingConfig = {
|
| 19 |
getColor: () => string
|
| 20 |
blendMode: GlobalCompositeOperation
|
| 21 |
getBrushSize: () => number
|
| 22 |
+
onFinalize: (patch: Uint8Array, region: Region) => Promise<void>
|
| 23 |
/** Called after finalize with the full-canvas PNG and the patch region. */
|
| 24 |
+
onFinalizeFullCanvas?: (fullPng: Uint8Array, patchRegion: Region) => Promise<void>
|
| 25 |
enabled: boolean
|
| 26 |
/** Optional second canvas to mirror strokes to. */
|
| 27 |
targetCanvasRef?: RefObject<HTMLCanvasElement | null>
|
| 28 |
/** When true, clear the drawing canvas after each stroke finalize. */
|
| 29 |
clearAfterStroke?: boolean
|
| 30 |
+
/** Seed canvas content on dims change (e.g. draw an existing mask). */
|
| 31 |
+
onCanvasInit?: (ctx: CanvasRenderingContext2D, dims: CanvasDims) => void | Promise<void>
|
| 32 |
}
|
| 33 |
|
| 34 |
// ---------------------------------------------------------------------------
|
| 35 |
// Helpers
|
| 36 |
// ---------------------------------------------------------------------------
|
| 37 |
|
| 38 |
+
const clampToDims = (point: DocumentPointer, dims?: CanvasDims): DocumentPointer => {
|
| 39 |
+
if (!dims) return point
|
| 40 |
return {
|
| 41 |
+
x: Math.max(0, Math.min(dims.width, point.x)),
|
| 42 |
+
y: Math.max(0, Math.min(dims.height, point.y)),
|
| 43 |
}
|
| 44 |
}
|
| 45 |
|
|
|
|
| 50 |
maxY: Math.max(bounds.maxY, point.y + radius),
|
| 51 |
})
|
| 52 |
|
| 53 |
+
const boundsToRegion = (bounds: Bounds, dims: CanvasDims): Region => {
|
| 54 |
const x0 = Math.max(0, Math.floor(bounds.minX))
|
| 55 |
const y0 = Math.max(0, Math.floor(bounds.minY))
|
| 56 |
+
const x1 = Math.min(dims.width, Math.ceil(bounds.maxX))
|
| 57 |
+
const y1 = Math.min(dims.height, Math.ceil(bounds.maxY))
|
| 58 |
return {
|
| 59 |
x: x0,
|
| 60 |
y: y0,
|
|
|
|
| 63 |
}
|
| 64 |
}
|
| 65 |
|
| 66 |
+
async function blobToUint8(blob: Blob): Promise<Uint8Array> {
|
| 67 |
+
return new Uint8Array(await blob.arrayBuffer())
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
const exportCanvasRegion = async (
|
| 71 |
canvas: HTMLCanvasElement,
|
| 72 |
+
region: Region,
|
| 73 |
): Promise<Uint8Array | null> => {
|
| 74 |
if (region.width <= 0 || region.height <= 0) return null
|
| 75 |
const tmp = document.createElement('canvas')
|
|
|
|
| 89 |
region.height,
|
| 90 |
)
|
| 91 |
const blob = await new Promise<Blob | null>((r) => tmp.toBlob(r, 'image/png'))
|
| 92 |
+
return blob ? blobToUint8(blob) : null
|
| 93 |
}
|
| 94 |
|
| 95 |
const exportFullCanvas = async (canvas: HTMLCanvasElement): Promise<Uint8Array | null> => {
|
| 96 |
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/png'))
|
| 97 |
+
return blob ? blobToUint8(blob) : null
|
| 98 |
}
|
| 99 |
|
| 100 |
const initBounds = (point: DocumentPointer, radius: number): Bounds => ({
|
|
|
|
| 109 |
// ---------------------------------------------------------------------------
|
| 110 |
|
| 111 |
export function useCanvasDrawing(
|
| 112 |
+
dims: CanvasDims | null,
|
| 113 |
pointerToDocument: PointerToDocumentFn,
|
| 114 |
config: CanvasDrawingConfig,
|
| 115 |
) {
|
|
|
|
| 119 |
const lastPointRef = useRef<DocumentPointer | null>(null)
|
| 120 |
const boundsRef = useRef<Bounds | null>(null)
|
| 121 |
|
|
|
|
| 122 |
useEffect(() => {
|
| 123 |
if (config.enabled) return
|
| 124 |
drawingRef.current = false
|
|
|
|
| 126 |
boundsRef.current = null
|
| 127 |
}, [config.enabled])
|
| 128 |
|
|
|
|
| 129 |
useEffect(() => {
|
| 130 |
const canvas = canvasRef.current
|
| 131 |
if (!canvas) return
|
| 132 |
const ctx = canvas.getContext('2d')
|
| 133 |
ctxRef.current = ctx
|
| 134 |
|
| 135 |
+
if (!dims || !config.enabled) {
|
| 136 |
canvas.width = 0
|
| 137 |
canvas.height = 0
|
| 138 |
ctx?.clearRect(0, 0, canvas.width, canvas.height)
|
|
|
|
| 143 |
}
|
| 144 |
}
|
| 145 |
|
| 146 |
+
const needsResize = canvas.width !== dims.width || canvas.height !== dims.height
|
|
|
|
|
|
|
| 147 |
if (needsResize) {
|
| 148 |
+
canvas.width = dims.width
|
| 149 |
+
canvas.height = dims.height
|
| 150 |
}
|
| 151 |
ctx?.clearRect(0, 0, canvas.width, canvas.height)
|
| 152 |
|
| 153 |
if (config.onCanvasInit && ctx) {
|
| 154 |
+
const result = config.onCanvasInit(ctx, dims)
|
| 155 |
+
if (result && typeof (result as Promise<void>).then === 'function') {
|
| 156 |
void (result as Promise<void>).catch(console.error)
|
| 157 |
}
|
| 158 |
}
|
|
|
|
| 162 |
lastPointRef.current = null
|
| 163 |
boundsRef.current = null
|
| 164 |
}
|
| 165 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 166 |
+
}, [dims?.key, dims?.width, dims?.height, config.enabled])
|
| 167 |
|
| 168 |
const drawStroke = (from: DocumentPointer, to: DocumentPointer) => {
|
| 169 |
const color = config.getColor()
|
|
|
|
| 186 |
|
| 187 |
const ctx = ctxRef.current
|
| 188 |
if (ctx) stroke(ctx)
|
|
|
|
| 189 |
const targetCtx = config.targetCanvasRef?.current?.getContext('2d')
|
| 190 |
if (targetCtx) stroke(targetCtx)
|
| 191 |
}
|
|
|
|
| 193 |
const finalizeStroke = () => {
|
| 194 |
if (!config.enabled) return
|
| 195 |
const strokeBounds = boundsRef.current
|
| 196 |
+
if (!dims || !strokeBounds) return
|
| 197 |
+
const patchRegion = boundsToRegion(strokeBounds, dims)
|
| 198 |
boundsRef.current = null
|
| 199 |
drawingRef.current = false
|
| 200 |
lastPointRef.current = null
|
|
|
|
| 233 |
|
| 234 |
const bind = useDrag(
|
| 235 |
({ first, last, event, active }) => {
|
| 236 |
+
if (!config.enabled || !dims) return
|
| 237 |
const sourceEvent = event as MouseEvent
|
| 238 |
const point = pointerToDocument(sourceEvent)
|
| 239 |
if (!point) {
|
| 240 |
if ((last || !active) && drawingRef.current) finalizeStroke()
|
| 241 |
return
|
| 242 |
}
|
| 243 |
+
const clamped = clampToDims(point, dims)
|
| 244 |
const brushSize = config.getBrushSize()
|
| 245 |
const radius = brushSize / 2
|
| 246 |
|
|
|
|
| 251 |
drawStroke(clamped, clamped)
|
| 252 |
return
|
| 253 |
}
|
|
|
|
| 254 |
if (!drawingRef.current) return
|
| 255 |
const lastPoint = lastPointRef.current ?? clamped
|
| 256 |
drawStroke(lastPoint, clamped)
|
|
|
|
| 258 |
boundsRef.current = boundsRef.current
|
| 259 |
? expandBounds(boundsRef.current, clamped, radius)
|
| 260 |
: initBounds(clamped, radius)
|
|
|
|
| 261 |
if (last || !active) finalizeStroke()
|
| 262 |
},
|
| 263 |
{
|
ui/hooks/useCanvasZoom.ts
CHANGED
|
@@ -1,27 +1,19 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import {
|
| 4 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 5 |
|
| 6 |
export function useCanvasZoom() {
|
| 7 |
-
const scale = useEditorUiStore((
|
| 8 |
-
const setScaleRaw = useEditorUiStore((
|
| 9 |
-
const setAutoFitEnabled = useEditorUiStore((
|
| 10 |
-
const
|
| 11 |
-
const
|
| 12 |
-
query: { enabled: !!documentId },
|
| 13 |
-
})
|
| 14 |
-
|
| 15 |
-
const summary = document ? `${document.width} x ${document.height}` : '--'
|
| 16 |
|
| 17 |
const applyScale = (value: number) => {
|
| 18 |
setAutoFitEnabled(false)
|
| 19 |
setScaleRaw(value)
|
| 20 |
}
|
| 21 |
|
| 22 |
-
return {
|
| 23 |
-
scale,
|
| 24 |
-
setScale: applyScale,
|
| 25 |
-
summary,
|
| 26 |
-
}
|
| 27 |
}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useCurrentPage } from '@/hooks/useCurrentPage'
|
| 4 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 5 |
|
| 6 |
export function useCanvasZoom() {
|
| 7 |
+
const scale = useEditorUiStore((s) => s.scale)
|
| 8 |
+
const setScaleRaw = useEditorUiStore((s) => s.setScale)
|
| 9 |
+
const setAutoFitEnabled = useEditorUiStore((s) => s.setAutoFitEnabled)
|
| 10 |
+
const page = useCurrentPage()
|
| 11 |
+
const summary = page ? `${page.width} x ${page.height}` : '--'
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
const applyScale = (value: number) => {
|
| 14 |
setAutoFitEnabled(false)
|
| 15 |
setScaleRaw(value)
|
| 16 |
}
|
| 17 |
|
| 18 |
+
return { scale, setScale: applyScale, summary }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
ui/hooks/useCurrentPage.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useMemo } from 'react'
|
| 4 |
+
|
| 5 |
+
import type { ImageRole, MaskRole, Node, Page, TextData, Transform } from '@/lib/api/schemas'
|
| 6 |
+
import { useSelectionStore } from '@/lib/stores/selectionStore'
|
| 7 |
+
|
| 8 |
+
import { useScene } from './useScene'
|
| 9 |
+
|
| 10 |
+
// ---------------------------------------------------------------------------
|
| 11 |
+
// Node kind guards
|
| 12 |
+
// ---------------------------------------------------------------------------
|
| 13 |
+
|
| 14 |
+
export function isImageNode(
|
| 15 |
+
n: Node,
|
| 16 |
+
): n is Node & { kind: { image: import('@/lib/api/schemas').ImageData } } {
|
| 17 |
+
return 'image' in n.kind
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function isMaskNode(
|
| 21 |
+
n: Node,
|
| 22 |
+
): n is Node & { kind: { mask: import('@/lib/api/schemas').MaskData } } {
|
| 23 |
+
return 'mask' in n.kind
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function isTextNode(n: Node): n is Node & { kind: { text: TextData } } {
|
| 27 |
+
return 'text' in n.kind
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// ---------------------------------------------------------------------------
|
| 31 |
+
// Accessors — page-level, role-keyed
|
| 32 |
+
// ---------------------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
/** Return the blob hash of the `Image { role }` node on `page`, if any. */
|
| 35 |
+
export function findImageBlob(page: Page, role: ImageRole): string | null {
|
| 36 |
+
for (const node of Object.values(page.nodes)) {
|
| 37 |
+
if (isImageNode(node) && node.kind.image.role === role) {
|
| 38 |
+
return node.kind.image.blob
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
return null
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function findImageNodeId(page: Page, role: ImageRole): string | null {
|
| 45 |
+
for (const [id, node] of Object.entries(page.nodes)) {
|
| 46 |
+
if (isImageNode(node) && node.kind.image.role === role) return id
|
| 47 |
+
}
|
| 48 |
+
return null
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function findMaskBlob(page: Page, role: MaskRole): string | null {
|
| 52 |
+
for (const node of Object.values(page.nodes)) {
|
| 53 |
+
if (isMaskNode(node) && node.kind.mask.role === role) {
|
| 54 |
+
return node.kind.mask.blob
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
return null
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export function findMaskNodeId(page: Page, role: MaskRole): string | null {
|
| 61 |
+
for (const [id, node] of Object.entries(page.nodes)) {
|
| 62 |
+
if (isMaskNode(node) && node.kind.mask.role === role) return id
|
| 63 |
+
}
|
| 64 |
+
return null
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export type TextNodeEntry = {
|
| 68 |
+
id: string
|
| 69 |
+
transform: Transform
|
| 70 |
+
data: TextData
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export function textNodesOf(page: Page): TextNodeEntry[] {
|
| 74 |
+
const out: TextNodeEntry[] = []
|
| 75 |
+
for (const [id, node] of Object.entries(page.nodes)) {
|
| 76 |
+
if (!isTextNode(node)) continue
|
| 77 |
+
out.push({
|
| 78 |
+
id,
|
| 79 |
+
transform: node.transform ?? { x: 0, y: 0, width: 0, height: 0 },
|
| 80 |
+
data: node.kind.text,
|
| 81 |
+
})
|
| 82 |
+
}
|
| 83 |
+
return out
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
// ---------------------------------------------------------------------------
|
| 87 |
+
// React hooks
|
| 88 |
+
// ---------------------------------------------------------------------------
|
| 89 |
+
|
| 90 |
+
/** The active page, or `null` if none selected / no project open. */
|
| 91 |
+
export function useCurrentPage(): Page | null {
|
| 92 |
+
const pageId = useSelectionStore((s) => s.pageId)
|
| 93 |
+
const { scene } = useScene()
|
| 94 |
+
if (!pageId) return null
|
| 95 |
+
return scene?.pages?.[pageId] ?? null
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/** Text nodes on the active page, in stacking order. */
|
| 99 |
+
export function useTextNodes(): TextNodeEntry[] {
|
| 100 |
+
const page = useCurrentPage()
|
| 101 |
+
const { epoch } = useScene()
|
| 102 |
+
return useMemo(() => (page ? textNodesOf(page) : []), [page, epoch])
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/**
|
| 106 |
+
* Selected text node entry, derived from `selectionStore.nodeIds`. Returns
|
| 107 |
+
* the first selected text node (V1 had a single-select block concept).
|
| 108 |
+
*/
|
| 109 |
+
export function useSelectedTextNode(): TextNodeEntry | null {
|
| 110 |
+
const page = useCurrentPage()
|
| 111 |
+
const nodeIds = useSelectionStore((s) => s.nodeIds)
|
| 112 |
+
const { epoch } = useScene()
|
| 113 |
+
return useMemo(() => {
|
| 114 |
+
if (!page) return null
|
| 115 |
+
for (const id of nodeIds) {
|
| 116 |
+
const node = page.nodes[id]
|
| 117 |
+
if (node && isTextNode(node)) {
|
| 118 |
+
return {
|
| 119 |
+
id,
|
| 120 |
+
transform: node.transform ?? { x: 0, y: 0, width: 0, height: 0 },
|
| 121 |
+
data: node.kind.text,
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
return null
|
| 126 |
+
}, [page, nodeIds, epoch])
|
| 127 |
+
}
|
ui/hooks/useKeyboardShortcuts.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useMemo } from 'react'
|
| 4 |
|
|
|
|
| 5 |
import { getPlatform, formatShortcut, isModifierKey } from '@/lib/shortcutUtils'
|
| 6 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 7 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
|
@@ -13,12 +14,29 @@ export function useKeyboardShortcuts() {
|
|
| 13 |
|
| 14 |
useEffect(() => {
|
| 15 |
const handleKeyDown = (event: KeyboardEvent) => {
|
| 16 |
-
// Skip if user is typing in an input
|
| 17 |
const target = event.target as HTMLElement
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
return
|
| 20 |
}
|
| 21 |
|
|
|
|
|
|
|
|
|
|
| 22 |
// Early exit for modifier-only events
|
| 23 |
if (isModifierKey(event.key)) {
|
| 24 |
return
|
|
@@ -55,5 +73,6 @@ export function useKeyboardShortcuts() {
|
|
| 55 |
|
| 56 |
window.addEventListener('keydown', handleKeyDown)
|
| 57 |
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
|
|
| 58 |
}, [isMac, setMode])
|
| 59 |
}
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useMemo } from 'react'
|
| 4 |
|
| 5 |
+
import { redoOp, undoOp } from '@/lib/io/scene'
|
| 6 |
import { getPlatform, formatShortcut, isModifierKey } from '@/lib/shortcutUtils'
|
| 7 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 8 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
|
|
|
| 14 |
|
| 15 |
useEffect(() => {
|
| 16 |
const handleKeyDown = (event: KeyboardEvent) => {
|
|
|
|
| 17 |
const target = event.target as HTMLElement
|
| 18 |
+
const inTextField =
|
| 19 |
+
target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
|
| 20 |
+
|
| 21 |
+
// Undo / Redo — work globally, including from within text fields (the
|
| 22 |
+
// browser's native `z` on an input would only undo keystrokes, not
|
| 23 |
+
// scene ops). Cmd on macOS, Ctrl elsewhere.
|
| 24 |
+
const mod = isMac ? event.metaKey : event.ctrlKey
|
| 25 |
+
if (mod && (event.key === 'z' || event.key === 'Z')) {
|
| 26 |
+
event.preventDefault()
|
| 27 |
+
if (event.shiftKey) void redoOp()
|
| 28 |
+
else void undoOp()
|
| 29 |
+
return
|
| 30 |
+
}
|
| 31 |
+
if (mod && (event.key === 'y' || event.key === 'Y')) {
|
| 32 |
+
event.preventDefault()
|
| 33 |
+
void redoOp()
|
| 34 |
return
|
| 35 |
}
|
| 36 |
|
| 37 |
+
// Every other shortcut is tool-level and should not fire while typing.
|
| 38 |
+
if (inTextField) return
|
| 39 |
+
|
| 40 |
// Early exit for modifier-only events
|
| 41 |
if (isModifierKey(event.key)) {
|
| 42 |
return
|
|
|
|
| 73 |
|
| 74 |
window.addEventListener('keydown', handleKeyDown)
|
| 75 |
return () => window.removeEventListener('keydown', handleKeyDown)
|
| 76 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 77 |
}, [isMac, setMode])
|
| 78 |
}
|
ui/hooks/useMaskDrawing.ts
CHANGED
|
@@ -1,72 +1,67 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import {
|
| 4 |
-
import { useCallback, useRef } from 'react'
|
| 5 |
|
| 6 |
-
import { useCanvasDrawing } from '@/hooks/useCanvasDrawing'
|
| 7 |
import type { PointerToDocumentFn } from '@/hooks/usePointerToDocument'
|
| 8 |
-
import
|
| 9 |
-
import {
|
| 10 |
-
import {
|
| 11 |
-
updateMask as updateMaskApi,
|
| 12 |
-
inpaintRegion as inpaintRegionApi,
|
| 13 |
-
} from '@/lib/api/regions/regions'
|
| 14 |
-
import { normalizeErrorMessage } from '@/lib/errors'
|
| 15 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 16 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 17 |
-
import {
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
type MaskDrawingOptions = {
|
| 21 |
mode: ToolMode
|
| 22 |
-
|
| 23 |
segmentData?: Uint8Array
|
| 24 |
pointerToDocument: PointerToDocumentFn
|
| 25 |
showMask: boolean
|
| 26 |
enabled: boolean
|
| 27 |
}
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
export function useMaskDrawing({
|
| 30 |
mode,
|
| 31 |
-
|
| 32 |
segmentData,
|
| 33 |
pointerToDocument,
|
| 34 |
showMask,
|
| 35 |
enabled,
|
| 36 |
}: MaskDrawingOptions) {
|
| 37 |
-
const queryClient = useQueryClient()
|
| 38 |
const inpaintQueueRef = useRef<Promise<void>>(Promise.resolve())
|
| 39 |
const isEraseMode = mode === 'eraser'
|
| 40 |
const isActive = enabled && (mode === 'repairBrush' || isEraseMode)
|
| 41 |
|
| 42 |
-
const
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
queryKey: getGetDocumentQueryKey(documentId),
|
| 46 |
-
})
|
| 47 |
-
await queryClient.invalidateQueries({
|
| 48 |
-
queryKey: getListDocumentsQueryKey(),
|
| 49 |
-
})
|
| 50 |
-
},
|
| 51 |
-
[queryClient],
|
| 52 |
-
)
|
| 53 |
|
| 54 |
-
const { canvasRef, bind: rawBind } = useCanvasDrawing(
|
| 55 |
getColor: () => (isEraseMode ? '#000000' : '#ffffff'),
|
| 56 |
blendMode: 'source-over',
|
| 57 |
getBrushSize: () => usePreferencesStore.getState().brushConfig.size,
|
| 58 |
enabled: showMask,
|
| 59 |
-
onCanvasInit: (ctx,
|
| 60 |
-
// Fill black then draw existing segment mask on top
|
| 61 |
ctx.fillStyle = '#000'
|
| 62 |
-
ctx.fillRect(0, 0,
|
| 63 |
if (segmentData) {
|
| 64 |
void (async () => {
|
| 65 |
try {
|
| 66 |
-
const bitmap = await
|
| 67 |
ctx.save()
|
| 68 |
-
ctx.clearRect(0, 0,
|
| 69 |
-
ctx.drawImage(bitmap, 0, 0,
|
| 70 |
ctx.restore()
|
| 71 |
bitmap.close()
|
| 72 |
} catch (e) {
|
|
@@ -76,28 +71,27 @@ export function useMaskDrawing({
|
|
| 76 |
}
|
| 77 |
},
|
| 78 |
onFinalizeFullCanvas: async (fullPng) => {
|
| 79 |
-
|
| 80 |
-
if (!documentId) return
|
| 81 |
try {
|
| 82 |
-
await
|
| 83 |
-
|
|
|
|
|
|
|
| 84 |
})
|
|
|
|
| 85 |
} catch (e) {
|
| 86 |
-
useEditorUiStore.getState().showError(
|
| 87 |
}
|
| 88 |
},
|
| 89 |
onFinalize: async (_patch, region) => {
|
| 90 |
-
|
| 91 |
-
if (!documentId) return
|
| 92 |
-
// Compute the inpaint region with margin
|
| 93 |
const brushSize = usePreferencesStore.getState().brushConfig.size
|
| 94 |
const width = Math.max(brushSize, region.width)
|
| 95 |
const margin = Math.min(width * 0.2, 32)
|
| 96 |
-
const doc = currentDocument!
|
| 97 |
const x0 = Math.max(0, Math.floor(region.x - margin))
|
| 98 |
const y0 = Math.max(0, Math.floor(region.y - margin))
|
| 99 |
-
const x1 = Math.min(
|
| 100 |
-
const y1 = Math.min(
|
| 101 |
const inpaintRegion = {
|
| 102 |
x: x0,
|
| 103 |
y: y0,
|
|
@@ -108,18 +102,21 @@ export function useMaskDrawing({
|
|
| 108 |
.catch(() => {})
|
| 109 |
.then(async () => {
|
| 110 |
try {
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
useEditorUiStore.getState().setShowInpaintedImage(true)
|
| 114 |
} catch (e) {
|
| 115 |
-
useEditorUiStore.getState().showError(
|
| 116 |
}
|
| 117 |
})
|
| 118 |
},
|
| 119 |
})
|
| 120 |
|
| 121 |
-
// Only allow drawing on the mask if the specific tools are active
|
| 122 |
const bind = isActive ? rawBind : () => ({})
|
| 123 |
-
|
| 124 |
return { canvasRef, visible: showMask, bind }
|
| 125 |
}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useRef } from 'react'
|
|
|
|
| 4 |
|
| 5 |
+
import { useCanvasDrawing, type CanvasDims } from '@/hooks/useCanvasDrawing'
|
| 6 |
import type { PointerToDocumentFn } from '@/hooks/usePointerToDocument'
|
| 7 |
+
import { getConfig, startPipeline } from '@/lib/api/default/default'
|
| 8 |
+
import type { Page } from '@/lib/api/schemas'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 10 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 11 |
+
import type { ToolMode } from '@/lib/types'
|
| 12 |
+
|
| 13 |
+
async function convertBytesToBitmap(bytes: Uint8Array): Promise<ImageBitmap> {
|
| 14 |
+
const blob = new Blob([bytes as unknown as BlobPart])
|
| 15 |
+
return createImageBitmap(blob)
|
| 16 |
+
}
|
| 17 |
|
| 18 |
type MaskDrawingOptions = {
|
| 19 |
mode: ToolMode
|
| 20 |
+
page: Page | null
|
| 21 |
segmentData?: Uint8Array
|
| 22 |
pointerToDocument: PointerToDocumentFn
|
| 23 |
showMask: boolean
|
| 24 |
enabled: boolean
|
| 25 |
}
|
| 26 |
|
| 27 |
+
/**
|
| 28 |
+
* Repair-brush canvas that edits the `Mask { role: segment }` node. On stroke
|
| 29 |
+
* end:
|
| 30 |
+
* 1. PUT the updated mask to `/api/v1/pages/{id}/masks/segment` (raw PNG).
|
| 31 |
+
* 2. Kick a region-scoped inpainter via `POST /pipelines` so the inpainted
|
| 32 |
+
* layer refreshes just over the touched area.
|
| 33 |
+
*/
|
| 34 |
export function useMaskDrawing({
|
| 35 |
mode,
|
| 36 |
+
page,
|
| 37 |
segmentData,
|
| 38 |
pointerToDocument,
|
| 39 |
showMask,
|
| 40 |
enabled,
|
| 41 |
}: MaskDrawingOptions) {
|
|
|
|
| 42 |
const inpaintQueueRef = useRef<Promise<void>>(Promise.resolve())
|
| 43 |
const isEraseMode = mode === 'eraser'
|
| 44 |
const isActive = enabled && (mode === 'repairBrush' || isEraseMode)
|
| 45 |
|
| 46 |
+
const dims: CanvasDims | null = page
|
| 47 |
+
? { width: page.width, height: page.height, key: page.id }
|
| 48 |
+
: null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
+
const { canvasRef, bind: rawBind } = useCanvasDrawing(dims, pointerToDocument, {
|
| 51 |
getColor: () => (isEraseMode ? '#000000' : '#ffffff'),
|
| 52 |
blendMode: 'source-over',
|
| 53 |
getBrushSize: () => usePreferencesStore.getState().brushConfig.size,
|
| 54 |
enabled: showMask,
|
| 55 |
+
onCanvasInit: (ctx, d) => {
|
|
|
|
| 56 |
ctx.fillStyle = '#000'
|
| 57 |
+
ctx.fillRect(0, 0, d.width, d.height)
|
| 58 |
if (segmentData) {
|
| 59 |
void (async () => {
|
| 60 |
try {
|
| 61 |
+
const bitmap = await convertBytesToBitmap(segmentData)
|
| 62 |
ctx.save()
|
| 63 |
+
ctx.clearRect(0, 0, d.width, d.height)
|
| 64 |
+
ctx.drawImage(bitmap, 0, 0, d.width, d.height)
|
| 65 |
ctx.restore()
|
| 66 |
bitmap.close()
|
| 67 |
} catch (e) {
|
|
|
|
| 71 |
}
|
| 72 |
},
|
| 73 |
onFinalizeFullCanvas: async (fullPng) => {
|
| 74 |
+
if (!page) return
|
|
|
|
| 75 |
try {
|
| 76 |
+
const res = await fetch(`/api/v1/pages/${page.id}/masks/segment`, {
|
| 77 |
+
method: 'PUT',
|
| 78 |
+
headers: { 'Content-Type': 'image/png' },
|
| 79 |
+
body: fullPng as unknown as BodyInit,
|
| 80 |
})
|
| 81 |
+
if (!res.ok) throw new Error(`mask PUT failed: ${res.status}`)
|
| 82 |
} catch (e) {
|
| 83 |
+
useEditorUiStore.getState().showError(String(e))
|
| 84 |
}
|
| 85 |
},
|
| 86 |
onFinalize: async (_patch, region) => {
|
| 87 |
+
if (!page) return
|
|
|
|
|
|
|
| 88 |
const brushSize = usePreferencesStore.getState().brushConfig.size
|
| 89 |
const width = Math.max(brushSize, region.width)
|
| 90 |
const margin = Math.min(width * 0.2, 32)
|
|
|
|
| 91 |
const x0 = Math.max(0, Math.floor(region.x - margin))
|
| 92 |
const y0 = Math.max(0, Math.floor(region.y - margin))
|
| 93 |
+
const x1 = Math.min(page.width, Math.ceil(region.x + region.width + margin))
|
| 94 |
+
const y1 = Math.min(page.height, Math.ceil(region.y + region.height + margin))
|
| 95 |
const inpaintRegion = {
|
| 96 |
x: x0,
|
| 97 |
y: y0,
|
|
|
|
| 102 |
.catch(() => {})
|
| 103 |
.then(async () => {
|
| 104 |
try {
|
| 105 |
+
const cfg = await getConfig()
|
| 106 |
+
const inpainter = cfg.pipeline?.inpainter || 'lama-manga'
|
| 107 |
+
await startPipeline({
|
| 108 |
+
steps: [inpainter],
|
| 109 |
+
pages: [page.id],
|
| 110 |
+
region: inpaintRegion,
|
| 111 |
+
})
|
| 112 |
useEditorUiStore.getState().setShowInpaintedImage(true)
|
| 113 |
} catch (e) {
|
| 114 |
+
useEditorUiStore.getState().showError(String(e))
|
| 115 |
}
|
| 116 |
})
|
| 117 |
},
|
| 118 |
})
|
| 119 |
|
|
|
|
| 120 |
const bind = isActive ? rawBind : () => ({})
|
|
|
|
| 121 |
return { canvasRef, visible: showMask, bind }
|
| 122 |
}
|
ui/hooks/useRenderBrushDrawing.ts
CHANGED
|
@@ -1,57 +1,60 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useQueryClient } from '@tanstack/react-query'
|
| 4 |
import type { RefObject } from 'react'
|
| 5 |
|
| 6 |
-
import { useCanvasDrawing } from '@/hooks/useCanvasDrawing'
|
| 7 |
import type { PointerToDocumentFn } from '@/hooks/usePointerToDocument'
|
| 8 |
-
import type {
|
| 9 |
-
import { getGetDocumentQueryKey, getListDocumentsQueryKey } from '@/lib/api/documents/documents'
|
| 10 |
-
import { updateBrushLayer } from '@/lib/api/regions/regions'
|
| 11 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 12 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 13 |
-
import type { ToolMode } from '@/types'
|
| 14 |
|
| 15 |
type RenderBrushOptions = {
|
| 16 |
mode: ToolMode
|
| 17 |
-
|
| 18 |
pointerToDocument: PointerToDocumentFn
|
| 19 |
enabled: boolean
|
| 20 |
action: 'paint' | 'erase'
|
| 21 |
targetCanvasRef?: RefObject<HTMLCanvasElement | null>
|
| 22 |
}
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
export function useRenderBrushDrawing({
|
| 25 |
-
|
| 26 |
pointerToDocument,
|
| 27 |
enabled,
|
| 28 |
action,
|
| 29 |
targetCanvasRef,
|
| 30 |
}: RenderBrushOptions) {
|
| 31 |
-
const queryClient = useQueryClient()
|
| 32 |
const isErasing = action === 'erase'
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
-
return useCanvasDrawing(
|
| 35 |
getColor: () => (isErasing ? '#000000' : usePreferencesStore.getState().brushConfig.color),
|
| 36 |
blendMode: isErasing ? 'destination-out' : 'source-over',
|
| 37 |
getBrushSize: () => usePreferencesStore.getState().brushConfig.size,
|
| 38 |
enabled,
|
| 39 |
targetCanvasRef,
|
| 40 |
clearAfterStroke: true,
|
| 41 |
-
onFinalize: async (
|
| 42 |
-
|
| 43 |
-
if (!
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
},
|
| 56 |
})
|
| 57 |
}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
|
|
|
| 3 |
import type { RefObject } from 'react'
|
| 4 |
|
| 5 |
+
import { useCanvasDrawing, type CanvasDims } from '@/hooks/useCanvasDrawing'
|
| 6 |
import type { PointerToDocumentFn } from '@/hooks/usePointerToDocument'
|
| 7 |
+
import type { Page } from '@/lib/api/schemas'
|
|
|
|
|
|
|
| 8 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 9 |
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 10 |
+
import type { ToolMode } from '@/lib/types'
|
| 11 |
|
| 12 |
type RenderBrushOptions = {
|
| 13 |
mode: ToolMode
|
| 14 |
+
page: Page | null
|
| 15 |
pointerToDocument: PointerToDocumentFn
|
| 16 |
enabled: boolean
|
| 17 |
action: 'paint' | 'erase'
|
| 18 |
targetCanvasRef?: RefObject<HTMLCanvasElement | null>
|
| 19 |
}
|
| 20 |
|
| 21 |
+
/**
|
| 22 |
+
* Color-brush over the `Mask { role: brushInpaint }` node. Stroke finalize
|
| 23 |
+
* PUTs the updated mask to `/api/v1/pages/{id}/masks/brushInpaint`.
|
| 24 |
+
*/
|
| 25 |
export function useRenderBrushDrawing({
|
| 26 |
+
page,
|
| 27 |
pointerToDocument,
|
| 28 |
enabled,
|
| 29 |
action,
|
| 30 |
targetCanvasRef,
|
| 31 |
}: RenderBrushOptions) {
|
|
|
|
| 32 |
const isErasing = action === 'erase'
|
| 33 |
+
const dims: CanvasDims | null = page
|
| 34 |
+
? { width: page.width, height: page.height, key: page.id }
|
| 35 |
+
: null
|
| 36 |
|
| 37 |
+
return useCanvasDrawing(dims, pointerToDocument, {
|
| 38 |
getColor: () => (isErasing ? '#000000' : usePreferencesStore.getState().brushConfig.color),
|
| 39 |
blendMode: isErasing ? 'destination-out' : 'source-over',
|
| 40 |
getBrushSize: () => usePreferencesStore.getState().brushConfig.size,
|
| 41 |
enabled,
|
| 42 |
targetCanvasRef,
|
| 43 |
clearAfterStroke: true,
|
| 44 |
+
onFinalize: async () => {},
|
| 45 |
+
onFinalizeFullCanvas: async (fullPng) => {
|
| 46 |
+
if (!page) return
|
| 47 |
+
try {
|
| 48 |
+
const res = await fetch(`/api/v1/pages/${page.id}/masks/brushInpaint`, {
|
| 49 |
+
method: 'PUT',
|
| 50 |
+
headers: { 'Content-Type': 'image/png' },
|
| 51 |
+
body: fullPng as unknown as BodyInit,
|
| 52 |
+
})
|
| 53 |
+
if (!res.ok) throw new Error(`brush PUT failed: ${res.status}`)
|
| 54 |
+
useEditorUiStore.getState().setShowBrushLayer(true)
|
| 55 |
+
} catch (e) {
|
| 56 |
+
useEditorUiStore.getState().showError(String(e))
|
| 57 |
+
}
|
| 58 |
},
|
| 59 |
})
|
| 60 |
}
|
ui/hooks/useScene.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client'
|
| 2 |
+
|
| 3 |
+
import { useGetSceneJson } from '@/lib/api/default/default'
|
| 4 |
+
import type { Scene } from '@/lib/api/schemas'
|
| 5 |
+
|
| 6 |
+
/**
|
| 7 |
+
* Backend is the source of truth for the scene. Components read it through
|
| 8 |
+
* this hook — which is just a thin wrapper around the orval-generated
|
| 9 |
+
* `useGetSceneJson` query. Mutations must invalidate `getGetSceneJsonQueryKey`
|
| 10 |
+
* for the UI to pick up changes (see `lib/io/scene.ts`).
|
| 11 |
+
*
|
| 12 |
+
* When no project is open, `GET /scene.json` returns 400; React Query stores
|
| 13 |
+
* that as an error and `scene` is `null`.
|
| 14 |
+
*/
|
| 15 |
+
export function useScene(): { scene: Scene | null; epoch: number } {
|
| 16 |
+
const { data, isError } = useGetSceneJson({
|
| 17 |
+
query: {
|
| 18 |
+
retry: false,
|
| 19 |
+
staleTime: Infinity,
|
| 20 |
+
gcTime: Infinity,
|
| 21 |
+
},
|
| 22 |
+
})
|
| 23 |
+
// React Query preserves `data` across a failed refetch, so closing a
|
| 24 |
+
// project would leave the stale scene visible until the cache is
|
| 25 |
+
// manually cleared. Treat an error response (e.g. 400 "no project open")
|
| 26 |
+
// as an explicit "no scene".
|
| 27 |
+
if (isError) return { scene: null, epoch: 0 }
|
| 28 |
+
return {
|
| 29 |
+
scene: data?.scene ?? null,
|
| 30 |
+
epoch: data?.epoch ?? 0,
|
| 31 |
+
}
|
| 32 |
+
}
|
ui/hooks/useTextBlocks.ts
DELETED
|
@@ -1,183 +0,0 @@
|
|
| 1 |
-
'use client'
|
| 2 |
-
|
| 3 |
-
import { useQueryClient } from '@tanstack/react-query'
|
| 4 |
-
import { useCallback } from 'react'
|
| 5 |
-
|
| 6 |
-
import {
|
| 7 |
-
useGetDocument,
|
| 8 |
-
getGetDocumentQueryKey,
|
| 9 |
-
getListDocumentsQueryKey,
|
| 10 |
-
} from '@/lib/api/documents/documents'
|
| 11 |
-
export { useBlobData, useDocumentLayer } from '@/hooks/useBlobData'
|
| 12 |
-
import type { DocumentDetail, TextBlockInput } from '@/lib/api/schemas'
|
| 13 |
-
import { createTextBlock, patchTextBlock, putTextBlocks } from '@/lib/api/text-blocks/text-blocks'
|
| 14 |
-
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 15 |
-
import { TextBlock } from '@/types'
|
| 16 |
-
|
| 17 |
-
const hasGeometryChange = (updates: Partial<TextBlock>) =>
|
| 18 |
-
Object.prototype.hasOwnProperty.call(updates, 'x') ||
|
| 19 |
-
Object.prototype.hasOwnProperty.call(updates, 'y') ||
|
| 20 |
-
Object.prototype.hasOwnProperty.call(updates, 'width') ||
|
| 21 |
-
Object.prototype.hasOwnProperty.call(updates, 'height')
|
| 22 |
-
|
| 23 |
-
const mapTextBlock = (block: DocumentDetail['textBlocks'][number]): TextBlock => ({
|
| 24 |
-
id: block.id,
|
| 25 |
-
x: block.x,
|
| 26 |
-
y: block.y,
|
| 27 |
-
width: block.width,
|
| 28 |
-
height: block.height,
|
| 29 |
-
confidence: block.confidence,
|
| 30 |
-
linePolygons: block.linePolygons as TextBlock['linePolygons'],
|
| 31 |
-
sourceDirection: block.sourceDirection ?? undefined,
|
| 32 |
-
renderedDirection: block.renderedDirection ?? undefined,
|
| 33 |
-
sourceLanguage: block.sourceLanguage ?? undefined,
|
| 34 |
-
rotationDeg: block.rotationDeg ?? undefined,
|
| 35 |
-
detectedFontSizePx: block.detectedFontSizePx ?? undefined,
|
| 36 |
-
detector: block.detector ?? undefined,
|
| 37 |
-
text: block.text ?? undefined,
|
| 38 |
-
translation: block.translation ?? undefined,
|
| 39 |
-
style: block.style as TextBlock['style'],
|
| 40 |
-
fontPrediction: block.fontPrediction as TextBlock['fontPrediction'],
|
| 41 |
-
rendered: block.rendered ?? undefined,
|
| 42 |
-
renderX: block.renderX ?? undefined,
|
| 43 |
-
renderY: block.renderY ?? undefined,
|
| 44 |
-
renderWidth: block.renderWidth ?? undefined,
|
| 45 |
-
renderHeight: block.renderHeight ?? undefined,
|
| 46 |
-
})
|
| 47 |
-
|
| 48 |
-
export type MappedDocument = {
|
| 49 |
-
id: string
|
| 50 |
-
name: string
|
| 51 |
-
width: number
|
| 52 |
-
height: number
|
| 53 |
-
textBlocks: TextBlock[]
|
| 54 |
-
/** Blob hashes for each layer — fetch bytes via useDocumentLayer(). */
|
| 55 |
-
image: string
|
| 56 |
-
segment?: string
|
| 57 |
-
inpainted?: string
|
| 58 |
-
brushLayer?: string
|
| 59 |
-
rendered?: string
|
| 60 |
-
style?: { defaultFont?: string | null }
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
const mapDocumentDetail = (detail: DocumentDetail): MappedDocument => ({
|
| 64 |
-
id: detail.id,
|
| 65 |
-
name: detail.name,
|
| 66 |
-
width: detail.width,
|
| 67 |
-
height: detail.height,
|
| 68 |
-
textBlocks: detail.textBlocks.map(mapTextBlock),
|
| 69 |
-
image: detail.image,
|
| 70 |
-
segment: detail.segment ?? undefined,
|
| 71 |
-
inpainted: detail.inpainted ?? undefined,
|
| 72 |
-
brushLayer: detail.brushLayer ?? undefined,
|
| 73 |
-
rendered: detail.rendered ?? undefined,
|
| 74 |
-
style: detail.style ?? undefined,
|
| 75 |
-
})
|
| 76 |
-
|
| 77 |
-
const toTextBlockInput = (block: TextBlock): TextBlockInput => ({
|
| 78 |
-
id: block.id ?? null,
|
| 79 |
-
x: block.x,
|
| 80 |
-
y: block.y,
|
| 81 |
-
width: block.width,
|
| 82 |
-
height: block.height,
|
| 83 |
-
text: block.text ?? null,
|
| 84 |
-
translation: block.translation ?? null,
|
| 85 |
-
style: (block.style as any) ?? null,
|
| 86 |
-
})
|
| 87 |
-
|
| 88 |
-
export function useCurrentDocument(): MappedDocument | null {
|
| 89 |
-
const documentId = useEditorUiStore((s) => s.currentDocumentId)
|
| 90 |
-
const { data: detail } = useGetDocument(documentId ?? '', {
|
| 91 |
-
query: { enabled: !!documentId },
|
| 92 |
-
})
|
| 93 |
-
if (!detail) return null
|
| 94 |
-
return mapDocumentDetail(detail)
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
export function useTextBlocks() {
|
| 98 |
-
const queryClient = useQueryClient()
|
| 99 |
-
const document = useCurrentDocument()
|
| 100 |
-
const textBlocks = document?.textBlocks ?? []
|
| 101 |
-
const selectedBlockIndex = useEditorUiStore((state) => state.selectedBlockIndex)
|
| 102 |
-
const setSelectedBlockIndex = useEditorUiStore((state) => state.setSelectedBlockIndex)
|
| 103 |
-
|
| 104 |
-
const invalidateDocument = useCallback(
|
| 105 |
-
async (docId: string) => {
|
| 106 |
-
await queryClient.invalidateQueries({
|
| 107 |
-
queryKey: getGetDocumentQueryKey(docId),
|
| 108 |
-
})
|
| 109 |
-
await queryClient.invalidateQueries({
|
| 110 |
-
queryKey: getListDocumentsQueryKey(),
|
| 111 |
-
})
|
| 112 |
-
},
|
| 113 |
-
[queryClient],
|
| 114 |
-
)
|
| 115 |
-
|
| 116 |
-
const updateTextBlocks = useCallback(
|
| 117 |
-
async (blocks: TextBlock[]) => {
|
| 118 |
-
const docId = useEditorUiStore.getState().currentDocumentId
|
| 119 |
-
if (!docId) return
|
| 120 |
-
await putTextBlocks(docId, blocks.map(toTextBlockInput))
|
| 121 |
-
await invalidateDocument(docId)
|
| 122 |
-
},
|
| 123 |
-
[invalidateDocument],
|
| 124 |
-
)
|
| 125 |
-
|
| 126 |
-
const replaceBlock = async (index: number, updates: Partial<TextBlock>) => {
|
| 127 |
-
const docId = useEditorUiStore.getState().currentDocumentId
|
| 128 |
-
if (!docId) return
|
| 129 |
-
const block = document?.textBlocks?.[index]
|
| 130 |
-
if (!block?.id) return
|
| 131 |
-
|
| 132 |
-
const patch: Record<string, unknown> = {}
|
| 133 |
-
for (const [key, value] of Object.entries(updates)) {
|
| 134 |
-
patch[key] = value
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
if (hasGeometryChange(updates)) {
|
| 138 |
-
const ui = useEditorUiStore.getState()
|
| 139 |
-
ui.setShowRenderedImage(false)
|
| 140 |
-
ui.setShowTextBlocksOverlay(true)
|
| 141 |
-
}
|
| 142 |
-
|
| 143 |
-
await patchTextBlock(docId, block.id, patch)
|
| 144 |
-
await invalidateDocument(docId)
|
| 145 |
-
}
|
| 146 |
-
|
| 147 |
-
const appendBlock = async (block: TextBlock) => {
|
| 148 |
-
const docId = useEditorUiStore.getState().currentDocumentId
|
| 149 |
-
if (!docId) return
|
| 150 |
-
await createTextBlock(docId, {
|
| 151 |
-
x: block.x,
|
| 152 |
-
y: block.y,
|
| 153 |
-
width: block.width,
|
| 154 |
-
height: block.height,
|
| 155 |
-
})
|
| 156 |
-
await invalidateDocument(docId)
|
| 157 |
-
const currentBlocks = document?.textBlocks ?? []
|
| 158 |
-
setSelectedBlockIndex(currentBlocks.length)
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
const removeBlock = async (index: number) => {
|
| 162 |
-
const currentBlocks = document?.textBlocks ?? []
|
| 163 |
-
const nextBlocks = currentBlocks.filter((_, idx) => idx !== index)
|
| 164 |
-
await updateTextBlocks(nextBlocks)
|
| 165 |
-
setSelectedBlockIndex(undefined)
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
const clearSelection = () => {
|
| 169 |
-
setSelectedBlockIndex(undefined)
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
return {
|
| 173 |
-
document,
|
| 174 |
-
textBlocks,
|
| 175 |
-
selectedBlockIndex,
|
| 176 |
-
setSelectedBlockIndex,
|
| 177 |
-
clearSelection,
|
| 178 |
-
replaceBlock,
|
| 179 |
-
appendBlock,
|
| 180 |
-
removeBlock,
|
| 181 |
-
updateTextBlocks,
|
| 182 |
-
}
|
| 183 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|