openpencil/tests/engine/renderer-effects.test.ts
Danila Poyarkov 8ab7148294
Fix effects rendering: shadows, blur, spread (#24)
* Fix shadow rendering, implement blur effects, add spread support

- Fix drop shadow rendering order: draw shadows before fills so they
  appear behind opaque shapes instead of on top (#23.1)
- Implement shadow spread: expand/contract shadow shape by spread value
  for both drop shadow and inner shadow (#23.2)
- Implement background blur: clip to node shape and apply blur filter
  to content behind the node (#23.3)
- Fix text shadows: use saveLayer with ColorFilter.MakeBlend to apply
  shadows to text glyphs instead of the bounding box (#23.4)
- Implement layer blur with actual saveLayer + blur filter instead of
  the no-op comment (#23.3)
- Implement foreground blur: clip to node shape and blur content in
  front (#23.7)
- Add inner shadow spread support with rounded rect handling
- Add makeRRectWithSpread and makeRRectWithOffset helpers

Closes #23

* Add Effects section to demo showcasing shadow and blur fixes

Drop shadows (subtle, medium, heavy, with spread), inner shadows
(inset, with spread, on ellipse, combined), text shadows on glyphs,
layer blur, and glassmorphism background blur card.

* Fix drop shadow, layer blur, and background blur rendering

Drop shadow: use MakeDropShadowOnly to produce shadow-only output
that doesn't bleed through opaque fills. The sigma is radius/2 to
match Figma's blur convention.

Layer blur: wrap entire node content in a saveLayer with blur filter
at the renderNode level instead of trying to blur after-the-fact
in renderEffects.

Background/foreground blur: extract clipNodeShape helper to reduce
duplication in blur effect rendering.

Update tests to match new rendering approach.

* Add visual regression tests for effects rendering

9 snapshot tests covering drop shadow, inner shadow, spread,
ellipse shapes, text glyphs, layer blur, and combined effects.

* Make Effects demo section background white

* Clean up renderer code and tests

- Extract applyClippedBlur to deduplicate background/foreground blur
- Remove comments that repeat what code does
- Format with oxfmt

* Add undo/redo support for effect property changes

ScrubInput controls (offset, radius, spread, opacity) now use
scrubEffect for live preview and commitEffect on release to create
a single undo entry per interaction.
2026-03-03 09:53:39 +03:00

121 lines
4.7 KiB
TypeScript

import { describe, test, expect } from 'bun:test'
const rendererSource = await Bun.file(
new URL('../../packages/core/src/renderer.ts', import.meta.url)
).text()
describe('Renderer effect ordering', () => {
test('drop shadow renders before fills', () => {
// In renderShape, "Effects (behind: drop shadow)" must come before "Fills"
const behindIdx = rendererSource.indexOf("renderEffects(canvas, node, rect, hasRadius, 'behind')")
const fillsIdx = rendererSource.indexOf('this.drawNodeFill(canvas, node, rect, hasRadius)')
expect(behindIdx).toBeGreaterThan(-1)
expect(fillsIdx).toBeGreaterThan(-1)
expect(behindIdx).toBeLessThan(fillsIdx)
})
test('inner shadow and blur render after strokes', () => {
const strokeIdx = rendererSource.indexOf('this.drawNodeStroke(canvas, node, rect, hasRadius)')
const frontIdx = rendererSource.indexOf("renderEffects(canvas, node, rect, hasRadius, 'front')")
expect(strokeIdx).toBeGreaterThan(-1)
expect(frontIdx).toBeGreaterThan(-1)
expect(strokeIdx).toBeLessThan(frontIdx)
})
})
describe('Renderer handles all effect types', () => {
test('handles DROP_SHADOW', () => {
expect(rendererSource).toContain("effect.type === 'DROP_SHADOW'")
})
test('handles INNER_SHADOW', () => {
expect(rendererSource).toContain("effect.type === 'INNER_SHADOW'")
})
test('handles LAYER_BLUR', () => {
expect(rendererSource).toContain("e.type === 'LAYER_BLUR'")
})
test('handles BACKGROUND_BLUR', () => {
expect(rendererSource).toContain("effect.type === 'BACKGROUND_BLUR'")
})
test('handles FOREGROUND_BLUR', () => {
expect(rendererSource).toContain("effect.type === 'FOREGROUND_BLUR'")
})
})
describe('Shadow spread support', () => {
test('drop shadow uses spread for shape expansion', () => {
const dropShadowSection = rendererSource.slice(
rendererSource.indexOf("effect.type === 'DROP_SHADOW'"),
rendererSource.indexOf("effect.type === 'DROP_SHADOW'") + 2000
)
expect(dropShadowSection).toContain('effect.spread')
expect(dropShadowSection).toContain('makeRRectWithSpread')
expect(dropShadowSection).toContain('MakeDropShadowOnly')
})
test('inner shadow uses spread for cutout contraction', () => {
const innerShadowSection = rendererSource.slice(
rendererSource.indexOf("effect.type === 'INNER_SHADOW'"),
rendererSource.indexOf("effect.type === 'INNER_SHADOW'") + 3000
)
expect(innerShadowSection).toContain('effect.spread')
})
test('makeRRectWithSpread helper exists', () => {
expect(rendererSource).toContain('private makeRRectWithSpread')
})
test('makeRRectWithOffset helper exists', () => {
expect(rendererSource).toContain('private makeRRectWithOffset')
})
})
describe('Text shadow renders on glyphs, not bounding box', () => {
test('drop shadow has TEXT-specific branch', () => {
const dropShadowBlock = rendererSource.slice(
rendererSource.indexOf("effect.type === 'DROP_SHADOW'"),
rendererSource.indexOf("effect.type === 'DROP_SHADOW'") + 2000
)
expect(dropShadowBlock).toContain("node.type === 'TEXT'")
expect(dropShadowBlock).toContain('renderText')
expect(dropShadowBlock).toContain('MakeDropShadowOnly')
})
test('inner shadow has TEXT-specific branch', () => {
const innerShadowBlock = rendererSource.slice(
rendererSource.indexOf("effect.type === 'INNER_SHADOW'"),
rendererSource.indexOf("effect.type === 'INNER_SHADOW'") + 1500
)
expect(innerShadowBlock).toContain("node.type === 'TEXT'")
expect(innerShadowBlock).toContain('renderText')
})
})
describe('Blur effects use saveLayer pattern', () => {
test('layer blur wraps node content in a blurred saveLayer', () => {
expect(rendererSource).toContain("e.type === 'LAYER_BLUR'")
// Applied at the renderNode level, not in renderEffects
const layerBlurIdx = rendererSource.indexOf("e.type === 'LAYER_BLUR'")
const layerBlurSection = rendererSource.slice(layerBlurIdx - 200, layerBlurIdx + 300)
expect(layerBlurSection).toContain('MakeBlur')
expect(layerBlurSection).toContain('saveLayer')
})
test('background and foreground blur use applyClippedBlur', () => {
expect(rendererSource).toContain("effect.type === 'BACKGROUND_BLUR'")
expect(rendererSource).toContain("effect.type === 'FOREGROUND_BLUR'")
expect(rendererSource).toContain('applyClippedBlur')
})
test('applyClippedBlur clips to node shape and uses saveLayer', () => {
const idx = rendererSource.indexOf('private applyClippedBlur')
const section = rendererSource.slice(idx, idx + 500)
expect(section).toContain('clipNodeShape')
expect(section).toContain('MakeBlur')
expect(section).toContain('saveLayer')
})
})