Skip to content

Commit 4367182

Browse files
committed
fix: layer editor UX and review follow-ups
1 parent bdc0345 commit 4367182

19 files changed

Lines changed: 433 additions & 30 deletions

src/locales/en/main.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1290,6 +1290,7 @@
12901290
"title": "Compositor",
12911291
"webglUnavailable": "WebGL is unavailable - the canvas cannot render",
12921292
"loadFailed": "Failed to load layers",
1293+
"needsTwoImages": "The layer editor needs at least two output images",
12931294
"layersFailedToLoad": "One layer failed to load | {count} layers failed to load",
12941295
"selectTool": "Select",
12951296
"handTool": "Hand",
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { render, screen } from '@testing-library/vue'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { ref } from 'vue'
4+
5+
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
6+
import { toNodeId } from '@/types/nodeId'
7+
8+
import {
9+
clearCompositorLayers,
10+
setCompositorLayers
11+
} from '../composables/useCompositorLayers'
12+
import WidgetCompositor from './WidgetCompositor.vue'
13+
14+
const { getNodeById } = vi.hoisted(() => ({
15+
getNodeById: vi.fn<() => unknown>(() => undefined)
16+
}))
17+
18+
vi.mock('@/scripts/app', () => ({
19+
app: { canvas: { graph: { getNodeById } } }
20+
}))
21+
vi.mock('@/stores/nodeOutputStore', () => ({
22+
useNodeOutputStore: () => ({
23+
getNodeImageUrls: () => undefined,
24+
nodeOutputs: {},
25+
nodePreviewImages: {}
26+
})
27+
}))
28+
vi.mock(
29+
'@/renderer/extensions/compositor/composables/useCompositorEditor',
30+
() => ({
31+
useCompositorEditor: () => ({ openCompositorEditor: vi.fn() })
32+
})
33+
)
34+
vi.mock(
35+
'@/renderer/extensions/compositor/composables/useCompositorPsdDownload',
36+
() => ({
37+
useCompositorPsdDownload: () => ({
38+
exporting: ref(false),
39+
downloadPsd: vi.fn()
40+
})
41+
})
42+
)
43+
vi.mock('vue-i18n', () => ({
44+
useI18n: () => ({ t: (key: string) => key })
45+
}))
46+
47+
const nodeId = toNodeId(9)
48+
const graphNode = { id: nodeId, graph: null } as unknown as LGraphNode
49+
50+
function renderWidget() {
51+
return render(WidgetCompositor, {
52+
props: { nodeId },
53+
global: { stubs: { Button: { template: '<button v-bind="$attrs" />' } } }
54+
})
55+
}
56+
57+
describe('WidgetCompositor', () => {
58+
beforeEach(() => {
59+
vi.clearAllMocks()
60+
clearCompositorLayers(graphNode)
61+
getNodeById.mockReturnValue(undefined)
62+
})
63+
64+
it('renders the empty state when the node is not in the graph (search preview)', () => {
65+
renderWidget()
66+
67+
expect(screen.getByTestId('compositor-empty')).toBeTruthy()
68+
const open = screen.getByTestId('compositor-open-button')
69+
expect(open.hasAttribute('disabled')).toBe(true)
70+
})
71+
72+
it('enables opening once the graph node exists with cached layers', () => {
73+
getNodeById.mockReturnValue(graphNode)
74+
setCompositorLayers(graphNode, [
75+
{ filename: 'a.png', subfolder: '', type: 'temp' }
76+
])
77+
78+
renderWidget()
79+
80+
const open = screen.getByTestId('compositor-open-button')
81+
expect(open.hasAttribute('disabled')).toBe(false)
82+
})
83+
})

src/renderer/extensions/compositor/components/WidgetCompositor.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const outputUrl = ref<string | null>(null)
9393
9494
const litegraphNode = computed(() => {
9595
if (!nodeId || !app.canvas.graph) return null
96-
return app.canvas.graph.getNodeById(nodeId)
96+
return app.canvas.graph.getNodeById(nodeId) ?? null
9797
})
9898
9999
function updateOutputUrl(): void {
@@ -125,7 +125,7 @@ const dimensionsLabel = computed(() =>
125125
126126
const canOpen = computed(() => {
127127
const node = litegraphNode.value
128-
return node !== null && hasCompositorLayers(node)
128+
return !!node && hasCompositorLayers(node)
129129
})
130130
131131
function onPreviewLoad(event: Event): void {

src/renderer/extensions/layerEditor/components/LayerEditorCanvas.vue

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
ref="viewportRef"
44
tabindex="0"
55
data-testid="layer-editor-viewport"
6-
class="relative min-h-0 min-w-0 flex-1 touch-none overflow-hidden bg-base-background outline-none"
6+
class="focus-visible:ring-ring relative min-h-0 min-w-0 flex-1 touch-none overflow-hidden bg-base-background outline-none focus-visible:ring-1"
77
:style="{ cursor: viewportCursor }"
88
@pointerdown="session.onPointerDown"
99
@pointermove="session.onPointerMove"
@@ -67,7 +67,9 @@ const checkerboardStyle = {
6767
}
6868
6969
let firstLayout = true
70-
useResizeObserver(viewportRef, () => {
70+
useResizeObserver(viewportRef, (entries) => {
71+
const rect = entries[0]?.contentRect
72+
if (!rect?.width || !rect?.height) return
7173
if (firstLayout) {
7274
firstLayout = false
7375
session.fitView()

src/renderer/extensions/layerEditor/components/LayerPanel.vue

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@
1212
<div
1313
v-for="node in rows"
1414
:key="node.id"
15-
:class="rowClass(isRowSelected(node.id))"
15+
:class="cn(rowClass(isRowSelected(node.id)), dropHintClass(node.id))"
1616
data-testid="layer-panel-row"
17+
:draggable="renamingId !== node.id"
1718
@click="onRowClick(node.id, $event)"
19+
@dragstart="onRowDragStart(node.id, $event)"
20+
@dragover="onRowDragOver(node.id, $event)"
21+
@drop="onRowDrop(node.id, $event)"
22+
@dragend="endDrag"
1823
>
1924
<button
2025
:class="eyeButtonClass(node.visible)"
@@ -34,7 +39,7 @@
3439
<input
3540
v-if="renamingId === node.id"
3641
:ref="focusInput"
37-
class="min-w-0 rounded-sm border border-border-default bg-base-background px-1 text-xs text-base-foreground"
42+
class="min-w-0 rounded-sm border border-border-default bg-base-background px-1 text-xs text-base-foreground select-text"
3843
:value="node.name"
3944
@blur="commitRename(node.id, $event)"
4045
@keydown.enter="commitRename(node.id, $event)"
@@ -115,6 +120,11 @@ import { computed, ref, watch } from 'vue'
115120
import type { ComponentPublicInstance } from 'vue'
116121
import { useI18n } from 'vue-i18n'
117122
123+
import type { LayerDropPos } from '@/renderer/extensions/layerEditor/composables/layerPanelDnd'
124+
import {
125+
dropPositionFor,
126+
reorderDropIndex
127+
} from '@/renderer/extensions/layerEditor/composables/layerPanelDnd'
118128
import { DEFAULT_BACKGROUND_COLOR } from '@/renderer/extensions/layerEditor/composables/useLayerEditorSession'
119129
import type { LayerEditorSession } from '@/renderer/extensions/layerEditor/composables/useLayerEditorSession'
120130
import type {
@@ -167,7 +177,7 @@ function isRowSelected(id: string): boolean {
167177
168178
function rowClass(selected: boolean, extra?: string): string {
169179
return cn(
170-
'group flex cursor-pointer items-center gap-2 border-border-default px-2 py-1.5',
180+
'group flex cursor-pointer items-center gap-2 border-border-default px-2 py-1.5 select-none',
171181
'hover:bg-secondary-background-hover',
172182
selected && 'bg-secondary-background-selected',
173183
extra
@@ -188,6 +198,60 @@ function eyeIconClass(visible: boolean): string {
188198
)
189199
}
190200
201+
const dragId = ref<string | null>(null)
202+
const dropHint = ref<{ id: string; pos: LayerDropPos } | null>(null)
203+
204+
function endDrag(): void {
205+
dragId.value = null
206+
dropHint.value = null
207+
}
208+
209+
function dropHintClass(id: string): string {
210+
const hint = dropHint.value
211+
if (hint?.id !== id) return 'relative'
212+
return cn(
213+
'relative',
214+
hint.pos === 'above'
215+
? "before:absolute before:inset-x-0 before:top-0 before:h-0.5 before:bg-base-foreground before:content-['']"
216+
: "after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-base-foreground after:content-['']"
217+
)
218+
}
219+
220+
function onRowDragStart(id: string, e: DragEvent): void {
221+
dragId.value = id
222+
if (e.dataTransfer) {
223+
e.dataTransfer.effectAllowed = 'move'
224+
e.dataTransfer.setData('text/plain', id)
225+
}
226+
}
227+
228+
function onRowDragOver(id: string, e: DragEvent): void {
229+
if (!dragId.value || dragId.value === id) return
230+
e.preventDefault()
231+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
232+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
233+
const ratio = rect.height > 0 ? (e.clientY - rect.top) / rect.height : 0.5
234+
const pos = dropPositionFor(ratio)
235+
if (dropHint.value?.id !== id || dropHint.value?.pos !== pos)
236+
dropHint.value = { id, pos }
237+
}
238+
239+
function onRowDrop(id: string, e: DragEvent): void {
240+
e.preventDefault()
241+
const dragged = dragId.value
242+
const hint = dropHint.value
243+
if (dragged && hint?.id === id) {
244+
const toIndex = reorderDropIndex(
245+
imageLayers.value.map((n) => n.id),
246+
id,
247+
hint.pos,
248+
backgroundLayer.value ? 1 : 0
249+
)
250+
if (toIndex !== null) session.moveLayerTo(dragged, toIndex)
251+
}
252+
endDrag()
253+
}
254+
191255
function onRowClick(id: string, e: MouseEvent): void {
192256
if (e.ctrlKey || e.metaKey || e.shiftKey) {
193257
const current = selectedNodeIds.value

src/renderer/extensions/layerEditor/components/LayerPropertiesPanel.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,7 @@ function onOpacity(percent: number): void {
345345
}
346346
347347
function isBlendFn(value: unknown): value is BlendFn {
348-
return typeof value === 'string' && value in LAYER_MODES
348+
return typeof value === 'string' && Object.hasOwn(LAYER_MODES, value)
349349
}
350350
351351
function onBlendChange(value: AcceptableValue): void {
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import userEvent from '@testing-library/user-event'
2+
import { render, screen } from '@testing-library/vue'
3+
import { describe, expect, it } from 'vitest'
4+
5+
import PropertyNumberField from './PropertyNumberField.vue'
6+
7+
async function commitValue(value: string) {
8+
const input = screen.getByLabelText<HTMLInputElement>('X')
9+
await userEvent.clear(input)
10+
if (value !== '') await userEvent.type(input, value)
11+
await userEvent.tab()
12+
return input
13+
}
14+
15+
function renderField() {
16+
return render(PropertyNumberField, {
17+
props: { label: 'X', value: 40, min: 0, max: 100 }
18+
})
19+
}
20+
21+
describe('PropertyNumberField', () => {
22+
it('commits a clamped value on change', async () => {
23+
const { emitted } = renderField()
24+
await commitValue('250')
25+
expect(emitted('commit')).toEqual([[100]])
26+
})
27+
28+
it('reverts an emptied field instead of committing 0', async () => {
29+
const { emitted } = renderField()
30+
const input = await commitValue('')
31+
expect(emitted('commit')).toBeUndefined()
32+
expect(input.value).toBe('40')
33+
})
34+
})

src/renderer/extensions/layerEditor/components/PropertyNumberField.vue

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ watch(
4646
)
4747
4848
function onChange(): void {
49-
const next = Number(draft.value)
49+
const raw = draft.value
50+
const next =
51+
typeof raw === 'string' && raw.trim() === '' ? Number.NaN : Number(raw)
5052
if (Number.isFinite(next)) {
5153
const low = min ?? -Infinity
5254
const high = max ?? Infinity
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { dropPositionFor, reorderDropIndex } from './layerPanelDnd'
4+
5+
describe('dropPositionFor', () => {
6+
it('splits the row at the midpoint', () => {
7+
expect(dropPositionFor(0.2)).toBe('above')
8+
expect(dropPositionFor(0.8)).toBe('below')
9+
expect(dropPositionFor(0.5)).toBe('below')
10+
})
11+
})
12+
13+
describe('reorderDropIndex', () => {
14+
const ids = ['a', 'b', 'c']
15+
16+
it('above a layer lands directly over it in z-order', () => {
17+
expect(reorderDropIndex(ids, 'b', 'above', 0)).toBe(2)
18+
})
19+
20+
it('below a layer lands directly under it', () => {
21+
expect(reorderDropIndex(ids, 'b', 'below', 0)).toBe(1)
22+
})
23+
24+
it('offsets past a background layer', () => {
25+
expect(reorderDropIndex(ids, 'a', 'below', 1)).toBe(1)
26+
expect(reorderDropIndex(ids, 'c', 'above', 1)).toBe(4)
27+
})
28+
29+
it('returns null for an unknown target', () => {
30+
expect(reorderDropIndex(ids, 'x', 'above', 0)).toBeNull()
31+
})
32+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export type LayerDropPos = 'above' | 'below'
2+
3+
export function dropPositionFor(ratio: number): LayerDropPos {
4+
return ratio < 0.5 ? 'above' : 'below'
5+
}
6+
7+
export function reorderDropIndex(
8+
bottomUpIds: readonly string[],
9+
targetId: string,
10+
pos: LayerDropPos,
11+
offset: number
12+
): number | null {
13+
const index = bottomUpIds.indexOf(targetId)
14+
if (index === -1) return null
15+
return offset + (pos === 'above' ? index + 1 : index)
16+
}

0 commit comments

Comments
 (0)