Skip to content

Commit 5a910e1

Browse files
committed
fix: address coderabbit review on the layer editor UX batch
1 parent 4367182 commit 5a910e1

7 files changed

Lines changed: 139 additions & 36 deletions

File tree

src/renderer/extensions/compositor/components/WidgetCompositor.test.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { render, screen } from '@testing-library/vue'
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
33
import { ref } from 'vue'
4+
import { createI18n } from 'vue-i18n'
45

56
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
67
import { toNodeId } from '@/types/nodeId'
@@ -40,17 +41,33 @@ vi.mock(
4041
})
4142
})
4243
)
43-
vi.mock('vue-i18n', () => ({
44-
useI18n: () => ({ t: (key: string) => key })
45-
}))
44+
const i18n = createI18n({
45+
legacy: false,
46+
locale: 'en',
47+
messages: {
48+
en: {
49+
compositor: {
50+
empty: 'Run the workflow to generate a composite',
51+
open: 'Open Compositor',
52+
runWorkflowFirst: 'Run the workflow once to load input images',
53+
downloadPsd: 'Download PSD'
54+
}
55+
}
56+
}
57+
})
4658

4759
const nodeId = toNodeId(9)
4860
const graphNode = { id: nodeId, graph: null } as unknown as LGraphNode
4961

5062
function renderWidget() {
5163
return render(WidgetCompositor, {
5264
props: { nodeId },
53-
global: { stubs: { Button: { template: '<button v-bind="$attrs" />' } } }
65+
global: {
66+
plugins: [i18n],
67+
stubs: {
68+
Button: { template: '<button v-bind="$attrs"><slot /></button>' }
69+
}
70+
}
5471
})
5572
}
5673

@@ -64,8 +81,11 @@ describe('WidgetCompositor', () => {
6481
it('renders the empty state when the node is not in the graph (search preview)', () => {
6582
renderWidget()
6683

67-
expect(screen.getByTestId('compositor-empty')).toBeTruthy()
84+
expect(screen.getByTestId('compositor-empty').textContent).toContain(
85+
'Run the workflow to generate a composite'
86+
)
6887
const open = screen.getByTestId('compositor-open-button')
88+
expect(open.textContent).toContain('Open Compositor')
6989
expect(open.hasAttribute('disabled')).toBe(true)
7090
})
7191

src/renderer/extensions/layerEditor/components/PropertyNumberField.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import userEvent from '@testing-library/user-event'
22
import { render, screen } from '@testing-library/vue'
33
import { describe, expect, it } from 'vitest'
4+
import { nextTick } from 'vue'
45

56
import PropertyNumberField from './PropertyNumberField.vue'
67

@@ -28,6 +29,7 @@ describe('PropertyNumberField', () => {
2829
it('reverts an emptied field instead of committing 0', async () => {
2930
const { emitted } = renderField()
3031
const input = await commitValue('')
32+
await nextTick()
3133
expect(emitted('commit')).toBeUndefined()
3234
expect(input.value).toBe('40')
3335
})

src/renderer/extensions/layerEditor/composables/useLayerEditorSession.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
RasterData
2525
} from '@/renderer/extensions/layerEditor/engine/node'
2626

27+
import { reorderDropIndex } from './layerPanelDnd'
2728
import { useLayerEditorSession } from './useLayerEditorSession'
2829

2930
class FakeCompositor implements Compositor {
@@ -383,6 +384,39 @@ describe('useLayerEditorSession', () => {
383384
expect(session.imageLayers.value.map((n) => n.name)).toEqual(['A', 'B'])
384385
})
385386

387+
it('drag-drop math lands the dragged layer around the target in both directions', async () => {
388+
const { session } = makeSession()
389+
await session.loadImages(['a.png', 'b.png', 'c.png'], ['A', 'B', 'C'])
390+
const names = () => session.imageLayers.value.map((n) => n.name)
391+
const idOf = (name: string) =>
392+
session.imageLayers.value.find((n) => n.name === name)!.id
393+
const drop = (
394+
dragged: string,
395+
target: string,
396+
pos: 'above' | 'below'
397+
) => {
398+
const toIndex = reorderDropIndex(
399+
session.imageLayers.value.map((n) => n.id),
400+
idOf(target),
401+
pos,
402+
1
403+
)
404+
session.moveLayerTo(idOf(dragged), toIndex!)
405+
}
406+
407+
drop('A', 'C', 'above')
408+
expect(names()).toEqual(['B', 'C', 'A'])
409+
410+
drop('A', 'B', 'below')
411+
expect(names()).toEqual(['A', 'B', 'C'])
412+
413+
drop('C', 'B', 'below')
414+
expect(names()).toEqual(['A', 'C', 'B'])
415+
416+
drop('A', 'B', 'below')
417+
expect(names()).toEqual(['C', 'A', 'B'])
418+
})
419+
386420
it('moveLayerTo refuses to drop below the background fill', async () => {
387421
const { session } = await loadedSession()
388422
const [a] = session.imageLayers.value

src/renderer/extensions/layerEditor/engine/history.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,19 @@ describe('History — dirty tracking and eviction', () => {
215215
expect(h.labels().undo).toHaveLength(3)
216216
})
217217

218+
it('a branch that discards the save point stays dirty', () => {
219+
const h = new History()
220+
h.push(new TestCommand('a'))
221+
h.markSaved()
222+
h.undo()
223+
h.push(new TestCommand('b'))
224+
expect(h.dirty()).toBe(true)
225+
h.undo()
226+
expect(h.dirty()).toBe(true)
227+
h.markSaved()
228+
expect(h.dirty()).toBe(false)
229+
})
230+
218231
it('an evicted history stays dirty until the next markSaved', () => {
219232
const h = new History({ byteBudget: 1, minSteps: 1 })
220233
h.push(new TestCommand('a', Dirty.DRAWABLE, 64))

src/renderer/extensions/layerEditor/engine/history.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,9 @@ export class History {
115115
this.undoStack.push(cmd)
116116
this.sizes.set(cmd, cmd.sizeBytes())
117117
this.undoBytes += this.sizes.get(cmd) ?? 0
118+
if (this.redoStack.length > 0 && this.dirtyCount < 0) {
119+
this.cleanReachable = false
120+
}
118121
this.redoStack = []
119122
this.bumpDirty()
120123
this.evict()

src/renderer/extensions/layerEditor/engine/render/renderStack.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,26 @@ describe('renderDocument', () => {
171171
expect(c.freed).toEqual([c.allocated[0].id])
172172
})
173173

174+
it('frees every allocated target when a group composite throws mid-build', () => {
175+
class ExplodingCompositor extends FakeCompositor {
176+
targetComposites = 0
177+
override composite(
178+
inputs: CompositeInput[],
179+
target?: FBOHandle | null,
180+
region?: Rect
181+
) {
182+
if (target && ++this.targetComposites === 2) throw new Error('boom')
183+
super.composite(inputs, target, region)
184+
}
185+
}
186+
const c = new ExplodingCompositor()
187+
const first = group([leaf(1)], { id: 'g1' })
188+
const second = group([leaf(0.9)], { id: 'g2' })
189+
expect(() => renderDocument(doc([first, second]), deps(c))).toThrow('boom')
190+
expect(c.allocated).toHaveLength(2)
191+
expect([...c.freed].sort()).toEqual(c.allocated.map((h) => h.id).sort())
192+
})
193+
174194
it('splices a pass-through group directly into the parent stack (no isolation target)', () => {
175195
const c = new FakeCompositor()
176196
const g = group([leaf(1), leaf(1)], { passThrough: true })

src/renderer/extensions/layerEditor/engine/render/renderStack.ts

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -227,41 +227,52 @@ function buildInputs(
227227
devicePixelRatio: deps.devicePixelRatio ?? 1
228228
}
229229

230-
for (const node of group.children) {
231-
if (!node.visible || node.opacity <= 0) continue
230+
try {
231+
for (const node of group.children) {
232+
if (!node.visible || node.opacity <= 0) continue
232233

233-
if (node.kind === 'group') {
234-
const g = node as GroupData
235-
const sub = buildInputs(g, doc, deps, used)
236-
if (g.passThrough) {
237-
inputs.push(...sub.inputs)
238-
cleanups.push(sub.cleanup)
234+
if (node.kind === 'group') {
235+
const g = node as GroupData
236+
const sub = buildInputs(g, doc, deps, used)
237+
if (g.passThrough) {
238+
inputs.push(...sub.inputs)
239+
cleanups.push(sub.cleanup)
240+
continue
241+
}
242+
const handle = deps.compositor.allocTarget(doc.width, doc.height)
243+
try {
244+
deps.compositor.composite(sub.inputs, handle)
245+
} catch (err) {
246+
deps.compositor.freeTarget(handle)
247+
sub.cleanup()
248+
throw err
249+
}
250+
sub.cleanup()
251+
cleanups.push(() => deps.compositor.freeTarget(handle))
252+
const groupTexture = deps.compositor.targetTexture(handle)
253+
if (groupTexture) {
254+
inputs.push({
255+
texture: { source: groupTexture, rect: region, linear: true },
256+
opacity: node.opacity,
257+
mode: resolveMode(node.mode),
258+
mask: renderMaskTexture(node, region, deps, placed, used)
259+
})
260+
}
239261
continue
240262
}
241-
const handle = deps.compositor.allocTarget(doc.width, doc.height)
242-
deps.compositor.composite(sub.inputs, handle)
243-
sub.cleanup()
244-
cleanups.push(() => deps.compositor.freeTarget(handle))
245-
const groupTexture = deps.compositor.targetTexture(handle)
246-
if (groupTexture) {
247-
inputs.push({
248-
texture: { source: groupTexture, rect: region, linear: true },
249-
opacity: node.opacity,
250-
mode: resolveMode(node.mode),
251-
mask: renderMaskTexture(node, region, deps, placed, used)
252-
})
253-
}
254-
continue
255-
}
256263

257-
const texture = renderLeafTexture(node, ctx, deps, used)
258-
if (!texture) continue
259-
inputs.push({
260-
texture,
261-
opacity: node.opacity,
262-
mode: resolveMode(node.mode),
263-
mask: renderMaskTexture(node, region, deps, placed, used)
264-
})
264+
const texture = renderLeafTexture(node, ctx, deps, used)
265+
if (!texture) continue
266+
inputs.push({
267+
texture,
268+
opacity: node.opacity,
269+
mode: resolveMode(node.mode),
270+
mask: renderMaskTexture(node, region, deps, placed, used)
271+
})
272+
}
273+
} catch (err) {
274+
cleanups.forEach((fn) => fn())
275+
throw err
265276
}
266277

267278
return { inputs, cleanup: () => cleanups.forEach((fn) => fn()) }

0 commit comments

Comments
 (0)