Skip to content

Commit ce2b236

Browse files
committed
fix: address the multi-agent review on the layer editor UX batch
1 parent 8dc3c2e commit ce2b236

8 files changed

Lines changed: 100 additions & 35 deletions

File tree

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

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
</span>
99
</div>
1010

11-
<div class="min-h-0 flex-1 overflow-y-auto">
11+
<div class="min-h-0 flex-1 overflow-y-auto" @dragleave="onListDragLeave">
1212
<div
1313
v-for="node in rows"
1414
:key="node.id"
@@ -177,7 +177,7 @@ function isRowSelected(id: string): boolean {
177177
178178
function rowClass(selected: boolean, extra?: string): string {
179179
return cn(
180-
'group flex cursor-pointer items-center gap-2 border-border-default px-2 py-1.5 select-none',
180+
'group relative flex cursor-pointer items-center gap-2 border-border-default px-2 py-1.5 select-none',
181181
'hover:bg-secondary-background-hover',
182182
selected && 'bg-secondary-background-selected',
183183
extra
@@ -208,20 +208,19 @@ function endDrag(): void {
208208
209209
function dropHintClass(id: string): string {
210210
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-
)
211+
if (hint?.id !== id) return ''
212+
return hint.pos === 'above'
213+
? "before:absolute before:inset-x-0 before:top-0 before:h-0.5 before:bg-base-foreground before:content-['']"
214+
: "after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:bg-base-foreground after:content-['']"
218215
}
219216
220217
function onRowDragStart(id: string, e: DragEvent): void {
221218
dragId.value = id
222219
if (e.dataTransfer) {
223220
e.dataTransfer.effectAllowed = 'move'
224-
e.dataTransfer.setData('text/plain', id)
221+
// Firefox refuses to start the drag without data; empty keeps text
222+
// inputs from receiving the layer id on a stray drop.
223+
e.dataTransfer.setData('text/plain', '')
225224
}
226225
}
227226
@@ -237,21 +236,28 @@ function onRowDragOver(id: string, e: DragEvent): void {
237236
}
238237
239238
function onRowDrop(id: string, e: DragEvent): void {
240-
e.preventDefault()
241239
const dragged = dragId.value
242240
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)
241+
if (!dragged || hint?.id !== id) {
242+
endDrag()
243+
return
251244
}
245+
e.preventDefault()
246+
const toIndex = reorderDropIndex(
247+
imageLayers.value.map((n) => n.id),
248+
id,
249+
hint.pos,
250+
backgroundLayer.value ? 1 : 0
251+
)
252+
if (toIndex !== null) session.moveLayerTo(dragged, toIndex)
252253
endDrag()
253254
}
254255
256+
function onListDragLeave(e: DragEvent): void {
257+
const container = e.currentTarget as HTMLElement
258+
if (!container.contains(e.relatedTarget as Node | null)) dropHint.value = null
259+
}
260+
255261
function onRowClick(id: string, e: MouseEvent): void {
256262
if (e.ctrlKey || e.metaKey || e.shiftKey) {
257263
const current = selectedNodeIds.value

src/renderer/extensions/layerEditor/composables/layerPanelDnd.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ export function dropPositionFor(ratio: number): LayerDropPos {
44
return ratio < 0.5 ? 'above' : 'below'
55
}
66

7+
/**
8+
* Target index in root.children for a panel drag-drop.
9+
* @param bottomUpIds ids ordered z=0 (bottom) first - the inverse of the
10+
* panel's display order, so visually "above" means a higher index.
11+
* @param offset reserved bottom slots (1 when a background fill is pinned).
12+
*/
713
export function reorderDropIndex(
814
bottomUpIds: readonly string[],
915
targetId: string,

src/renderer/extensions/layerEditor/engine/editor/pickOps.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,24 @@ describe('layerOpacityAt', () => {
7979
expect(layerOpacityAt(node, { x: 20, y: 100 }, content)).toBe(0)
8080
expect(layerOpacityAt(node, { x: 200, y: 100 }, content)).toBe(1)
8181
})
82+
it('scales a group pick by the group opacity', () => {
83+
const child = mkRaster('a', 0, 0, 100, 100)
84+
const content = fakeContent({ a: fakeCanvas(10, 10, () => 1) })
85+
const group = {
86+
id: 'g',
87+
kind: 'group',
88+
name: 'g',
89+
visible: true,
90+
opacity: 0.2,
91+
mode: {},
92+
transform: { x: 0, y: 0, w: 100, h: 100, rotation: 0 },
93+
locks: { content: false, position: false, visibility: false },
94+
children: [child],
95+
passThrough: false
96+
} as unknown as SceneNode
97+
expect(layerOpacityAt(group, { x: 50, y: 50 }, content)).toBeCloseTo(0.2)
98+
expect(pickLayerAt([group], { x: 50, y: 50 }, content)).toBeNull()
99+
})
82100
it('returns 0 outside the transform and for invisible layers', () => {
83101
const node = mkRaster('a', 0, 0, 100, 100)
84102
const content = fakeContent({ a: fakeCanvas(10, 10, () => 1) })

src/renderer/extensions/layerEditor/engine/editor/pickOps.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,16 @@ export type AlphaSampler = (
1010
y: number
1111
) => number
1212

13-
const sampleCache = new WeakMap<
14-
HTMLCanvasElement,
15-
CanvasRenderingContext2D | null
16-
>()
17-
1813
function defaultAlphaSampler(
1914
canvas: HTMLCanvasElement,
2015
x: number,
2116
y: number
2217
): number {
23-
let ctx = sampleCache.get(canvas)
24-
if (ctx === undefined) {
25-
try {
26-
ctx = canvas.getContext('2d', { willReadFrequently: true })
27-
} catch {
28-
ctx = null
29-
}
30-
sampleCache.set(canvas, ctx)
18+
let ctx: CanvasRenderingContext2D | null
19+
try {
20+
ctx = canvas.getContext('2d', { willReadFrequently: true })
21+
} catch {
22+
ctx = null
3123
}
3224
if (!ctx) return 1
3325
try {
@@ -78,7 +70,7 @@ export function layerOpacityAt(
7870
best = Math.max(best, layerOpacityAt(child, pt, content, sample))
7971
if (best >= 1) break
8072
}
81-
return best
73+
return best * node.opacity
8274
}
8375
case 'raster':
8476
return rasterAlphaAt(node as RasterData, pt, content, sample)

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,18 @@ describe('History — merging', () => {
173173
expect(h.dirty()).toBe(false)
174174
})
175175

176+
it('redo after undoing a post-save merge reads dirty again', () => {
177+
const h = new History()
178+
h.push(new MergingCommand('opacity'))
179+
h.push(new MergingCommand('opacity'))
180+
h.markSaved()
181+
h.push(new MergingCommand('opacity'))
182+
h.undo()
183+
expect(h.dirty()).toBe(false)
184+
h.redo()
185+
expect(h.dirty()).toBe(true)
186+
})
187+
176188
it('never merges across a save point, so the new edit stays undoable to clean', () => {
177189
const h = new History()
178190
const first = new MergingCommand('opacity')

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export class History {
105105
) {
106106
this.undoBytes += top.sizeBytes() - (this.sizes.get(top) ?? 0)
107107
this.sizes.set(top, top.sizeBytes())
108+
// Merged edits share the first commit's dirty step - no bumpDirty() here.
108109
this.emit(cmd.dirtyMask)
109110
return
110111
}
@@ -194,6 +195,7 @@ export class History {
194195
) {
195196
const dropped = this.undoStack.shift()
196197
if (!dropped) break
198+
if (dropped === this.mergeBarrier) this.mergeBarrier = null
197199
this.undoBytes -= this.sizes.get(dropped) ?? 0
198200
this.cleanReachable = false
199201
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,24 @@ describe('renderDocument', () => {
191191
expect([...c.freed].sort()).toEqual(c.allocated.map((h) => h.id).sort())
192192
})
193193

194+
it('frees nested targets when a later allocTarget throws', () => {
195+
class RationedCompositor extends FakeCompositor {
196+
allocs = 0
197+
override allocTarget(width: number, height: number): FBOHandle {
198+
if (++this.allocs === 2) throw new Error('out of targets')
199+
return super.allocTarget(width, height)
200+
}
201+
}
202+
const c = new RationedCompositor()
203+
const inner = group([leaf(1)], { id: 'inner' })
204+
const outer = group([inner], { id: 'outer' })
205+
expect(() => renderDocument(doc([outer]), deps(c))).toThrow(
206+
'out of targets'
207+
)
208+
expect(c.allocated).toHaveLength(1)
209+
expect(c.freed).toEqual([c.allocated[0].id])
210+
})
211+
194212
it('splices a pass-through group directly into the parent stack (no isolation target)', () => {
195213
const c = new FakeCompositor()
196214
const g = group([leaf(1), leaf(1)], { passThrough: true })

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { Compositor, CompositeInput, NodeTexture } from '../compositor'
1+
import type {
2+
Compositor,
3+
CompositeInput,
4+
FBOHandle,
5+
NodeTexture
6+
} from '../compositor'
27
import type { ContentStore } from '../content'
38
import type { Document } from '../document'
49
import { resolveMode } from '../mode'
@@ -239,7 +244,13 @@ function buildInputs(
239244
cleanups.push(sub.cleanup)
240245
continue
241246
}
242-
const handle = deps.compositor.allocTarget(doc.width, doc.height)
247+
let handle: FBOHandle
248+
try {
249+
handle = deps.compositor.allocTarget(doc.width, doc.height)
250+
} catch (err) {
251+
sub.cleanup()
252+
throw err
253+
}
243254
try {
244255
deps.compositor.composite(sub.inputs, handle)
245256
} catch (err) {

0 commit comments

Comments
 (0)