diff --git a/src/display/components/Background.js b/src/display/components/Background.js index 63264ba6..19944d04 100644 --- a/src/display/components/Background.js +++ b/src/display/components/Background.js @@ -17,6 +17,8 @@ const ComposedBackground = mixins( ); export class Background extends ComposedBackground { + static respectsPadding = false; + constructor(context) { super({ type: 'background', context, texture: Texture.WHITE }); } diff --git a/src/display/data-schema/element-schema.js b/src/display/data-schema/element-schema.js index ce726cc6..aa357cf6 100644 --- a/src/display/data-schema/element-schema.js +++ b/src/display/data-schema/element-schema.js @@ -1,6 +1,6 @@ import { z } from 'zod'; import { componentArraySchema } from './component-schema'; -import { Base, Gap, RelationsStyle, Size } from './primitive-schema'; +import { Base, Gap, Margin, RelationsStyle, Size } from './primitive-schema'; /** * Groups multiple elements to apply common properties.. @@ -22,7 +22,11 @@ export const gridSchema = Base.extend({ type: z.literal('grid'), cells: z.array(z.array(z.union([z.literal(0), z.literal(1)]))), gap: Gap, - item: z.object({ components: componentArraySchema, size: Size }), + item: z.object({ + components: componentArraySchema, + size: Size, + padding: Margin.default(0), + }), }).strict(); /** @@ -35,6 +39,7 @@ export const itemSchema = Base.extend({ type: z.literal('item'), components: componentArraySchema, size: Size, + padding: Margin.default(0), }).strict(); /** diff --git a/src/display/data-schema/element-schema.test.js b/src/display/data-schema/element-schema.test.js index caf19adc..af13f2b9 100644 --- a/src/display/data-schema/element-schema.test.js +++ b/src/display/data-schema/element-schema.test.js @@ -81,6 +81,7 @@ describe('Element Schemas', () => { expect(parsed.gap).toEqual({ x: 0, y: 0 }); expect(parsed.item).toEqual({ size: { width: 50, height: 50 }, + padding: { bottom: 0, left: 0, right: 0, top: 0 }, components: [], }); }); diff --git a/src/display/data-schema/primitive-schema.js b/src/display/data-schema/primitive-schema.js index cf1b971b..6d4958d6 100644 --- a/src/display/data-schema/primitive-schema.js +++ b/src/display/data-schema/primitive-schema.js @@ -1,5 +1,6 @@ import { z } from 'zod'; import { uid } from '../../utils/uuid'; +import { ZERO_MARGIN } from '../mixins/constants'; import { Color, HslColor, @@ -168,7 +169,7 @@ export const Margin = z.preprocess( bottom: z.number().default(0), left: z.number().default(0), }) - .default({}), + .default(ZERO_MARGIN), ); export const TextureStyle = z diff --git a/src/display/mixins/Itemsizeable.js b/src/display/mixins/Itemsizeable.js index 2c3e369b..5bfe6eb8 100644 --- a/src/display/mixins/Itemsizeable.js +++ b/src/display/mixins/Itemsizeable.js @@ -1,6 +1,6 @@ import { UPDATE_STAGES } from './constants'; -const KEYS = ['size']; +const KEYS = ['size', 'padding']; export const ItemSizeable = (superClass) => { const MixedClass = class extends superClass { diff --git a/src/display/mixins/Placementable.js b/src/display/mixins/Placementable.js index d14f4952..a5f6e86c 100644 --- a/src/display/mixins/Placementable.js +++ b/src/display/mixins/Placementable.js @@ -1,4 +1,5 @@ import { UPDATE_STAGES } from './constants'; +import { getLayoutContext } from './utils'; const KEYS = ['placement', 'margin']; @@ -34,31 +35,36 @@ export const Placementable = (superClass) => { }; const getHorizontalPosition = (component, align, margin) => { - const parentWidth = component.parent.props.size.width; + const { parentWidth, contentWidth, parentPadding } = + getLayoutContext(component); + let result = null; if (align === 'left') { - result = margin.left; + result = parentPadding.left + margin.left; } else if (align === 'right') { - result = parentWidth - component.width - margin.right; + result = parentWidth - component.width - margin.right - parentPadding.right; } else if (align === 'center') { const marginWidth = component.width + margin.left + margin.right; - const blockStartPosition = (parentWidth - marginWidth) / 2; - result = blockStartPosition + margin.left; + const blockStartPosition = (contentWidth - marginWidth) / 2; + result = parentPadding.left + blockStartPosition + margin.left; } return result; }; const getVerticalPosition = (component, align, margin) => { - const parentHeight = component.parent.props.size.height; + const { parentHeight, contentHeight, parentPadding } = + getLayoutContext(component); + let result = null; if (align === 'top') { - result = margin.top; + result = parentPadding.top + margin.top; } else if (align === 'bottom') { - result = parentHeight - component.height - margin.bottom; + result = + parentHeight - component.height - margin.bottom - parentPadding.bottom; } else if (align === 'center') { const marginHeight = component.height + margin.top + margin.bottom; - const blockStartPosition = (parentHeight - marginHeight) / 2; - result = blockStartPosition + margin.top; + const blockStartPosition = (contentHeight - marginHeight) / 2; + result = parentPadding.top + blockStartPosition + margin.top; } return result; }; diff --git a/src/display/mixins/constants.js b/src/display/mixins/constants.js index 6d387e50..93eba2e7 100644 --- a/src/display/mixins/constants.js +++ b/src/display/mixins/constants.js @@ -27,3 +27,10 @@ export const FONT_WEIGHT = { extrabold: 'extrabold', black: 'black', }; + +export const ZERO_MARGIN = Object.freeze({ + top: 0, + right: 0, + bottom: 0, + left: 0, +}); diff --git a/src/display/mixins/utils.js b/src/display/mixins/utils.js index df6ab2aa..1f1ea903 100644 --- a/src/display/mixins/utils.js +++ b/src/display/mixins/utils.js @@ -2,6 +2,7 @@ import gsap from 'gsap'; import { isValidationError } from 'zod-validation-error'; import { findIndexByPriority } from '../../utils/findIndexByPriority'; import { validate } from '../../utils/validator'; +import { ZERO_MARGIN } from './constants'; export const tweensOf = (object) => gsap.getTweensOf(object); @@ -27,8 +28,8 @@ const parseCalcExpression = (expression, parentDimension) => { }; export const calcSize = (component, { source, size }) => { - const { width: parentWidth, height: parentHeight } = - component.parent.props.size; + const { contentWidth, contentHeight } = getLayoutContext(component); + const borderWidth = typeof source === 'object' ? (source?.borderWidth ?? 0) : 0; @@ -36,20 +37,20 @@ export const calcSize = (component, { source, size }) => { let finalHeight = null; if (typeof size.width === 'string' && size.width.startsWith('calc')) { - finalWidth = parseCalcExpression(size.width, parentWidth); + finalWidth = parseCalcExpression(size.width, contentWidth); } else { finalWidth = size.width.unit === '%' - ? parentWidth * (size.width.value / 100) + ? contentWidth * (size.width.value / 100) : size.width.value; } if (typeof size.height === 'string' && size.height.startsWith('calc')) { - finalHeight = parseCalcExpression(size.height, parentHeight); + finalHeight = parseCalcExpression(size.height, contentHeight); } else { finalHeight = size.height.unit === '%' - ? parentHeight * (size.height.value / 100) + ? contentHeight * (size.height.value / 100) : size.height.value; } @@ -106,3 +107,45 @@ export const validateAndPrepareChanges = (currentElements, changes, schema) => { return preparedChanges; }; + +/** + * Calculates the layout context of a component (content area size, padding, etc). + * @param {PIXI.DisplayObject} component - The component for which to calculate the layout context + * @returns {{parentWidth: number, parentHeight: number, contentWidth: number, contentHeight: number, parentPadding: object}} + */ +export const getLayoutContext = (component) => { + const parent = component?.parent; + if (!parent) { + return { + parentWidth: 0, + parentHeight: 0, + contentWidth: 0, + contentHeight: 0, + parentPadding: ZERO_MARGIN, + }; + } + + const usePadding = component.constructor.respectsPadding !== false; + const parentPadding = + usePadding && parent.props.padding ? parent.props.padding : ZERO_MARGIN; + + const parentWidth = parent.props.size.width; + const parentHeight = parent.props.size.height; + + const contentWidth = Math.max( + 0, + parentWidth - parentPadding.left - parentPadding.right, + ); + const contentHeight = Math.max( + 0, + parentHeight - parentPadding.top - parentPadding.bottom, + ); + + return { + parentWidth, + parentHeight, + contentWidth, + contentHeight, + parentPadding, + }; +}; diff --git a/src/tests/render/components/Bar.test.js b/src/tests/render/components/Bar.test.js new file mode 100644 index 00000000..b411a129 --- /dev/null +++ b/src/tests/render/components/Bar.test.js @@ -0,0 +1,334 @@ +import gsap from 'gsap'; +import { describe, expect, it } from 'vitest'; +import { setupPatchmapTests } from '../patchmap.setup'; + +describe('Bar Component Tests', () => { + const { getPatchmap } = setupPatchmapTests(); + + const itemWithBar = { + type: 'item', + id: 'item-with-bar', + size: { width: 200, height: 100 }, + components: [ + { + type: 'bar', + id: 'bar-1', + source: { type: 'rect', fill: 'blue' }, + size: { width: '50%', height: 20 }, + }, + ], + }; + + it('should render a bar with minimal required properties and correct default values', async () => { + const patchmap = getPatchmap(); + patchmap.draw([itemWithBar]); + + const bar = patchmap.selector('$..[?(@.id=="bar-1")]')[0]; + expect(bar).toBeDefined(); + + expect(bar.props.placement).toBe('bottom'); + expect(bar.props.margin).toEqual({ top: 0, right: 0, bottom: 0, left: 0 }); + expect(bar.props.animation).toBe(true); + expect(bar.props.animationDuration).toBe(200); + + expect(bar.width).toBe(1); + expect(bar.height).toBe(1); + gsap.exportRoot().totalProgress(1); + expect(bar.width).toBe(100); + expect(bar.height).toBe(20); + expect(bar.x).toBe(50); + expect(bar.y).toBe(80); + }); + + it("should update the bar's appearance when source property is changed", () => { + const patchmap = getPatchmap(); + patchmap.draw([itemWithBar]); + gsap.exportRoot().totalProgress(1); + + const bar = patchmap.selector('$..[?(@.id=="bar-1")]')[0]; + expect(bar.props.source.fill).toBe('blue'); + + patchmap.update({ + path: '$..[?(@.id=="bar-1")]', + changes: { source: { type: 'rect', fill: 'red' } }, + }); + expect(bar.props.source.fill).toBe('red'); + }); + + describe('when updating size', () => { + const testCases = [ + { + description: 'percentage width and fixed height', + size: { width: '25%', height: 40 }, + expected: { width: 50, height: 40 }, + }, + { + description: 'fixed width and percentage height', + size: { width: 60, height: '10%' }, + expected: { width: 60, height: 10 }, + }, + { + description: 'percentage for both width and height', + size: { width: '100%', height: '50%' }, + expected: { width: 200, height: 50 }, + }, + { + description: 'fixed values for both width and height', + size: { width: 120, height: 30 }, + expected: { width: 120, height: 30 }, + }, + { + description: 'zero size', + size: { width: 0, height: 0 }, + expected: { width: 0, height: 0 }, + }, + { + description: 'size overflowing parent with fixed values', + size: { width: 300, height: 150 }, + expected: { width: 300, height: 150 }, + }, + { + description: 'size overflowing parent with percentage values', + size: { width: '200%', height: '110%' }, + expected: { width: 400, height: 110 }, + }, + ]; + + it.each(testCases)( + 'should correctly update to $description', + ({ size, expected }) => { + const patchmap = getPatchmap(); + patchmap.draw([itemWithBar]); + gsap.exportRoot().totalProgress(1); + + const bar = patchmap.selector('$..[?(@.id=="bar-1")]')[0]; + + patchmap.update({ + path: '$..[?(@.id=="bar-1")]', + changes: { size }, + }); + gsap.exportRoot().totalProgress(1); + + expect(bar.width).toBe(expected.width); + expect(bar.height).toBe(expected.height); + }, + ); + }); + + describe('when parent item has padding', () => { + const itemWithPaddedBar = { + type: 'item', + id: 'padded-item', + size: { width: 200, height: 100 }, + padding: 20, + components: [ + { + type: 'bar', + id: 'bar-in-padded', + source: { type: 'rect', fill: 'green' }, + size: { width: '50%', height: '100%' }, + }, + ], + }; + + it('should calculate size based on parent content area', () => { + const patchmap = getPatchmap(); + patchmap.draw([itemWithPaddedBar]); + gsap.exportRoot().totalProgress(1); + + const bar = patchmap.selector('$..[?(@.id=="bar-in-padded")]')[0]; + const contentWidth = 200 - 20 * 2; // 160 + const contentHeight = 100 - 20 * 2; // 60 + expect(bar.width).toBe(contentWidth * 0.5); // 80 + expect(bar.height).toBe(contentHeight * 1); // 60 + }); + }); + + describe('when toggling animation property', () => { + it('should apply size changes immediately if animation is false from the start', () => { + const patchmap = getPatchmap(); + const itemWithNonAnimatedBar = { + ...itemWithBar, + components: [{ ...itemWithBar.components[0], animation: false }], + }; + + patchmap.draw([itemWithNonAnimatedBar]); + const bar = patchmap.selector('$..[?(@.id=="bar-1")]')[0]; + expect(bar.width).toBe(100); + expect(bar.height).toBe(20); + }); + + it('should kill the in-progress animation and jump to the final state when animation is set to false', () => { + const patchmap = getPatchmap(); + patchmap.draw([itemWithBar]); + gsap.exportRoot().totalProgress(1); + const bar = patchmap.selector('$..[?(@.id=="bar-1")]')[0]; + + patchmap.update({ + path: '$..[?(@.id=="bar-1")]', + changes: { size: { width: 200, height: 50 } }, + }); + patchmap.update({ + path: '$..[?(@.id=="bar-1")]', + changes: { animation: false }, + }); + expect(bar.width).toBe(200); + expect(bar.height).toBe(50); + }); + }); + + describe('when combining various layout properties', () => { + const layoutTestCases = [ + { + description: 'basic center placement with no padding or margin', + itemSize: { width: 200, height: 100 }, + itemPadding: 0, + barSize: { width: 50, height: 20 }, + barPlacement: 'center', + barMargin: 0, + expected: { x: 75, y: 40, width: 50, height: 20 }, + }, + { + description: 'top-left placement with uniform padding', + itemSize: { width: 200, height: 100 }, + itemPadding: 10, + barSize: { width: 50, height: 20 }, + barPlacement: 'left-top', + barMargin: 0, + expected: { x: 10, y: 10, width: 50, height: 20 }, + }, + { + description: 'bottom-right with uniform padding and margin', + itemSize: { width: 200, height: 100 }, + itemPadding: 10, + barSize: { width: 50, height: 20 }, + barPlacement: 'right-bottom', + barMargin: 5, + expected: { x: 135, y: 65, width: 50, height: 20 }, + }, + { + description: 'center with non-uniform padding and margin', + itemSize: { width: 200, height: 100 }, + itemPadding: { top: 10, right: 20, bottom: 30, left: 40 }, + barSize: { width: 50, height: 20 }, + barPlacement: 'center', + barMargin: { top: 1, right: 2, bottom: 3, left: 4 }, + expected: { x: 86, y: 29, width: 50, height: 20 }, + }, + { + description: 'percentage size with padding and margin', + itemSize: { width: 200, height: 100 }, + itemPadding: 20, + barSize: { width: '50%', height: '25%' }, + barPlacement: 'right-top', + barMargin: 5, + expected: { x: 95, y: 25, width: 80, height: 15 }, + }, + { + description: 'full-size bar with padding and margin', + itemSize: { width: 200, height: 100 }, + itemPadding: 10, + barSize: { width: '100%', height: '100%' }, + barPlacement: 'left-top', + barMargin: 5, + expected: { x: 15, y: 15, width: 180, height: 80 }, + }, + { + description: + 'single axis placement (top) should be horizontally centered', + itemSize: { width: 200, height: 100 }, + itemPadding: 0, + barSize: { width: 50, height: 20 }, + barPlacement: 'top', + barMargin: 10, + expected: { x: 75, y: 10, width: 50, height: 20 }, + }, + { + description: + 'single axis placement (left) should be vertically centered', + itemSize: { width: 200, height: 100 }, + itemPadding: 0, + barSize: { width: 50, height: 20 }, + barPlacement: 'left', + barMargin: 10, + expected: { x: 10, y: 40, width: 50, height: 20 }, + }, + { + description: 'edge case: bar larger than content area', + itemSize: { width: 100, height: 100 }, + itemPadding: 10, + barSize: { width: 100, height: 100 }, + barPlacement: 'left-top', + barMargin: 0, + expected: { x: 10, y: 10, width: 100, height: 100 }, + }, + { + description: 'edge case: padding larger than item size', + itemSize: { width: 100, height: 100 }, + itemPadding: 60, + barSize: { width: '100%', height: '100%' }, + barPlacement: 'center', + barMargin: 0, + expected: { x: 60, y: 60, width: 0, height: 0 }, + }, + { + description: 'edge case: zero size item', + itemSize: { width: 0, height: 0 }, + itemPadding: 0, + barSize: { width: 10, height: 10 }, + barPlacement: 'center', + barMargin: 0, + expected: { x: -5, y: -5, width: 10, height: 10 }, + }, + + { + description: 'edge case: negative margin should shift element outside', + itemSize: { width: 200, height: 100 }, + itemPadding: 10, + barSize: { width: 50, height: 20 }, + barPlacement: 'left-top', + barMargin: -5, + expected: { x: 5, y: 5, width: 50, height: 20 }, + }, + ]; + + it.each(layoutTestCases)( + '$description', + ({ + itemSize, + itemPadding, + barSize, + barPlacement, + barMargin, + expected, + }) => { + const patchmap = getPatchmap(); + const testItem = { + type: 'item', + id: 'test-item', + size: itemSize, + padding: itemPadding, + components: [ + { + type: 'bar', + id: 'test-bar', + source: { type: 'rect', fill: 'magenta' }, + size: barSize, + placement: barPlacement, + margin: barMargin, + }, + ], + }; + + patchmap.draw([testItem]); + gsap.exportRoot().totalProgress(1); + + const bar = patchmap.selector('$..[?(@.id=="test-bar")]')[0]; + expect(bar.width).toBeCloseTo(expected.width); + expect(bar.height).toBeCloseTo(expected.height); + expect(bar.x).toBeCloseTo(expected.x); + expect(bar.y).toBeCloseTo(expected.y); + }, + ); + }); +}); diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index eccdcecf..587a804c 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { setupPatchmapTests } from './patchmap.setup'; const sampleData = [ @@ -224,13 +224,17 @@ describe('patchmap test', () => { let onOver; let onDragSelect; - beforeEach(async () => { + beforeEach(() => { + vi.useFakeTimers(); patchmap = getPatchmap(); patchmap.draw(sampleData); onSelect = vi.fn(); onOver = vi.fn(); onDragSelect = vi.fn(); - await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + afterEach(() => { + vi.useRealTimers(); }); describe('when draggable is false', () => { @@ -331,7 +335,7 @@ describe('patchmap test', () => { async ({ position, expectedId }) => { const viewport = patchmap.viewport; transform(viewport); - await new Promise((resolve) => setTimeout(resolve, 50)); + await vi.advanceTimersByTimeAsync(100); viewport.emit('mousedown', { global: viewport.toGlobal(position), @@ -402,7 +406,7 @@ describe('patchmap test', () => { patchmap.draw([ { type: 'group', id: 'group-2', children: sampleData }, ]); - await new Promise((resolve) => setTimeout(resolve, 50)); + await vi.advanceTimersByTimeAsync(100); const onSelect = vi.fn(); diff --git a/src/utils/convert.js b/src/utils/convert.js index 5ee0890c..fcd07f39 100644 --- a/src/utils/convert.js +++ b/src/utils/convert.js @@ -30,6 +30,7 @@ export const convertLegacyData = (data) => { ), gap: 4, item: { + padding: 3, size: { width: props.spec.width * 40, height: props.spec.height * 40, @@ -48,10 +49,9 @@ export const convertLegacyData = (data) => { { type: 'bar', show: false, - size: 'calc(100% - 6px)', + size: '100%', source: { type: 'rect', radius: 3, fill: 'white' }, tint: 'primary.default', - margin: { bottom: 3 }, }, ], },