Skip to content

Commit 0c807d2

Browse files
committed
feat: accept bboxes input and add grid snapping to Create Bounding Boxes
1 parent 9e5fb67 commit 0c807d2

4 files changed

Lines changed: 326 additions & 48 deletions

File tree

src/components/boundingBoxes/WidgetBoundingBoxes.vue

Lines changed: 71 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,72 @@
44
data-testid="bounding-boxes"
55
@pointerdown.stop
66
>
7-
<div
8-
ref="canvasContainer"
9-
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
10-
:style="canvasStyle"
11-
>
12-
<canvas
13-
ref="canvasEl"
14-
tabindex="0"
15-
class="absolute inset-0 size-full rounded-sm outline-none"
16-
:style="{ cursor: canvasCursor }"
17-
@pointerdown="onPointerDown"
18-
@pointermove="onCanvasPointerMove"
19-
@pointerup="onDocPointerUp"
20-
@pointercancel="onDocPointerUp"
21-
@pointerleave="onPointerLeave"
22-
@lostpointercapture="onDocPointerUp"
23-
@dblclick="onDoubleClick"
24-
@keydown="onCanvasKeyDown"
25-
@focus="focused = true"
26-
@blur="focused = false"
27-
/>
28-
<textarea
29-
v-if="inlineEditor"
30-
ref="inlineEditorEl"
31-
v-model="inlineEditor.value"
32-
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
33-
:style="inlineEditor.style"
34-
data-capture-wheel="true"
35-
@keydown.stop="onInlineKeyDown"
36-
@blur="commitInlineEditor"
37-
/>
7+
<div class="flex flex-col">
8+
<div
9+
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
10+
>
11+
<Button
12+
v-tooltip.bottom="{ value: $t('boundingBoxes.grid'), showDelay: 300 }"
13+
variant="textonly"
14+
size="unset"
15+
:aria-pressed="grid"
16+
:class="
17+
cn(
18+
actionBtnClass,
19+
grid && 'bg-component-node-widget-background-selected'
20+
)
21+
"
22+
@click="grid = !grid"
23+
>
24+
<i class="icon-[lucide--grid-3x3] size-4" />
25+
<span>{{ $t('boundingBoxes.grid') }}</span>
26+
</Button>
27+
<Button
28+
v-tooltip.bottom="{
29+
value: $t('boundingBoxes.clearAll'),
30+
showDelay: 300
31+
}"
32+
variant="textonly"
33+
size="unset"
34+
:class="cn(actionBtnClass, 'ml-auto')"
35+
@click="clearAll"
36+
>
37+
<i class="icon-[lucide--undo-2] size-4" />
38+
<span>{{ $t('boundingBoxes.clearAll') }}</span>
39+
</Button>
40+
</div>
41+
<div
42+
ref="canvasContainer"
43+
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
44+
:style="canvasStyle"
45+
>
46+
<canvas
47+
ref="canvasEl"
48+
tabindex="0"
49+
class="absolute inset-0 size-full rounded-sm outline-none"
50+
:style="{ cursor: canvasCursor }"
51+
@pointerdown="onPointerDown"
52+
@pointermove="onCanvasPointerMove"
53+
@pointerup="onDocPointerUp"
54+
@pointercancel="onDocPointerUp"
55+
@pointerleave="onPointerLeave"
56+
@lostpointercapture="onDocPointerUp"
57+
@dblclick="onDoubleClick"
58+
@keydown="onCanvasKeyDown"
59+
@focus="focused = true"
60+
@blur="focused = false"
61+
/>
62+
<textarea
63+
v-if="inlineEditor"
64+
ref="inlineEditorEl"
65+
v-model="inlineEditor.value"
66+
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
67+
:style="inlineEditor.style"
68+
data-capture-wheel="true"
69+
@keydown.stop="onInlineKeyDown"
70+
@blur="commitInlineEditor"
71+
/>
72+
</div>
3873
</div>
3974

4075
<div
@@ -122,16 +157,6 @@
122157
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
123158
{{ $t('boundingBoxes.clickRegionToEdit') }}
124159
</div>
125-
126-
<Button
127-
variant="secondary"
128-
size="md"
129-
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
130-
@click="clearAll"
131-
>
132-
<i class="icon-[lucide--undo-2]" />
133-
{{ $t('boundingBoxes.clearAll') }}
134-
</Button>
135160
</div>
136161
</template>
137162

@@ -147,6 +172,9 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
147172
import type { BoundingBox } from '@/types/boundingBoxes'
148173
import type { NodeId } from '@/types/nodeId'
149174
175+
const actionBtnClass =
176+
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
177+
150178
const { nodeId } = defineProps<{ nodeId: NodeId }>()
151179
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
152180
@@ -172,7 +200,8 @@ const {
172200
commitInlineEditor,
173201
setActiveType,
174202
clearAll,
175-
syncState
203+
syncState,
204+
grid
176205
} = useBoundingBoxes(nodeId, {
177206
canvasEl,
178207
canvasContainer,

src/composables/boundingBoxes/useBoundingBoxes.test.ts

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,32 @@ import { useBoundingBoxes } from './useBoundingBoxes'
88
import type { BoundingBox } from '@/types/boundingBoxes'
99
import { toNodeId } from '@/types/nodeId'
1010

11-
const { appState } = vi.hoisted(() => ({
12-
appState: { node: null as unknown }
11+
const { appState, outputState } = vi.hoisted(() => ({
12+
appState: { node: null as unknown },
13+
outputState: {
14+
outputs: undefined as unknown,
15+
nodeOutputs: null as { value: Record<string, unknown> } | null
16+
}
1317
}))
1418

1519
vi.mock('@/scripts/app', () => ({
1620
app: { canvas: { graph: { getNodeById: () => appState.node } } }
1721
}))
1822

23+
vi.mock('@/stores/nodeOutputStore', async () => {
24+
const { ref } = await import('vue')
25+
const nodeOutputs = ref<Record<string, unknown>>({})
26+
outputState.nodeOutputs = nodeOutputs
27+
return {
28+
useNodeOutputStore: () => ({
29+
nodeOutputs,
30+
nodePreviewImages: ref({}),
31+
getNodeImageUrls: () => undefined,
32+
getNodeOutputs: () => outputState.outputs
33+
})
34+
}
35+
})
36+
1937
const ctx = {
2038
measureText: (s: string) => ({ width: s.length * 7 }),
2139
setTransform: () => {},
@@ -27,6 +45,9 @@ const ctx = {
2745
save: () => {},
2846
restore: () => {},
2947
beginPath: () => {},
48+
moveTo: () => {},
49+
arc: () => {},
50+
fill: () => {},
3051
rect: () => {},
3152
clip: () => {},
3253
font: '',
@@ -128,9 +149,23 @@ const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
128149
...over
129150
})
130151

152+
function makeConnectedNode() {
153+
return {
154+
widgets: [
155+
{ name: 'width', value: 512 },
156+
{ name: 'height', value: 512 }
157+
],
158+
findInputSlot: (name: string) => (name === 'bboxes' ? 1 : -1),
159+
getInputNode: () => null,
160+
isInputConnected: () => true
161+
}
162+
}
163+
131164
beforeEach(() => {
132165
setActivePinia(createPinia())
133166
appState.node = makeNode()
167+
outputState.outputs = undefined
168+
if (outputState.nodeOutputs) outputState.nodeOutputs.value = {}
134169
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
135170
void Promise.resolve().then(() => cb(0))
136171
return 1
@@ -239,6 +274,77 @@ describe('useBoundingBoxes inline editor', () => {
239274
})
240275
})
241276

277+
describe('useBoundingBoxes incoming bboxes input', () => {
278+
it('overrides the canvas when the bboxes input is connected', () => {
279+
appState.node = makeConnectedNode()
280+
outputState.outputs = {
281+
input_bboxes: [box({ x: 0, y: 0, width: 100, height: 100 })]
282+
}
283+
const c = setup([])
284+
expect(c.modelValue.value).toHaveLength(1)
285+
expect(c.modelValue.value[0].width).toBe(100)
286+
})
287+
288+
it('replaces existing drawn boxes with the incoming ones', () => {
289+
appState.node = makeConnectedNode()
290+
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
291+
const c = setup([box({ x: 200, width: 300 }), box({ x: 400, width: 50 })])
292+
expect(c.modelValue.value).toHaveLength(1)
293+
expect(c.modelValue.value[0].width).toBe(100)
294+
})
295+
296+
it('ignores incoming output when the input is not connected', () => {
297+
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
298+
const c = setup([])
299+
expect(c.modelValue.value).toHaveLength(0)
300+
})
301+
302+
it('applies incoming boxes when outputs stream in after mount', async () => {
303+
appState.node = makeConnectedNode()
304+
const c = setup([])
305+
expect(c.modelValue.value).toHaveLength(0)
306+
307+
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
308+
outputState.nodeOutputs!.value = { updated: true }
309+
await flush()
310+
311+
expect(c.modelValue.value).toHaveLength(1)
312+
expect(c.modelValue.value[0].width).toBe(100)
313+
})
314+
})
315+
316+
describe('useBoundingBoxes grid snapping', () => {
317+
it('snaps a drawn box to the grid when grid is enabled (default)', async () => {
318+
const c = setup()
319+
c.onPointerDown(pe(10, 10))
320+
c.onCanvasPointerMove(pe(60, 60))
321+
c.onDocPointerUp(pe(60, 60))
322+
await flush()
323+
expect(c.modelValue.value).toHaveLength(1)
324+
expect(c.modelValue.value[0].x).toBe(64)
325+
expect(c.modelValue.value[0].width).toBe(256)
326+
})
327+
328+
it('does not snap when grid is disabled', async () => {
329+
const c = setup()
330+
c.grid.value = false
331+
c.onPointerDown(pe(10, 10))
332+
c.onCanvasPointerMove(pe(55, 55))
333+
c.onDocPointerUp(pe(55, 55))
334+
await flush()
335+
expect(c.modelValue.value[0].width).toBe(230)
336+
})
337+
338+
it('keeps the anchored edge fixed when resizing a single edge', async () => {
339+
const c = setup([box({ x: 51, y: 51, width: 256, height: 256 })])
340+
c.onPointerDown(pe(60, 30))
341+
c.onCanvasPointerMove(pe(80, 30))
342+
c.onDocPointerUp(pe(80, 30))
343+
await flush()
344+
expect(c.modelValue.value[0].x).toBe(51)
345+
})
346+
})
347+
242348
describe('useBoundingBoxes hover cursor', () => {
243349
it('switches to a pointer cursor over a tag', async () => {
244350
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])

0 commit comments

Comments
 (0)