From 691037d9512e09bc3a2e93877934f46509885414 Mon Sep 17 00:00:00 2001 From: Federico Ruggi <1081051+ruggi@users.noreply.github.com> Date: Thu, 18 Jul 2024 16:30:35 +0200 Subject: [PATCH] Support `repeat()` in grid templates (#6090) **Problem:** Grids with `repeat()` in their template are correctly displayed, but cannot be resized because the dimensions are `FALLBACK`. **Fix:** Expand the `repeat()` functions inside the templates when parsing them, so they are calculated correctly. https://github.com/user-attachments/assets/7744262c-f701-4685-a357-6ddaf9685bca Fixes #6089 --- .../resize-grid-strategy.spec.browser2.tsx | 132 +++++++++++++++++- .../inspector/common/css-tree-utils.ts | 68 +++++++++ .../inspector/common/css-utils.spec.ts | 24 ++++ .../components/inspector/common/css-utils.ts | 26 +++- 4 files changed, 242 insertions(+), 8 deletions(-) create mode 100644 editor/src/components/inspector/common/css-tree-utils.ts diff --git a/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.spec.browser2.tsx b/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.spec.browser2.tsx index 63e06693071c..67593bcedcee 100644 --- a/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.spec.browser2.tsx +++ b/editor/src/components/canvas/canvas-strategies/strategies/resize-grid-strategy.spec.browser2.tsx @@ -5,7 +5,7 @@ import { CanvasControlsContainerID } from '../../controls/new-canvas-controls' import { mouseDownAtPoint, mouseMoveToPoint, mouseUpAtPoint } from '../../event-helpers.test-utils' import { canvasPoint } from '../../../../core/shared/math-utils' -const testProject = ` +const makeTestProject = (columns: string, rows: string) => ` import * as React from 'react' import { Storyboard } from 'utopia-api' @@ -22,8 +22,8 @@ export var storyboard = ( gap: 10, width: 600, height: 600, - gridTemplateColumns: '2.4fr 1fr 1fr', - gridTemplateRows: '99px 109px 90px', + gridTemplateColumns: '${columns}', + gridTemplateRows: '${rows}', height: 'max-content', }} > @@ -97,7 +97,10 @@ export var storyboard = ( describe('resize a grid', () => { it('update a fractionally sized column', async () => { - const renderResult = await renderTestEditorWithCode(testProject, 'await-first-dom-report') + const renderResult = await renderTestEditorWithCode( + makeTestProject('2.4fr 1fr 1fr', '99px 109px 90px'), + 'await-first-dom-report', + ) const target = EP.fromString(`sb/grid/row-1-column-2`) await renderResult.dispatch(selectComponents([target], false), true) await renderResult.getDispatchFollowUpActionsFinished() @@ -210,7 +213,10 @@ export var storyboard = ( }) it('update a pixel sized row', async () => { - const renderResult = await renderTestEditorWithCode(testProject, 'await-first-dom-report') + const renderResult = await renderTestEditorWithCode( + makeTestProject('2.4fr 1fr 1fr', '99px 109px 90px'), + 'await-first-dom-report', + ) const target = EP.fromString(`sb/grid/row-1-column-2`) await renderResult.dispatch(selectComponents([target], false), true) await renderResult.getDispatchFollowUpActionsFinished() @@ -319,6 +325,122 @@ export var storyboard = ( ) +`) + }) + + it('update a repeat (fr) sized column', async () => { + const renderResult = await renderTestEditorWithCode( + makeTestProject('repeat(3, 1fr)', '99px 109px 90px'), + 'await-first-dom-report', + ) + const target = EP.fromString(`sb/grid/row-1-column-2`) + await renderResult.dispatch(selectComponents([target], false), true) + await renderResult.getDispatchFollowUpActionsFinished() + const canvasControlsLayer = renderResult.renderedDOM.getByTestId(CanvasControlsContainerID) + const resizeControl = renderResult.renderedDOM.getByTestId(`grid-column-handle-1`) + const resizeControlRect = resizeControl.getBoundingClientRect() + const startPoint = canvasPoint({ + x: resizeControlRect.x + resizeControlRect.width / 2, + y: resizeControlRect.y + resizeControlRect.height / 2, + }) + const endPoint = canvasPoint({ + x: startPoint.x + 20, + y: startPoint.y, + }) + await mouseMoveToPoint(resizeControl, startPoint) + await mouseDownAtPoint(resizeControl, startPoint) + await mouseMoveToPoint(canvasControlsLayer, endPoint) + await mouseUpAtPoint(canvasControlsLayer, endPoint) + await renderResult.getDispatchFollowUpActionsFinished() + + expect(getPrintedUiJsCode(renderResult.getEditorState())) + .toEqual(`import * as React from 'react' +import { Storyboard } from 'utopia-api' + +export var storyboard = ( + +
+
+
+
+
+
+
+
+
+
+
+ +) `) }) }) diff --git a/editor/src/components/inspector/common/css-tree-utils.ts b/editor/src/components/inspector/common/css-tree-utils.ts new file mode 100644 index 000000000000..46f7ea72ce25 --- /dev/null +++ b/editor/src/components/inspector/common/css-tree-utils.ts @@ -0,0 +1,68 @@ +import * as csstree from 'css-tree' + +export function parseCssTreeNodeValue(str: string): csstree.CssNode { + return csstree.parse(str, { context: 'value' }) +} + +export function cssTreeNodeList(nodes: csstree.CssNode[]): csstree.List { + let list = new csstree.List() + nodes.forEach((node) => list.appendData(node)) + return list +} + +export function cssTreeNodeValue(children: csstree.List): csstree.Value { + return { + type: 'Value', + children: children, + } +} + +export function expandCssTreeNodeValue(node: csstree.CssNode): csstree.Value { + return cssTreeNodeValue(expandNode(node)) +} + +function expandNode(node: csstree.CssNode): csstree.List { + if (node.type === 'Function' && node.name === 'repeat') { + // specific expansion for repeat functions + return expandRepeatFunction(node) + } else if (node.type === 'Value') { + // recursively expand children of Value nodes + const children = node.children.toArray() + const expanded = children.flatMap((child) => expandNode(child).toArray()) + return cssTreeNodeList(expanded) + } else { + // fallback to just the verbatim node + return cssTreeNodeList([node]) + } +} + +function expandRepeatFunction(fnNode: csstree.FunctionNode): csstree.List { + // The node should have 3+ children, because it should be [Number + Operator (,) + Value(s)] + // TODO this should be extended to support non-numeric, keyword repeaters + const children = fnNode.children.toArray() + if (children.length < 3) { + // just return the original children if the format is not supported + return fnNode.children + } + + // 1. parse the repeat number + const repeatNumber = children[0] + if (repeatNumber.type !== 'Number') { + return fnNode.children + } + const times = parseInt(repeatNumber.value) + + // 2. grab ALL the values to repeat (rightside of the comma), wrap them in a new Value node, + // and expand them so they support nested repeats + const valuesToRepeat = children.slice(2) + const nodeToRepeat = cssTreeNodeValue(cssTreeNodeList(valuesToRepeat)) + const expandedValues = expandNode(nodeToRepeat) + + // 3. append the expanded values N times, where N is the number of repeats parsed earlier + let result = new csstree.List() + for (let i = 0; i < times; i++) { + expandedValues.forEach((v) => result.appendData(v)) + } + + return result +} diff --git a/editor/src/components/inspector/common/css-utils.spec.ts b/editor/src/components/inspector/common/css-utils.spec.ts index cc0fb96037fa..139eb037d072 100644 --- a/editor/src/components/inspector/common/css-utils.spec.ts +++ b/editor/src/components/inspector/common/css-utils.spec.ts @@ -54,6 +54,7 @@ import { defaultCSSRadialGradientSize, defaultCSSRadialOrConicGradientCenter, disabledFunctionName, + expandRepeatFunctions, parseBackgroundColor, parseBackgroundImage, parseBorderRadius, @@ -1839,3 +1840,26 @@ describe('tokenizeGridTemplate', () => { ]) }) }) + +describe('expandRepeatFunctions', () => { + it('expands repeat', async () => { + expect(expandRepeatFunctions('repeat(4, 1fr)')).toEqual('1fr 1fr 1fr 1fr') + }) + it('expands repeat with multiple units', () => { + expect(expandRepeatFunctions('repeat(2, 1fr 2fr 3fr)')).toEqual('1fr 2fr 3fr 1fr 2fr 3fr') + }) + it('expands repeat with spacing', () => { + expect(expandRepeatFunctions('repeat( 4 , 1fr )')).toEqual('1fr 1fr 1fr 1fr') + }) + it('expands repeat with decimals', () => { + expect(expandRepeatFunctions('repeat(4, 1.5fr)')).toEqual('1.5fr 1.5fr 1.5fr 1.5fr') + }) + it('expands nested', () => { + expect(expandRepeatFunctions('repeat(2, repeat(3, 1fr))')).toEqual('1fr 1fr 1fr 1fr 1fr 1fr') + + // Note: crazytown, I'm not even sure this is valid CSS *but still* + expect(expandRepeatFunctions('repeat(2, repeat(2, 1fr repeat(3, 2fr 4em)))')).toEqual( + '1fr 2fr 4em 2fr 4em 2fr 4em 1fr 2fr 4em 2fr 4em 2fr 4em 1fr 2fr 4em 2fr 4em 2fr 4em 1fr 2fr 4em 2fr 4em 2fr 4em', + ) + }) +}) diff --git a/editor/src/components/inspector/common/css-utils.ts b/editor/src/components/inspector/common/css-utils.ts index cfa15b330723..29680e4b1354 100644 --- a/editor/src/components/inspector/common/css-utils.ts +++ b/editor/src/components/inspector/common/css-utils.ts @@ -86,6 +86,8 @@ import { memoize } from '../../../core/shared/memoize' import { parseCSSArray } from '../../../printer-parsers/css/css-parser-utils' import type { ParseError } from '../../../utils/value-parser-utils' import { descriptionParseError } from '../../../utils/value-parser-utils' +import * as csstree from 'css-tree' +import { expandCssTreeNodeValue, parseCssTreeNodeValue } from './css-tree-utils' var combineRegExp = function (regexpList: Array, flags?: string) { let source: string = '' @@ -943,12 +945,30 @@ export function parseGridRange( } } +export function expandRepeatFunctions(str: string): string { + const node = parseCssTreeNodeValue(str) + const expanded = expandCssTreeNodeValue(node) + return csstree.generate(expanded) +} + const reGridAreaNameBrackets = /^\[.+\]$/ -export function tokenizeGridTemplate(str: string): string[] { - let tokens: string[] = [] - let parts = str.replace(/\]/g, '] ').split(/\s+/) +function normalizeGridTemplate(template: string): string { + type normalizeFn = (s: string) => string + + const normalizePasses: normalizeFn[] = [ + // 1. expand repeat functions + expandRepeatFunctions, + // 2. normalize area names spacing + (s) => s.replace(/\]/g, '] ').replace(/\[/g, ' ['), + ] + return normalizePasses.reduce((working, normalize) => normalize(working), template).trim() +} + +export function tokenizeGridTemplate(template: string): string[] { + let tokens: string[] = [] + let parts = normalizeGridTemplate(template).split(/\s+/) while (parts.length > 0) { const part = parts.shift()?.trim() if (part == null) {