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) {