From 2daa9a761b64f58b644808547a8036e72cd85b70 Mon Sep 17 00:00:00 2001 From: Federico Ruggi <1081051+ruggi@users.noreply.github.com> Date: Tue, 12 Nov 2024 17:01:28 +0100 Subject: [PATCH] Parse grid spans, use `csstree` for placement parsing (#6629) **Problem:** 1. Parsing an element placing does not use `csstree` 2. Spans are not parsed nor represented This is a purely-parsing PR, laying the groundwork to have strategies and interactions play nicely with spanning items. **Fix:** 1. Use `csstree` to parse grid placement 2. Parse `span` coming form parsing props Fixes #6628 --- .../strategies/grid-helpers.ts | 50 ++++-- .../canvas/controls/grid-controls.tsx | 65 ++++---- editor/src/components/canvas/dom-walker.ts | 89 +++++----- .../store/store-deep-equality-instances.ts | 63 +++++++- .../inspector/common/css-utils.spec.ts | 152 +++++++++++++++++- .../components/inspector/common/css-utils.ts | 112 +++++++++++-- .../src/components/inspector/flex-section.tsx | 9 +- .../grid-cell-subsection.tsx | 48 +++++- editor/src/core/shared/element-template.ts | 61 ++++++- 9 files changed, 518 insertions(+), 131 deletions(-) diff --git a/editor/src/components/canvas/canvas-strategies/strategies/grid-helpers.ts b/editor/src/components/canvas/canvas-strategies/strategies/grid-helpers.ts index fbfef128c77e..23a1bd1e8794 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/grid-helpers.ts +++ b/editor/src/components/canvas/canvas-strategies/strategies/grid-helpers.ts @@ -5,14 +5,16 @@ import * as EP from '../../../../core/shared/element-path' import type { ElementInstanceMetadataMap, GridAutoOrTemplateBase, + GridPositionOrSpan, GridPositionValue, + GridSpan, SpecialSizeMeasurements, } from '../../../../core/shared/element-template' import { + isGridSpan, type ElementInstanceMetadata, type GridContainerProperties, type GridElementProperties, - type GridPosition, } from '../../../../core/shared/element-template' import type { CanvasRectangle } from '../../../../core/shared/math-utils' import * as PP from '../../../../core/shared/property-path' @@ -42,10 +44,25 @@ import { gridCellCoordinates, } from './grid-cell-bounds' -export function gridPositionToValue(p: GridPosition | null | undefined): string | number | null { +export function gridPositionToValue( + p: GridPositionOrSpan | null | undefined, + spanOffset: GridPositionOrSpan | null, +): string | number | null { if (p == null) { return null } + + const offset = isGridSpan(spanOffset) && spanOffset.type === 'SPAN_NUMERIC' ? spanOffset.value : 0 + + if (isGridSpan(p)) { + switch (p.type) { + case 'SPAN_AREA': + return null // # TODO fill this in once we support grid areas + case 'SPAN_NUMERIC': + return p.value + offset + } + } + if (isCSSKeyword(p)) { return p.value } @@ -69,10 +86,10 @@ export function setGridPropsCommands( PP.create('style', 'gridRowEnd'), ]), ] - const columnStart = gridPositionToValue(gridProps.gridColumnStart) - const columnEnd = gridPositionToValue(gridProps.gridColumnEnd) - const rowStart = gridPositionToValue(gridProps.gridRowStart) - const rowEnd = gridPositionToValue(gridProps.gridRowEnd) + const columnStart = gridPositionToValue(gridProps.gridColumnStart, null) + const columnEnd = gridPositionToValue(gridProps.gridColumnEnd, gridProps.gridColumnStart ?? null) + const rowStart = gridPositionToValue(gridProps.gridRowStart, null) + const rowEnd = gridPositionToValue(gridProps.gridRowEnd, gridProps.gridRowStart ?? null) const lineColumnStart = asMaybeNamedLineOrValue(gridTemplate, 'column', columnStart) const lineColumnEnd = asMaybeNamedLineOrValue(gridTemplate, 'column', columnEnd) @@ -149,7 +166,7 @@ function getGridChildCellCoordBoundsFromProps( // get the grid fixtures (start and end for column and row) from the element metadata function getGridProperty(field: keyof GridElementProperties, innerFallback: number) { const propValue = element.specialSizeMeasurements.elementGridProperties[field] - if (propValue == null || isCSSKeyword(propValue)) { + if (propValue == null || isCSSKeyword(propValue) || isGridSpan(propValue)) { return innerFallback } return propValue.numericalPosition ?? innerFallback @@ -215,8 +232,19 @@ export function sortElementsByGridPosition(gridTemplateColumns: number) { return index } - const row = e.gridRowStart.numericalPosition ?? 1 - const column = e.gridColumnStart.numericalPosition ?? 1 + function maybeNumericalValue(dim: GridSpan | GridPositionValue) { + return isGridSpan(dim) + ? dim.type === 'SPAN_NUMERIC' + ? dim.value + : null + : dim.numericalPosition + } + + const start = maybeNumericalValue(e.gridRowStart) + const end = maybeNumericalValue(e.gridColumnStart) + + const row = start ?? 1 + const column = end ?? 1 return (row - 1) * gridTemplateColumns + column - 1 } @@ -225,8 +253,8 @@ export function sortElementsByGridPosition(gridTemplateColumns: number) { } } -function isGridPositionNumericValue(p: GridPosition | null): p is GridPositionValue { - return p != null && !(isCSSKeyword(p) && p.value === 'auto') +function isGridPositionNumericValue(p: GridPositionOrSpan | null): p is GridPositionValue { + return p != null && !(isCSSKeyword(p) && !isGridSpan(p) && p.value === 'auto') } export function getGridPositionIndex(props: { diff --git a/editor/src/components/canvas/controls/grid-controls.tsx b/editor/src/components/canvas/controls/grid-controls.tsx index b708bead4b9f..87bc49846aa1 100644 --- a/editor/src/components/canvas/controls/grid-controls.tsx +++ b/editor/src/components/canvas/controls/grid-controls.tsx @@ -16,9 +16,11 @@ import * as EP from '../../../core/shared/element-path' import type { ElementInstanceMetadataMap, GridAutoOrTemplateDimensions, + GridPositionOrSpan, } from '../../../core/shared/element-template' import { isGridAutoOrTemplateDimensions, + isGridSpan, type GridAutoOrTemplateBase, } from '../../../core/shared/element-template' import type { CanvasPoint, CanvasRectangle, LocalRectangle } from '../../../core/shared/math-utils' @@ -30,7 +32,6 @@ import { pointsEqual, scaleRect, windowPoint, - zeroCanvasRect, zeroRectIfNullOrInfinity, } from '../../../core/shared/math-utils' import { @@ -47,19 +48,9 @@ import { Modifier } from '../../../utils/modifiers' import { when } from '../../../utils/react-conditionals' import { useColorTheme, UtopiaStyles } from '../../../uuiui' import { useDispatch } from '../../editor/store/dispatch-context' +import { Substores, useEditorState, useRefEditorState } from '../../editor/store/store-hook' +import type { GridDimension, GridDiscreteDimension } from '../../inspector/common/css-utils' import { - Substores, - useEditorState, - useRefEditorState, - useSelectorWithCallback, -} from '../../editor/store/store-hook' -import type { - CSSNumber, - GridDimension, - GridDiscreteDimension, -} from '../../inspector/common/css-utils' -import { - cssNumberToString, isCSSKeyword, isDynamicGridRepeat, isGridCSSRepeat, @@ -111,7 +102,7 @@ import { useResizeEdges } from './select-mode/use-resize-edges' import { getGridHelperStyleMatchingTargetGrid } from './grid-controls-helpers' import { isFillOrStretchModeAppliedOnSpecificSide } from '../../inspector/inspector-common' import type { PinOutlineProps } from './position-outline' -import { PinOutline, PositionOutline, usePropsOrJSXAttributes } from './position-outline' +import { PinOutline, usePropsOrJSXAttributes } from './position-outline' import { getLayoutProperty } from '../../../core/layout/getLayoutProperty' import { styleStringInArray } from '../../../utils/common-constants' @@ -755,22 +746,24 @@ const GridControl = React.memo(({ grid, controlsVisible }) => const columnFromProps = cell.specialSizeMeasurements.elementGridProperties.gridColumnStart const rowFromProps = cell.specialSizeMeasurements.elementGridProperties.gridRowStart + + function getAxisValue(value: GridPositionOrSpan | null, counted: number): number { + if ( + value == null || + isCSSKeyword(value) || + isGridSpan(value) || + value.numericalPosition == null + ) { + return counted + } + return value.numericalPosition + } return { elementPath: cell.elementPath, globalFrame: cell.globalFrame, borderRadius: cell.specialSizeMeasurements.borderRadius, - column: - columnFromProps == null - ? countedColumn - : isCSSKeyword(columnFromProps) - ? countedColumn - : columnFromProps.numericalPosition ?? countedColumn, - row: - rowFromProps == null - ? countedRow - : isCSSKeyword(rowFromProps) - ? countedRow - : rowFromProps.numericalPosition ?? countedRow, + column: getAxisValue(columnFromProps, countedColumn), + row: getAxisValue(rowFromProps, countedRow), index: index, } }, children) @@ -1933,15 +1926,23 @@ const GridElementContainingBlock = React.memo(( const gridComputed = childMetadata.specialSizeMeasurements.elementGridProperties return { gridColumnStart: - gridPositionToValue(gridFromProps.gridColumnStart ?? gridComputed.gridColumnStart) ?? - undefined, + gridPositionToValue( + gridFromProps.gridColumnStart ?? gridComputed.gridColumnStart, + null, + ) ?? undefined, gridColumnEnd: - gridPositionToValue(gridFromProps.gridColumnEnd ?? gridComputed.gridColumnEnd) ?? - undefined, + gridPositionToValue( + gridFromProps.gridColumnEnd ?? gridComputed.gridColumnEnd, + gridFromProps.gridColumnStart ?? gridComputed.gridColumnStart, + ) ?? undefined, gridRowStart: - gridPositionToValue(gridFromProps.gridRowStart ?? gridComputed.gridRowStart) ?? undefined, + gridPositionToValue(gridFromProps.gridRowStart ?? gridComputed.gridRowStart, null) ?? + undefined, gridRowEnd: - gridPositionToValue(gridFromProps.gridRowEnd ?? gridComputed.gridRowEnd) ?? undefined, + gridPositionToValue( + gridFromProps.gridRowEnd ?? gridComputed.gridRowEnd, + gridFromProps.gridRowStart ?? gridComputed.gridRowStart, + ) ?? undefined, position: childMetadata.specialSizeMeasurements.position ?? undefined, } }, diff --git a/editor/src/components/canvas/dom-walker.ts b/editor/src/components/canvas/dom-walker.ts index d18f48e5d0cc..4860e9402570 100644 --- a/editor/src/components/canvas/dom-walker.ts +++ b/editor/src/components/canvas/dom-walker.ts @@ -11,6 +11,7 @@ import type { DomElementMetadata, GridAutoOrTemplateBase, BorderWidths, + GridPositionOrSpan, } from '../../core/shared/element-template' import { specialSizeMeasurements, @@ -19,6 +20,7 @@ import { gridAutoOrTemplateFallback, domElementMetadata, gridAutoOrTemplateDimensions, + isGridSpan, } from '../../core/shared/element-template' import type { ElementPath } from '../../core/shared/project-file-types' import type { ElementCanvasRectangleCache } from '../../core/shared/dom-utils' @@ -630,62 +632,51 @@ function getGridElementProperties( container: GridContainerProperties, elementStyle: CSSStyleDeclaration, ): GridElementProperties { + function getPlacementPin( + value: GridPositionOrSpan | null, + axis: 'row' | 'column', + pin: 'start' | 'end', + style: string, + ) { + if (isGridSpan(value) || value != null) { + return value + } + return defaultEither(null, parseGridPosition(container, axis, pin, value ?? null, style)) + } + const gridColumn = defaultEither( null, parseGridRange(container, 'column', elementStyle.gridColumn), ) - - const gridColumnStart = - gridColumn?.start ?? - defaultEither( - null, - parseGridPosition( - container, - 'column', - 'start', - gridColumn?.start ?? null, - elementStyle.gridColumnStart, - ), - ) ?? - null - const gridColumnEnd = - gridColumn?.end ?? - defaultEither( - null, - parseGridPosition( - container, - 'column', - 'end', - gridColumn?.end ?? null, - elementStyle.gridColumnEnd, - ), - ) ?? - null + const gridColumnStart = getPlacementPin( + gridColumn?.start ?? null, + 'column', + 'start', + elementStyle.gridColumnStart, + ) + const gridColumnEnd = getPlacementPin( + gridColumn?.end ?? null, + 'column', + 'end', + elementStyle.gridColumnEnd, + ) const adjustedColumnEnd = - isCSSKeyword(gridColumnEnd) && gridColumn?.end != null ? gridColumn.end : gridColumnEnd + isGridSpan(gridColumn?.end) || (isCSSKeyword(gridColumnEnd) && gridColumn?.end != null) + ? gridColumn.end + : gridColumnEnd const gridRow = defaultEither(null, parseGridRange(container, 'row', elementStyle.gridRow)) - const gridRowStart = - gridRow?.start ?? - defaultEither( - null, - parseGridPosition( - container, - 'row', - 'start', - gridRow?.start ?? null, - elementStyle.gridRowStart, - ), - ) ?? - null - const gridRowEnd = - gridRow?.end ?? - defaultEither( - null, - parseGridPosition(container, 'row', 'end', gridRow?.end ?? null, elementStyle.gridRowEnd), - ) ?? - null - const adjustedRowEnd = isCSSKeyword(gridRowEnd) && gridRow?.end != null ? gridRow.end : gridRowEnd + const gridRowStart = getPlacementPin( + gridRow?.start ?? null, + 'row', + 'start', + elementStyle.gridRowStart, + ) + const gridRowEnd = getPlacementPin(gridRow?.end ?? null, 'row', 'end', elementStyle.gridRowEnd) + const adjustedRowEnd = + isGridSpan(gridRow?.end) || (isCSSKeyword(gridRowEnd) && gridRow?.end != null) + ? gridRow.end + : gridRowEnd const result = gridElementProperties( gridColumnStart, diff --git a/editor/src/components/editor/store/store-deep-equality-instances.ts b/editor/src/components/editor/store/store-deep-equality-instances.ts index 1cf91fa4ebf5..9ea58768492b 100644 --- a/editor/src/components/editor/store/store-deep-equality-instances.ts +++ b/editor/src/components/editor/store/store-deep-equality-instances.ts @@ -152,6 +152,10 @@ import type { GridAutoOrTemplateDimensions, GridAutoOrTemplateFallback, BorderWidths, + GridSpan, + GridSpanArea, + GridSpanNumeric, + GridPositionOrSpan, } from '../../../core/shared/element-template' import { elementInstanceMetadata, @@ -223,6 +227,7 @@ import { gridPositionValue, gridAutoOrTemplateFallback, gridAutoOrTemplateDimensions, + isGridSpan, } from '../../../core/shared/element-template' import type { CanvasRectangle, @@ -2169,16 +2174,66 @@ export const GridPositionKeepDeepEquality: KeepDeepEqualityCall = } } +export const GridSpanAreaKeepDeepEquality: KeepDeepEqualityCall = + combine1EqualityCall( + (p) => p.value, + StringKeepDeepEquality, + (value) => ({ type: 'SPAN_AREA', value: value }), + ) + +export const GridSpanNumericKeepDeepEquality: KeepDeepEqualityCall = + combine1EqualityCall( + (p) => p.value, + NumberKeepDeepEquality, + (value) => ({ type: 'SPAN_NUMERIC', value: value }), + ) + +export const GridSpanKeepDeepEquality: KeepDeepEqualityCall = (oldValue, newValue) => { + switch (oldValue.type) { + case 'SPAN_AREA': + if (newValue.type === oldValue.type) { + return GridSpanAreaKeepDeepEquality(oldValue, newValue) + } + break + case 'SPAN_NUMERIC': + if (newValue.type === oldValue.type) { + return GridSpanNumericKeepDeepEquality(oldValue, newValue) + } + break + default: + assertNever(oldValue) + } + return keepDeepEqualityResult(newValue, false) +} + +export const GridPositionOrSpanKeepDeepEquality: KeepDeepEqualityCall = ( + oldValue, + newValue, +) => { + if (isGridSpan(oldValue)) { + if (isGridSpan(newValue)) { + return GridSpanKeepDeepEquality(oldValue, newValue) + } else { + return keepDeepEqualityResult(newValue, false) + } + } else { + if (isGridSpan(newValue)) { + return keepDeepEqualityResult(newValue, false) + } + return GridPositionKeepDeepEquality(oldValue, newValue) + } +} + export function GridElementPropertiesKeepDeepEquality(): KeepDeepEqualityCall { return combine4EqualityCalls( (properties) => properties.gridColumnStart, - nullableDeepEquality(GridPositionKeepDeepEquality), + nullableDeepEquality(GridPositionOrSpanKeepDeepEquality), (properties) => properties.gridColumnEnd, - nullableDeepEquality(GridPositionKeepDeepEquality), + nullableDeepEquality(GridPositionOrSpanKeepDeepEquality), (properties) => properties.gridRowStart, - nullableDeepEquality(GridPositionKeepDeepEquality), + nullableDeepEquality(GridPositionOrSpanKeepDeepEquality), (properties) => properties.gridRowEnd, - nullableDeepEquality(GridPositionKeepDeepEquality), + nullableDeepEquality(GridPositionOrSpanKeepDeepEquality), gridElementProperties, ) } diff --git a/editor/src/components/inspector/common/css-utils.spec.ts b/editor/src/components/inspector/common/css-utils.spec.ts index 60e85271648f..b27f1c8d8303 100644 --- a/editor/src/components/inspector/common/css-utils.spec.ts +++ b/editor/src/components/inspector/common/css-utils.spec.ts @@ -1,6 +1,7 @@ import { clearModifiableAttributeUniqueIDs } from '../../../core/shared/jsx-attributes' import type { Either } from '../../../core/shared/either' -import { isLeft, isRight, right } from '../../../core/shared/either' +import { isLeft, isRight, left, right } from '../../../core/shared/either' +import type { GridContainerProperties } from '../../../core/shared/element-template' import { emptyComments, jsExpressionFunctionCall, @@ -10,6 +11,12 @@ import { jsxTestElement, clearExpressionUniqueIDs, clearJSXElementChildUniqueIDs, + gridContainerProperties, + gridAutoOrTemplateDimensions, + gridPositionValue, + gridRange, + gridSpanNumeric, + gridSpanArea, } from '../../../core/shared/element-template' import * as PP from '../../../core/shared/property-path' import type { @@ -19,6 +26,7 @@ import type { CSSColor, CSSTextShadows, CSSTransforms, + GridDimension, } from './css-utils' import { cssAngle, @@ -64,6 +72,7 @@ import { parseConicGradient, parseCSSURLFunction, parsedCurlyBrace, + parseGridRange, parseLinearGradient, parseRadialGradient, parseTextShadow, @@ -1985,3 +1994,144 @@ describe('printGridDimensionCSS', () => { ).toBe('[the-area] minmax(auto, min-content)') }) }) + +function testGridContainerProperties( + cols: GridDimension[], + rows: GridDimension[], +): GridContainerProperties { + return gridContainerProperties( + gridAutoOrTemplateDimensions(cols), + gridAutoOrTemplateDimensions(rows), + gridAutoOrTemplateDimensions([]), + gridAutoOrTemplateDimensions([]), + null, + ) +} + +describe('parseGridRange', () => { + it('can parse a numerical unit', async () => { + const got = parseGridRange(testGridContainerProperties([], []), 'row', '3') + expect(got).toEqual(right(gridRange(gridPositionValue(3), null))) + }) + it('can parse a numerical range', async () => { + const got = parseGridRange(testGridContainerProperties([], []), 'row', '3 / 4') + expect(got).toEqual(right(gridRange(gridPositionValue(3), gridPositionValue(4)))) + }) + it('can parse a line', async () => { + const got = parseGridRange( + testGridContainerProperties( + [], + [ + gridCSSNumber(cssNumber(1, 'fr'), 'foo'), + gridCSSNumber(cssNumber(1, 'fr'), 'bar'), + gridCSSNumber(cssNumber(1, 'fr'), 'baz'), + ], + ), + 'row', + 'bar', + ) + expect(got).toEqual(right(gridRange(gridPositionValue(2), null))) + }) + it('errors if the line is not found in the template', async () => { + const got = parseGridRange( + testGridContainerProperties( + [], + [ + gridCSSNumber(cssNumber(1, 'fr'), 'foo'), + gridCSSNumber(cssNumber(1, 'fr'), 'bar'), + gridCSSNumber(cssNumber(1, 'fr'), 'baz'), + ], + ), + 'row', + 'WRONG', + ) + expect(got).toEqual(left('missing grid item start')) + }) + it('can parse a line range', async () => { + const got = parseGridRange( + testGridContainerProperties( + [], + [ + gridCSSNumber(cssNumber(1, 'fr'), 'foo'), + gridCSSNumber(cssNumber(1, 'fr'), 'bar'), + gridCSSNumber(cssNumber(1, 'fr'), 'baz'), + ], + ), + 'row', + 'bar / baz', + ) + expect(got).toEqual(right(gridRange(gridPositionValue(2), gridPositionValue(3)))) + }) + it('can parse a line / unit mixed range', async () => { + const got = parseGridRange( + testGridContainerProperties( + [], + [ + gridCSSNumber(cssNumber(1, 'fr'), 'foo'), + gridCSSNumber(cssNumber(1, 'fr'), 'bar'), + gridCSSNumber(cssNumber(1, 'fr'), 'baz'), + ], + ), + 'row', + 'bar / 3', + ) + expect(got).toEqual(right(gridRange(gridPositionValue(2), gridPositionValue(3)))) + }) + it('can parse a numerical span', async () => { + const got = parseGridRange(testGridContainerProperties([], []), 'row', 'span 2') + expect(got).toEqual(right(gridRange(gridSpanNumeric(2), null))) + }) + it('can parse a numerical span (flipped)', async () => { + const got = parseGridRange(testGridContainerProperties([], []), 'row', '2 span') + expect(got).toEqual(right(gridRange(gridSpanNumeric(2), null))) + }) + it('can parse an area span', async () => { + const got = parseGridRange(testGridContainerProperties([], []), 'row', 'span some-area') + expect(got).toEqual(right(gridRange(gridSpanArea('some-area'), null))) + }) + it('can parse an area span (flipped)', async () => { + const got = parseGridRange(testGridContainerProperties([], []), 'row', 'some-area span') + expect(got).toEqual(right(gridRange(gridSpanArea('some-area'), null))) + }) + it('can parse a span numerical range', async () => { + const got = parseGridRange(testGridContainerProperties([], []), 'row', 'span 2 / span 3') + expect(got).toEqual(right(gridRange(gridSpanNumeric(2), gridSpanNumeric(3)))) + }) + it('can parse an area span range', async () => { + const got = parseGridRange( + testGridContainerProperties([], []), + 'row', + 'span some-area / span some-other-area', + ) + expect(got).toEqual( + right(gridRange(gridSpanArea('some-area'), gridSpanArea('some-other-area'))), + ) + }) + it('can parse a mixed span range', async () => { + const got = parseGridRange( + testGridContainerProperties([], []), + 'row', + 'span some-area / span 3', + ) + expect(got).toEqual(right(gridRange(gridSpanArea('some-area'), gridSpanNumeric(3)))) + }) + it('can parse a mixed span and numerical range', async () => { + const got = parseGridRange(testGridContainerProperties([], []), 'row', 'span some-area / 3') + expect(got).toEqual(right(gridRange(gridSpanArea('some-area'), gridPositionValue(3)))) + }) + it('can parse a mixed span and line range', async () => { + const got = parseGridRange( + testGridContainerProperties( + [], + [ + gridCSSNumber(cssNumber(1, 'fr'), 'foo'), + gridCSSNumber(cssNumber(1, 'fr'), 'bar'), + gridCSSNumber(cssNumber(1, 'fr'), 'baz'), + ], + ), + 'row', + 'span some-area / bar', + ) + expect(got).toEqual(right(gridRange(gridSpanArea('some-area'), gridPositionValue(2)))) + }) +}) diff --git a/editor/src/components/inspector/common/css-utils.ts b/editor/src/components/inspector/common/css-utils.ts index 0e7d0c444c54..4a381243fdc5 100644 --- a/editor/src/components/inspector/common/css-utils.ts +++ b/editor/src/components/inspector/common/css-utils.ts @@ -14,7 +14,6 @@ import type { LayoutPropertyTypes, StyleLayoutProp } from '../../../core/layout/ import { findLastIndex } from '../../../core/shared/array-utils' import type { Either, Right as EitherRight } from '../../../core/shared/either' import { - applicative2Either, bimapEither, eitherToMaybe, flatMapEither, @@ -34,6 +33,7 @@ import type { GridRange, GridAutoOrTemplateBase, GridContainerProperties, + GridSpan, } from '../../../core/shared/element-template' import { emptyComments, @@ -45,6 +45,8 @@ import { jsExpressionValue, gridPositionValue, gridRange, + gridSpanArea, + gridSpanNumeric, } from '../../../core/shared/element-template' import type { ModifiableAttribute } from '../../../core/shared/jsx-attributes' import { @@ -1198,23 +1200,34 @@ export function parseGridRange( axis: 'row' | 'column', input: unknown, ): Either { - if (typeof input === 'string') { - if (input.includes('/')) { - const splitInput = input.split('/') - const startParsed = parseGridPosition(container, axis, 'start', null, splitInput[0]) - const endParsed = parseGridPosition(container, axis, 'end', null, splitInput[1]) - return applicative2Either(gridRange, startParsed, endParsed) - } else { - const startParsed = parseGridPosition(container, axis, 'start', null, input) - return mapEither((start) => { - const end = - !isCSSKeyword(start) && start.numericalPosition != null ? cssKeyword('auto') : null - return gridRange(start, end) - }, startParsed) - } - } else { - return left('Not a valid grid range.') + if (typeof input !== 'string') { + return left('invalid grid item') + } + + const parsed = csstree.parse(input, { context: 'value' }) + if (parsed.type !== 'Value') { + return left('invalid grid item value') + } + + const children = parsed.children.toArray() + const slashIndex = children.findIndex((c) => c.type === 'Operator' && c.value === '/') + + const isRange = slashIndex >= 0 + const start = isRange ? children.slice(0, slashIndex) : children + const end = isRange ? children.slice(slashIndex + 1) : [] + + if (start.length === 0) { + return left('invalid grid item start') + } + + const maybeStart = maybeParseGridSpan(start) ?? maybeParseGridLine(start, axis, container) + if (maybeStart == null) { + return left('missing grid item start') } + + const maybeEnd = maybeParseGridSpan(end) ?? maybeParseGridLine(end, axis, container) + + return right(gridRange(maybeStart, maybeEnd)) } export function parseGridAutoOrTemplateBase( @@ -5902,3 +5915,68 @@ function parseRepeatTimes(firstChild: csstree.CssNode) { return null } } + +export function maybeParseGridSpan(nodes: csstree.CssNode[]): GridSpan | null { + if (nodes.length !== 2) { + return null + } + + const spanIndex = nodes.findIndex((node) => node.type === 'Identifier' && node.name === 'span') + if (spanIndex < 0) { + return null + } + + const valueNodes = nodes.slice(0, spanIndex).concat(nodes.slice(spanIndex + 1)) + + const numericValue: csstree.NumberNode | null = + valueNodes.find((node) => node.type === 'Number') ?? null + const areaValue: csstree.Identifier | null = + valueNodes.find((node) => node.type === 'Identifier') ?? null + + if (numericValue != null) { + return gridSpanNumeric(parseInt(numericValue.value)) + } else if (areaValue != null) { + return gridSpanArea(areaValue.name) + } else { + return null + } +} + +export function maybeParseGridLine( + nodes: csstree.CssNode[], + axis: 'row' | 'column', + container: GridContainerProperties, +): GridPosition | null { + if (nodes.length !== 1) { + return null + } + + const firstNode = nodes[0] + switch (firstNode.type) { + case 'Number': + return gridPositionValue(parseInt(firstNode.value)) + case 'Identifier': + // the identifier can either be a keyword… + if (isValidGridDimensionKeyword(firstNode.name)) { + return cssKeyword(firstNode.name) + } + + // …or a line name, in which case look it up in the template and return its index + const targetTracks = + axis === 'column' ? container.gridTemplateColumns : container.gridTemplateRows + // TODO important! this behavior is currently incorrect, as this needs to find the _first_ lineName (in case of repeats) + // or otherwise relative (even backwards). + const maybeLineFromName = + targetTracks?.type === 'DIMENSIONS' + ? targetTracks.dimensions.findIndex((dim) => dim.lineName === firstNode.name) + : null + if (maybeLineFromName == null || maybeLineFromName < 0) { + // line name not found + return null + } + + return gridPositionValue(maybeLineFromName + 1) // tracks are 1-indexed + default: + return null + } +} diff --git a/editor/src/components/inspector/flex-section.tsx b/editor/src/components/inspector/flex-section.tsx index 5b5410823dc0..6912329ed34b 100644 --- a/editor/src/components/inspector/flex-section.tsx +++ b/editor/src/components/inspector/flex-section.tsx @@ -60,9 +60,13 @@ import { setProperty, } from '../canvas/commands/set-property-command' import * as PP from '../../core/shared/property-path' -import type { GridContainerProperties, GridPosition } from '../../core/shared/element-template' +import type { + GridContainerProperties, + GridPositionOrSpan, +} from '../../core/shared/element-template' import { gridPositionValue, + isGridSpan, type ElementInstanceMetadata, type GridElementProperties, } from '../../core/shared/element-template' @@ -322,9 +326,10 @@ const TemplateDimensionControl = React.memo( ...child.specialSizeMeasurements.elementGridPropertiesFromProps, } - function needsAdjusting(pos: GridPosition | null, bound: number) { + function needsAdjusting(pos: GridPositionOrSpan | null, bound: number) { return pos != null && !isCSSKeyword(pos) && + !isGridSpan(pos) && // TODO support grid spans pos.numericalPosition != null && pos.numericalPosition >= bound ? pos.numericalPosition diff --git a/editor/src/components/inspector/sections/style-section/container-subsection/grid-cell-subsection.tsx b/editor/src/components/inspector/sections/style-section/container-subsection/grid-cell-subsection.tsx index 15141b3f314a..f672852d4394 100644 --- a/editor/src/components/inspector/sections/style-section/container-subsection/grid-cell-subsection.tsx +++ b/editor/src/components/inspector/sections/style-section/container-subsection/grid-cell-subsection.tsx @@ -6,11 +6,13 @@ import type { ElementInstanceMetadata, GridContainerProperties, GridElementProperties, + GridPositionOrSpan, GridPositionValue, ValidGridPositionKeyword, } from '../../../../../core/shared/element-template' import { gridPositionValue, + isGridSpan, isValidGridPositionKeyword, type GridPosition, } from '../../../../../core/shared/element-template' @@ -247,6 +249,7 @@ const DimensionsControls = React.memo( case 'width': if ( !isCSSKeyword(newValues.gridColumnStart) && + !isGridSpan(newValues.gridColumnStart) && // TODO support grid spans newValues.gridColumnStart?.numericalPosition != null && isCSSNumber(e) ) { @@ -258,6 +261,7 @@ const DimensionsControls = React.memo( case 'height': if ( !isCSSKeyword(newValues.gridRowStart) && + !isGridSpan(newValues.gridRowStart) && // TODO support grid spans newValues.gridRowStart?.numericalPosition != null && isCSSNumber(e) ) { @@ -277,6 +281,7 @@ const DimensionsControls = React.memo( const columnStartValue = React.useMemo(() => { return getValueWithAlias( cell.specialSizeMeasurements.elementGridProperties.gridColumnStart, + cell.specialSizeMeasurements.elementGridProperties.gridColumnEnd, columnLabels, ) }, [cell, columnLabels]) @@ -284,6 +289,7 @@ const DimensionsControls = React.memo( const rowStartValue = React.useMemo(() => { return getValueWithAlias( cell.specialSizeMeasurements.elementGridProperties.gridRowStart, + cell.specialSizeMeasurements.elementGridProperties.gridRowEnd, rowLabels, ) }, [cell, rowLabels]) @@ -399,6 +405,7 @@ const BoundariesControls = React.memo( const columnStartValue = React.useMemo(() => { return getValueWithAlias( cell.specialSizeMeasurements.elementGridProperties.gridColumnStart, + cell.specialSizeMeasurements.elementGridProperties.gridColumnEnd, columnLabels, ) }, [cell, columnLabels]) @@ -406,6 +413,7 @@ const BoundariesControls = React.memo( const columnEndValue = React.useMemo(() => { return getValueWithAlias( cell.specialSizeMeasurements.elementGridProperties.gridColumnEnd, + null, columnLabels, ) }, [cell, columnLabels]) @@ -413,6 +421,7 @@ const BoundariesControls = React.memo( const rowStartValue = React.useMemo(() => { return getValueWithAlias( cell.specialSizeMeasurements.elementGridProperties.gridRowStart, + cell.specialSizeMeasurements.elementGridProperties.gridRowEnd, rowLabels, ) }, [cell, rowLabels]) @@ -420,6 +429,7 @@ const BoundariesControls = React.memo( const rowEndValue = React.useMemo(() => { return getValueWithAlias( cell.specialSizeMeasurements.elementGridProperties.gridRowEnd, + null, rowLabels, ) }, [cell, rowLabels]) @@ -494,8 +504,14 @@ const BoundariesControls = React.memo( ) BoundariesControls.displayName = 'BoundariesControls' -function getValue(pos: GridPosition | null): CSSNumber | null { - if (pos == null || isCSSKeyword(pos) || pos.numericalPosition == null) { +function getValue(pos: GridPositionOrSpan | null, maybeOffset: number = 0): CSSNumber | null { + if (pos == null || isCSSKeyword(pos)) { + return null + } + if (isGridSpan(pos)) { + return pos.type === 'SPAN_AREA' ? null : cssNumber(pos.value + maybeOffset) + } + if (pos.numericalPosition == null) { return null } return cssNumber(pos.numericalPosition) @@ -503,11 +519,14 @@ function getValue(pos: GridPosition | null): CSSNumber | null { function getWidthOrHeight(props: GridElementProperties, dimension: 'width' | 'height') { const start = getValue(dimension === 'width' ? props.gridColumnStart : props.gridRowStart) - const end = getValue(dimension === 'width' ? props.gridColumnEnd : props.gridRowEnd) + const end = getValue( + dimension === 'width' ? props.gridColumnEnd : props.gridRowEnd, + start?.value ?? 0, + ) if (start == null || end == null) { - return 1 + return Math.max(1, start?.value ?? 0, end?.value ?? 0) } - return end.value - start.value + return Math.max(1, end.value - start.value) } function getLabelsFromTemplate(gridTemplate: GridContainerProperties) { @@ -559,10 +578,23 @@ function maybeValueFromLineName( } function getValueWithAlias( - position: GridPosition | null, + start: GridPositionOrSpan | null, + end: GridPositionOrSpan | null, labels: { lineName: string; position: number }[], -) { - const value = getValue(position) ?? cssKeyword('auto') +): { + value: CSSNumber | CSSKeyword + alias?: string +} { + if (isGridSpan(start)) { + if (isGridSpan(end)) { + return start.type === 'SPAN_NUMERIC' + ? { value: cssNumber(start.value) } + : { value: cssKeyword('auto') } + } else { + return { value: cssKeyword('auto') } + } + } + const value = getValue(start) ?? cssKeyword('auto') if (isCSSKeyword(value)) { return { value: value } } diff --git a/editor/src/core/shared/element-template.ts b/editor/src/core/shared/element-template.ts index fdf067c9b8c6..840a7fe872e1 100644 --- a/editor/src/core/shared/element-template.ts +++ b/editor/src/core/shared/element-template.ts @@ -2680,21 +2680,21 @@ export const isValidGridPositionKeyword = } export interface GridRange { - start: GridPosition - end: GridPosition | null + start: GridPositionOrSpan + end: GridPositionOrSpan | null } -export function gridRange(start: GridPosition, end: GridPosition | null): GridRange { +export function gridRange(start: GridPositionOrSpan, end: GridPositionOrSpan | null): GridRange { return { start: start, end: end, } } -export type GridColumnStart = GridPosition -export type GridColumnEnd = GridPosition -export type GridRowStart = GridPosition -export type GridRowEnd = GridPosition +export type GridColumnStart = GridPositionOrSpan +export type GridColumnEnd = GridPositionOrSpan +export type GridRowStart = GridPositionOrSpan +export type GridRowEnd = GridPositionOrSpan export interface GridAutoOrTemplateFallback { type: 'FALLBACK' @@ -3229,3 +3229,50 @@ export function clearJSArbitraryStatementSourceMaps( assertNever(statement) } } + +export type GridPositionOrSpan = GridPosition | GridSpan + +export type GridSpan = GridSpanNumeric | GridSpanArea + +export function isGridSpan(u: unknown): u is GridSpan { + const maybe = u as GridSpan + return ( + maybe != null && typeof u === 'object' && (isGridSpanNumeric(maybe) || isGridSpanArea(maybe)) + ) +} + +export function stringifyGridSpan(span: GridSpan): string { + return `span ${span.value}` +} + +export type GridSpanNumeric = { + type: 'SPAN_NUMERIC' + value: number +} + +export function gridSpanNumeric(value: number): GridSpanNumeric { + return { + type: 'SPAN_NUMERIC', + value: value, + } +} + +function isGridSpanNumeric(span: GridSpan): span is GridSpanNumeric { + return span.type === 'SPAN_NUMERIC' +} + +export type GridSpanArea = { + type: 'SPAN_AREA' + value: string +} + +export function gridSpanArea(areaName: string): GridSpanArea { + return { + type: 'SPAN_AREA', + value: areaName, + } +} + +function isGridSpanArea(span: GridSpan): span is GridSpanArea { + return span.type === 'SPAN_AREA' +}