Mayo commited on
Commit
71ff5df
·
unverified ·
1 Parent(s): e5d8e6a

refactor(ui): rewrite hooks around the new scene snapshot + selection store

Browse files
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/blobs/blobs'
6
- import { convertToBlob } from '@/lib/util'
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 { MappedDocument } from '@/hooks/useTextBlocks'
8
 
9
  type BlockContextMenuOptions = {
10
- currentDocument: MappedDocument | null
11
  pointerToDocument: PointerToDocumentFn
12
- selectBlock: (index?: number) => void
13
- removeBlock: (index: number) => void
14
  }
15
 
 
 
 
 
16
  export function useBlockContextMenu({
17
- currentDocument,
18
  pointerToDocument,
19
- selectBlock,
20
- removeBlock,
21
  }: BlockContextMenuOptions) {
22
- const [contextMenuBlockIndex, setContextMenuBlockIndex] = useState<number | undefined>(undefined)
23
 
24
  const handleContextMenu = (event: React.MouseEvent<HTMLElement>) => {
25
- if (!currentDocument) return
26
  const point = pointerToDocument(event)
27
  if (!point) {
28
  event.preventDefault()
29
- setContextMenuBlockIndex(undefined)
30
- selectBlock(undefined)
31
  return
32
  }
33
- const blockIndex = currentDocument.textBlocks.findIndex(
34
- (block) =>
35
- point.x >= block.x &&
36
- point.x <= block.x + block.width &&
37
- point.y >= block.y &&
38
- point.y <= block.y + block.height,
39
- )
40
- if (blockIndex >= 0) {
41
- selectBlock(blockIndex)
42
- setContextMenuBlockIndex(blockIndex)
 
43
  } else {
44
  event.preventDefault()
45
- setContextMenuBlockIndex(undefined)
46
- selectBlock(undefined)
47
  }
48
  }
49
 
50
  const handleDeleteBlock = () => {
51
- if (contextMenuBlockIndex === undefined) return
52
- removeBlock(contextMenuBlockIndex)
53
- setContextMenuBlockIndex(undefined)
54
  }
55
 
56
- const clearContextMenu = () => {
57
- setContextMenuBlockIndex(undefined)
58
- }
59
 
60
  return {
61
- contextMenuBlockIndex,
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 { PointerToDocumentFn, DocumentPointer } from '@/hooks/usePointerToDocument'
7
- import type { MappedDocument } from '@/hooks/useTextBlocks'
8
- import { TextBlock, ToolMode } from '@/types'
 
 
 
 
 
 
 
 
 
 
 
9
 
10
  type BlockDraftingOptions = {
11
  mode: ToolMode
12
- currentDocument: MappedDocument | null
13
  pointerToDocument: PointerToDocumentFn
14
  clearSelection: () => void
15
- onCreateBlock: (block: TextBlock) => void
16
  }
17
 
18
  export function useBlockDrafting({
19
  mode,
20
- currentDocument,
21
  pointerToDocument,
22
  clearSelection,
23
  onCreateBlock,
24
  }: BlockDraftingOptions) {
25
  const dragStartRef = useRef<DocumentPointer | null>(null)
26
- const draftBlockRef = useRef<TextBlock | null>(null)
27
- const [draftBlock, setDraftBlock] = useState<TextBlock | null>(null)
28
 
29
- const resetDraft = () => {
30
  dragStartRef.current = null
31
- draftBlockRef.current = null
32
- setDraftBlock(null)
33
  }
34
 
35
- const finalizeDraft = () => {
36
  if (mode !== 'block') {
37
- resetDraft()
38
  return
39
  }
40
- const block = draftBlockRef.current
41
- dragStartRef.current = null
42
- draftBlockRef.current = null
43
- setDraftBlock(null)
44
- if (!block || !currentDocument) return
45
- const minSize = 4
46
- if (block.width < minSize || block.height < minSize) return
47
- const normalized: TextBlock = {
48
- x: Math.round(block.x),
49
- y: Math.round(block.y),
50
- width: Math.round(block.width),
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 (!currentDocument || mode !== 'block') return
62
  const sourceEvent = event as MouseEvent
63
  const point = pointerToDocument(sourceEvent)
64
  if (!point) {
65
- if ((last || !active) && draftBlockRef.current) {
66
- finalizeDraft()
67
- }
68
  return
69
  }
70
 
71
  if (first) {
72
  dragStartRef.current = point
73
- const nextDraft: TextBlock = {
74
- x: point.x,
75
- y: point.y,
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 nextDraft: TextBlock = {
93
- x,
94
- y,
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
- currentDocumentId?: string,
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
- }, [canvasRef, currentDocumentId])
 
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 { MappedDocument } from '@/hooks/useTextBlocks'
6
- import { convertToImageBitmap } from '@/lib/util'
 
 
 
 
7
 
8
  type BrushLayerDisplayOptions = {
9
- currentDocument: MappedDocument | null
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 (!currentDocument) {
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 = currentDocument.width
40
- canvas.height = currentDocument.height
41
  }
42
 
43
  let cancelled = false
44
-
45
  if (visible && brushLayerData) {
46
  void (async () => {
47
  try {
48
- const bitmap = await convertToImageBitmap(brushLayerData)
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, currentDocument.width, currentDocument.height)
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 PointerToDocumentFn, type DocumentPointer } from '@/hooks/usePointerToDocument'
7
- import type { MappedDocument } from '@/hooks/useTextBlocks'
8
- import { blobToUint8Array } from '@/lib/util'
9
- import type { InpaintRegion } from '@/types'
 
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: InpaintRegion) => Promise<void>
27
  /** Called after finalize with the full-canvas PNG and the patch region. */
28
- onFinalizeFullCanvas?: (fullPng: Uint8Array, patchRegion: InpaintRegion) => Promise<void>
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
- /** Called to set up the canvas content when the document changes (e.g. draw existing mask). */
35
- onCanvasInit?: (ctx: CanvasRenderingContext2D, doc: MappedDocument) => void | Promise<void>
36
  }
37
 
38
  // ---------------------------------------------------------------------------
39
  // Helpers
40
  // ---------------------------------------------------------------------------
41
 
42
- const clampToDocument = (point: DocumentPointer, doc?: MappedDocument): DocumentPointer => {
43
- if (!doc) return point
44
  return {
45
- x: Math.max(0, Math.min(doc.width, point.x)),
46
- y: Math.max(0, Math.min(doc.height, point.y)),
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, doc: MappedDocument): InpaintRegion => {
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(doc.width, Math.ceil(bounds.maxX))
61
- const y1 = Math.min(doc.height, Math.ceil(bounds.maxY))
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: InpaintRegion,
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 ? blobToUint8Array(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 ? blobToUint8Array(blob) : null
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
- currentDocument: MappedDocument | null,
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 (!currentDocument || !config.enabled) {
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 = currentDocument.width
153
- canvas.height = currentDocument.height
154
  }
155
  ctx?.clearRect(0, 0, canvas.width, canvas.height)
156
 
157
  if (config.onCanvasInit && ctx) {
158
- const result = config.onCanvasInit(ctx, currentDocument)
159
- if (result && typeof (result as any).then === 'function') {
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
- }, [currentDocument?.id, currentDocument?.width, currentDocument?.height, config.enabled])
 
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 (!currentDocument || !strokeBounds) return
201
- const patchRegion = boundsToRegion(strokeBounds, currentDocument)
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 || !currentDocument) return
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 = clampToDocument(point, currentDocument)
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 { useGetDocument } from '@/lib/api/documents/documents'
4
  import { useEditorUiStore } from '@/lib/stores/editorUiStore'
5
 
6
  export function useCanvasZoom() {
7
- const scale = useEditorUiStore((state) => state.scale)
8
- const setScaleRaw = useEditorUiStore((state) => state.setScale)
9
- const setAutoFitEnabled = useEditorUiStore((state) => state.setAutoFitEnabled)
10
- const documentId = useEditorUiStore((s) => s.currentDocumentId)
11
- const { data: document } = useGetDocument(documentId ?? '', {
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
- if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 { useQueryClient } from '@tanstack/react-query'
4
- import { useCallback, useRef } from 'react'
5
 
6
- import { useCanvasDrawing } from '@/hooks/useCanvasDrawing'
7
  import type { PointerToDocumentFn } from '@/hooks/usePointerToDocument'
8
- import type { MappedDocument } from '@/hooks/useTextBlocks'
9
- import { getGetDocumentQueryKey, getListDocumentsQueryKey } from '@/lib/api/documents/documents'
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 { convertToImageBitmap } from '@/lib/util'
18
- import type { ToolMode } from '@/types'
 
 
 
 
19
 
20
  type MaskDrawingOptions = {
21
  mode: ToolMode
22
- currentDocument: MappedDocument | null
23
  segmentData?: Uint8Array
24
  pointerToDocument: PointerToDocumentFn
25
  showMask: boolean
26
  enabled: boolean
27
  }
28
 
 
 
 
 
 
 
 
29
  export function useMaskDrawing({
30
  mode,
31
- currentDocument,
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 invalidateDocument = useCallback(
43
- async (documentId: string) => {
44
- await queryClient.invalidateQueries({
45
- queryKey: getGetDocumentQueryKey(documentId),
46
- })
47
- await queryClient.invalidateQueries({
48
- queryKey: getListDocumentsQueryKey(),
49
- })
50
- },
51
- [queryClient],
52
- )
53
 
54
- const { canvasRef, bind: rawBind } = useCanvasDrawing(currentDocument, pointerToDocument, {
55
  getColor: () => (isEraseMode ? '#000000' : '#ffffff'),
56
  blendMode: 'source-over',
57
  getBrushSize: () => usePreferencesStore.getState().brushConfig.size,
58
  enabled: showMask,
59
- onCanvasInit: (ctx, doc) => {
60
- // Fill black then draw existing segment mask on top
61
  ctx.fillStyle = '#000'
62
- ctx.fillRect(0, 0, doc.width, doc.height)
63
  if (segmentData) {
64
  void (async () => {
65
  try {
66
- const bitmap = await convertToImageBitmap(segmentData)
67
  ctx.save()
68
- ctx.clearRect(0, 0, doc.width, doc.height)
69
- ctx.drawImage(bitmap, 0, 0, doc.width, doc.height)
70
  ctx.restore()
71
  bitmap.close()
72
  } catch (e) {
@@ -76,28 +71,27 @@ export function useMaskDrawing({
76
  }
77
  },
78
  onFinalizeFullCanvas: async (fullPng) => {
79
- const documentId = useEditorUiStore.getState().currentDocumentId
80
- if (!documentId) return
81
  try {
82
- await updateMaskApi(documentId, {
83
- data: Array.from(fullPng),
 
 
84
  })
 
85
  } catch (e) {
86
- useEditorUiStore.getState().showError(normalizeErrorMessage(e))
87
  }
88
  },
89
  onFinalize: async (_patch, region) => {
90
- const documentId = useEditorUiStore.getState().currentDocumentId
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(doc.width, Math.ceil(region.x + region.width + margin))
100
- const y1 = Math.min(doc.height, Math.ceil(region.y + region.height + margin))
101
  const inpaintRegion = {
102
  x: x0,
103
  y: y0,
@@ -108,18 +102,21 @@ export function useMaskDrawing({
108
  .catch(() => {})
109
  .then(async () => {
110
  try {
111
- await inpaintRegionApi(documentId, { region: inpaintRegion })
112
- await invalidateDocument(documentId)
 
 
 
 
 
113
  useEditorUiStore.getState().setShowInpaintedImage(true)
114
  } catch (e) {
115
- useEditorUiStore.getState().showError(normalizeErrorMessage(e))
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 { MappedDocument } from '@/hooks/useTextBlocks'
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
- currentDocument: MappedDocument | null
18
  pointerToDocument: PointerToDocumentFn
19
  enabled: boolean
20
  action: 'paint' | 'erase'
21
  targetCanvasRef?: RefObject<HTMLCanvasElement | null>
22
  }
23
 
 
 
 
 
24
  export function useRenderBrushDrawing({
25
- currentDocument,
26
  pointerToDocument,
27
  enabled,
28
  action,
29
  targetCanvasRef,
30
  }: RenderBrushOptions) {
31
- const queryClient = useQueryClient()
32
  const isErasing = action === 'erase'
 
 
 
33
 
34
- return useCanvasDrawing(currentDocument, pointerToDocument, {
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 (patch, region) => {
42
- const documentId = useEditorUiStore.getState().currentDocumentId
43
- if (!documentId) return
44
- await updateBrushLayer(documentId, {
45
- data: Array.from(patch),
46
- region,
47
- })
48
- await queryClient.invalidateQueries({
49
- queryKey: getGetDocumentQueryKey(documentId),
50
- })
51
- await queryClient.invalidateQueries({
52
- queryKey: getListDocumentsQueryKey(),
53
- })
54
- useEditorUiStore.getState().setShowBrushLayer(true)
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
- }