Skip to content

Commit

Permalink
Support repeat() in grid templates (#6090)
Browse files Browse the repository at this point in the history
**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
  • Loading branch information
ruggi authored Jul 18, 2024
1 parent 03f6f0c commit 691037d
Show file tree
Hide file tree
Showing 4 changed files with 242 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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',
}}
>
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -319,6 +325,122 @@ export var storyboard = (
</div>
</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 = (
<Storyboard data-uid='sb'>
<div
data-uid='grid'
data-testid='grid'
style={{
position: 'absolute',
left: 25,
top: 305,
display: 'grid',
gap: 10,
width: 600,
height: 600,
gridTemplateColumns: '1fr 2fr 1fr',
gridTemplateRows: '99px 109px 90px',
height: 'max-content',
}}
>
<div
data-uid='row-1-column-1'
data-testid='row-1-column-1'
style={{
backgroundColor: 'green',
gridColumnStart: 1,
gridColumnEnd: 1,
gridRowStart: 2,
gridRowEnd: 2,
}}
/>
<div
data-uid='row-1-column-2'
data-testid='row-1-column-2'
style={{ backgroundColor: 'blue' }}
/>
<div
data-uid='row-1-column-3'
data-testid='row-1-column-3'
style={{ backgroundColor: 'pink' }}
/>
<div
data-uid='row-2-column-1'
data-testid='row-2-column-1'
style={{
backgroundColor: 'green',
gridColumnStart: 3,
gridColumnEnd: 4,
gridRowStart: 2,
gridRowEnd: 4,
}}
/>
<div
data-uid='row-2-column-2'
data-testid='row-2-column-2'
style={{
backgroundColor: 'blue',
gridColumnStart: 2,
gridColumnEnd: 2,
gridRowStart: 2,
gridRowEnd: 2,
}}
/>
<div
data-uid='row-2-column-3'
data-testid='row-2-column-3'
style={{ backgroundColor: 'pink' }}
/>
<div
data-uid='row-3-column-1'
data-testid='row-3-column-1'
style={{ backgroundColor: 'green' }}
/>
<div
data-uid='row-3-column-2'
data-testid='row-3-column-2'
style={{ backgroundColor: 'blue' }}
/>
<div
data-uid='row-3-column-3'
data-testid='row-3-column-3'
style={{ backgroundColor: 'pink' }}
/>
</div>
</Storyboard>
)
`)
})
})
68 changes: 68 additions & 0 deletions editor/src/components/inspector/common/css-tree-utils.ts
Original file line number Diff line number Diff line change
@@ -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<csstree.CssNode> {
let list = new csstree.List<csstree.CssNode>()
nodes.forEach((node) => list.appendData(node))
return list
}

export function cssTreeNodeValue(children: csstree.List<csstree.CssNode>): 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<csstree.CssNode> {
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<csstree.CssNode> {
// 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<csstree.CssNode>()
for (let i = 0; i < times; i++) {
expandedValues.forEach((v) => result.appendData(v))
}

return result
}
24 changes: 24 additions & 0 deletions editor/src/components/inspector/common/css-utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
defaultCSSRadialGradientSize,
defaultCSSRadialOrConicGradientCenter,
disabledFunctionName,
expandRepeatFunctions,
parseBackgroundColor,
parseBackgroundImage,
parseBorderRadius,
Expand Down Expand Up @@ -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',
)
})
})
26 changes: 23 additions & 3 deletions editor/src/components/inspector/common/css-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RegExp | string>, flags?: string) {
let source: string = ''
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit 691037d

Please sign in to comment.