From 0ff29ca8635cda58cb3f013530f2bbd5c1455479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bertalan=20K=C3=B6rmendy?= Date: Wed, 9 Oct 2024 16:33:20 +0200 Subject: [PATCH] Update class list command (#6506) ## Problem In order to add/remove/update tailwind classes on an element, we need a way to edit the `className` property in a granular way. ## Constraints/considerations - we can't treat the className property as a string, because we want to edit the list of classnames in it - we can't treat the className property as an array of strings, because Tailwind classes have their own syntax (negative values, selectors) that we need to respect - not all classes in className property are parseable, Tailwind class name updates should leave the non-tailwind classes alone ## Fix This PR introduces `tailwind-class-list-utils`, which provides a toolkit to edit the Tailwind classes in the className property. On top of that, that PR adds a command that provides a high-level wrapper for `tailwind-class-list-utils` that the rest of the editor can use --- editor/package.json | 1 + editor/pnpm-lock.yaml | 15 ++ .../components/canvas/commands/commands.ts | 6 +- .../commands/update-class-list-command.ts | 108 ++++++++++ .../tailwind-class-list-utils.spec.ts | 194 ++++++++++++++++++ .../tailwind/tailwind-class-list-utils.ts | 106 ++++++++++ editor/src/core/tailwind/tailwind-options.tsx | 24 ++- 7 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 editor/src/components/canvas/commands/update-class-list-command.ts create mode 100644 editor/src/core/tailwind/tailwind-class-list-utils.spec.ts create mode 100644 editor/src/core/tailwind/tailwind-class-list-utils.ts diff --git a/editor/package.json b/editor/package.json index 1d4fa750356a..6e1b3110a7bd 100644 --- a/editor/package.json +++ b/editor/package.json @@ -171,6 +171,7 @@ "@types/w3c-css-typed-object-model-level-1": "20180410.0.5", "@use-it/interval": "0.1.3", "@vercel/stega": "0.1.0", + "@xengine/tailwindcss-class-parser": "1.1.17", "ajv": "6.4.0", "anser": "2.1.0", "antd": "4.3.5", diff --git a/editor/pnpm-lock.yaml b/editor/pnpm-lock.yaml index fe2bc6505a00..7117af5eb442 100644 --- a/editor/pnpm-lock.yaml +++ b/editor/pnpm-lock.yaml @@ -150,6 +150,7 @@ specifiers: '@vercel/stega': 0.1.0 '@vitejs/plugin-react': 4.0.4 '@welldone-software/why-did-you-render': 5.0.0-rc.1 + '@xengine/tailwindcss-class-parser': 1.1.17 ajv: 6.4.0 anser: 2.1.0 antd: 4.3.5 @@ -388,6 +389,7 @@ dependencies: '@types/w3c-css-typed-object-model-level-1': 20180410.0.5 '@use-it/interval': 0.1.3_react@18.1.0 '@vercel/stega': 0.1.0 + '@xengine/tailwindcss-class-parser': 1.1.17_tailwindcss@3.4.13 ajv: 6.4.0 anser: 2.1.0 antd: 4.3.5_ef5jwxihqo6n7gxfmzogljlgcm @@ -5998,6 +6000,15 @@ packages: react: 18.1.0_47cciibm4ysmleigs33s763fqu dev: true + /@xengine/tailwindcss-class-parser/1.1.17_tailwindcss@3.4.13: + resolution: {integrity: sha512-LRod5tEDSpr3T2I8R3U0bRH4O18MDfAdybxQRLSbcIta4AboQEeIUrNVsz6wRmJcsrBXtQYbwP4k9zsE+QnKAg==} + peerDependencies: + tailwindcss: '*' + dependencies: + colord: 2.9.3 + tailwindcss: 3.4.13 + dev: false + /@xstate/fsm/2.1.0: resolution: {integrity: sha512-oJlc0iD0qZvAM7If/KlyJyqUt7wVI8ocpsnlWzAPl97evguPbd+oJbRM9R4A1vYJffYH96+Bx44nLDE6qS8jQg==} dev: false @@ -7890,6 +7901,10 @@ packages: color-string: 1.9.1 dev: false + /colord/2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + dev: false + /colorette/2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} dev: true diff --git a/editor/src/components/canvas/commands/commands.ts b/editor/src/components/canvas/commands/commands.ts index 2e496effcfa0..cf7428ce5209 100644 --- a/editor/src/components/canvas/commands/commands.ts +++ b/editor/src/components/canvas/commands/commands.ts @@ -82,6 +82,8 @@ import { runShowGridControlsCommand, type ShowGridControlsCommand, } from './show-grid-controls-command' +import type { UpdateClassList } from './update-class-list-command' +import { runUpdateClassList } from './update-class-list-command' export interface CommandFunctionResult { editorStatePatches: Array @@ -135,6 +137,7 @@ export type CanvasCommand = | SetActiveFrames | UpdateBulkProperties | ShowGridControlsCommand + | UpdateClassList export function runCanvasCommand( editorState: EditorState, @@ -178,7 +181,6 @@ export function runCanvasCommand( return runPushIntendedBoundsAndUpdateGroups(editorState, command, commandLifecycle) case 'PUSH_INTENDED_BOUNDS_AND_UPDATE_HUGGING_ELEMENTS': return runPushIntendedBoundsAndUpdateHuggingElements(editorState, command) - case 'DELETE_PROPERTIES': return runDeleteProperties(editorState, command) case 'SET_PROPERTY': @@ -219,6 +221,8 @@ export function runCanvasCommand( return runSetActiveFrames(editorState, command) case 'SHOW_GRID_CONTROLS': return runShowGridControlsCommand(editorState, command) + case 'UPDATE_CLASS_LIST': + return runUpdateClassList(editorState, command) default: const _exhaustiveCheck: never = command throw new Error(`Unhandled canvas command ${JSON.stringify(command)}`) diff --git a/editor/src/components/canvas/commands/update-class-list-command.ts b/editor/src/components/canvas/commands/update-class-list-command.ts new file mode 100644 index 000000000000..65e7781dcb9e --- /dev/null +++ b/editor/src/components/canvas/commands/update-class-list-command.ts @@ -0,0 +1,108 @@ +import { mapDropNulls } from '../../../core/shared/array-utils' +import * as EP from '../../../core/shared/element-path' +import { emptyComments, jsExpressionValue } from '../../../core/shared/element-template' +import type { ElementPath } from '../../../core/shared/project-file-types' +import * as PP from '../../../core/shared/property-path' +import type { PropertiesToUpdate } from '../../../core/tailwind/tailwind-class-list-utils' +import { + addNewClasses, + getClassListFromParsedClassList, + getParsedClassList, + removeClasses, + updateExistingClasses, +} from '../../../core/tailwind/tailwind-class-list-utils' +import { getTailwindConfigCached } from '../../../core/tailwind/tailwind-compilation' +import { getClassNameAttribute } from '../../../core/tailwind/tailwind-options' +import { getElementFromProjectContents, type EditorState } from '../../editor/store/editor-state' +import { applyValuesAtPath } from './adjust-number-command' +import type { BaseCommand, CommandFunction, WhenToRun } from './commands' + +export interface UpdateClassList extends BaseCommand { + type: 'UPDATE_CLASS_LIST' + element: ElementPath + classNameUpdates: ClassListUpdate[] +} + +export type ClassListUpdate = + | { type: 'add'; property: string; value: string } + | { type: 'remove'; property: string } + +export const add = ({ property, value }: { property: string; value: string }): ClassListUpdate => ({ + type: 'add', + property: property, + value: value, +}) +export const remove = (property: string): ClassListUpdate => ({ + type: 'remove', + property: property, +}) + +export function updateClassListCommand( + whenToRun: WhenToRun, + element: ElementPath, + classNameUpdates: ClassListUpdate[], +): UpdateClassList { + return { + type: 'UPDATE_CLASS_LIST', + whenToRun: whenToRun, + element: element, + classNameUpdates: classNameUpdates, + } +} + +export const runUpdateClassList: CommandFunction = ( + editorState: EditorState, + command: UpdateClassList, +) => { + const { element, classNameUpdates } = command + + const currentClassNameAttribute = getClassNameAttribute( + getElementFromProjectContents(element, editorState.projectContents), + )?.value + + if (currentClassNameAttribute == null) { + return { + editorStatePatches: [], + commandDescription: `Update class list for ${EP.toUid(element)} with ${classNameUpdates}`, + } + } + + const parsedClassList = getParsedClassList( + currentClassNameAttribute, + getTailwindConfigCached(editorState), + ) + + const propertiesToRemove = mapDropNulls( + (update) => (update.type !== 'remove' ? null : update.property), + classNameUpdates, + ) + + const propertiesToUpdate: PropertiesToUpdate = classNameUpdates.reduce( + (acc: { [property: string]: string }, val) => + val.type === 'remove' ? acc : { ...acc, [val.property]: val.value }, + {}, + ) + + const updatedClassList = [ + removeClasses(propertiesToRemove), + updateExistingClasses(propertiesToUpdate), + addNewClasses(propertiesToUpdate), + ].reduce((classList, fn) => fn(classList), parsedClassList) + + const newClassList = getClassListFromParsedClassList( + updatedClassList, + getTailwindConfigCached(editorState), + ) + + const { editorStatePatch } = applyValuesAtPath(editorState, element, [ + { + path: PP.create('className'), + value: jsExpressionValue(newClassList, emptyComments), + }, + ]) + + return { + editorStatePatches: [editorStatePatch], + commandDescription: `Update class list for ${EP.toUid(element)} to ${newClassList}`, + } +} diff --git a/editor/src/core/tailwind/tailwind-class-list-utils.spec.ts b/editor/src/core/tailwind/tailwind-class-list-utils.spec.ts new file mode 100644 index 000000000000..ca8816febed2 --- /dev/null +++ b/editor/src/core/tailwind/tailwind-class-list-utils.spec.ts @@ -0,0 +1,194 @@ +import type { TailwindClassParserResult } from './tailwind-class-list-utils' +import { + addNewClasses, + getClassListFromParsedClassList, + getParsedClassList, + removeClasses, + updateExistingClasses, +} from './tailwind-class-list-utils' + +describe('tailwind class list utils', () => { + describe('class list parsing / writing', () => { + function classNameAstSlice(ast: TailwindClassParserResult) { + switch (ast.type) { + case 'unparsed': + return ast + case 'parsed': + const slice: Record = { + type: ast.type, + property: ast.ast.property, + value: ast.ast.value, + } + if (ast.ast.negative) { + slice.negative = true + } + return slice + } + } + it('can parse the class list', () => { + const classList = getParsedClassList( + 'p-4 m-4 lg:w-4 hover:text-red-100 -top-4 flex flex-row fancyButton', + null, + ) + expect(classList.map(classNameAstSlice)).toMatchInlineSnapshot(` + Array [ + Object { + "property": "padding", + "type": "parsed", + "value": "1rem", + }, + Object { + "property": "margin", + "type": "parsed", + "value": "1rem", + }, + Object { + "property": "width", + "type": "parsed", + "value": "1rem", + }, + Object { + "property": "textColor", + "type": "parsed", + "value": "#fee2e2", + }, + Object { + "negative": true, + "property": "positionTop", + "type": "parsed", + "value": "1rem", + }, + Object { + "property": "display", + "type": "parsed", + "value": "flex", + }, + Object { + "property": "flexDirection", + "type": "parsed", + "value": "row", + }, + Object { + "className": "fancyButton", + "type": "unparsed", + }, + ] + `) + }) + + it('respects the tailwind config when parsing the class list', () => { + const classList = getParsedClassList('flex gap-huge', { + content: [], + theme: { extend: { gap: { huge: '123px' } } }, + }) + expect(classList.map(classNameAstSlice)).toMatchInlineSnapshot(` + Array [ + Object { + "property": "display", + "type": "parsed", + "value": "flex", + }, + Object { + "property": "gap", + "type": "parsed", + "value": "123px", + }, + ] + `) + }) + + it('can stringify class list', () => { + const classList = getClassListFromParsedClassList( + [ + { + type: 'parsed', + ast: { property: 'padding', value: '2rem', variants: [], negative: false }, + }, + { + type: 'parsed', + ast: { property: 'positionTop', value: '-14px', variants: [], negative: false }, + }, + { + type: 'parsed', + ast: { property: 'display', value: 'flex', variants: [], negative: false }, + }, + { + type: 'parsed', + ast: { property: 'gap', value: '123px', variants: [], negative: false }, + }, + { type: 'unparsed', className: 'highlight-button' }, + ], + { + content: [], + theme: { extend: { gap: { huge: '123px' } } }, + }, + ) + + expect(classList).toMatchInlineSnapshot(`"p-8 -top-[14px] flex gap-huge highlight-button"`) + }) + + it('stringifying a parsed class list yields the same class list string', () => { + const startingClassList = + 'p-4 m-2 w-4 lg:w-8 text-black hover:text-red-200 flex flex-row fancy-button' + const classList = getClassListFromParsedClassList( + getParsedClassList(startingClassList, null), + null, + ) + expect(classList).toEqual(startingClassList) + }) + }) + + describe('removing classes', () => { + it('can remove property', () => { + const classList = getParsedClassList('p-4 m-2 text-white w-4 flex flex-row', null) + const updatedClassList = removeClasses(['padding', 'textColor'])(classList) + expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( + `"m-2 w-4 flex flex-row"`, + ) + }) + it('does not remove property with selector', () => { + const classList = getParsedClassList( + 'p-4 m-2 text-white hover:text-red-100 w-4 flex flex-row', + null, + ) + const updatedClassList = removeClasses(['padding', 'textColor'])(classList) + expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( + `"m-2 hover:text-red-100 w-4 flex flex-row"`, + ) + }) + }) + + describe('updating classes', () => { + it('can update class in class list', () => { + const classList = getParsedClassList('p-4 m-2 text-white w-4 flex flex-row', null) + const updatedClassList = updateExistingClasses({ + flexDirection: 'column', + width: '23px', + })(classList) + expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( + `"p-4 m-2 text-white w-[23px] flex flex-col"`, + ) + }) + it('does not remove property with selector', () => { + const classList = getParsedClassList('p-4 hover:p-6 m-2 text-white w-4 flex flex-row', null) + const updatedClassList = updateExistingClasses({ padding: '8rem' })(classList) + expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( + `"p-32 hover:p-6 m-2 text-white w-4 flex flex-row"`, + ) + }) + }) + + describe('adding new classes', () => { + it('can add new class to class list', () => { + const classList = getParsedClassList('p-4 m-2 text-white w-4 flex flex-row', null) + const updatedClassList = addNewClasses({ + backgroundColor: 'white', + justifyContent: 'space-between', + positionLeft: '-20px', + })(classList) + expect(getClassListFromParsedClassList(updatedClassList, null)).toMatchInlineSnapshot( + `"p-4 m-2 text-white w-4 flex flex-row bg-white justify-between -left-[20px]"`, + ) + }) + }) +}) diff --git a/editor/src/core/tailwind/tailwind-class-list-utils.ts b/editor/src/core/tailwind/tailwind-class-list-utils.ts new file mode 100644 index 000000000000..e2b0bd63b825 --- /dev/null +++ b/editor/src/core/tailwind/tailwind-class-list-utils.ts @@ -0,0 +1,106 @@ +import * as TailwindClassParser from '@xengine/tailwindcss-class-parser' +import type { Config } from 'tailwindcss/types/config' +import { mapDropNulls } from '../shared/array-utils' + +export type ParsedTailwindClass = { + property: string + value: string + variants: unknown[] + negative: boolean +} & Record + +export type TailwindClassParserResult = + | { type: 'unparsed'; className: string } + | { type: 'parsed'; ast: ParsedTailwindClass } + +export function getParsedClassList( + classList: string, + config: Config | null, +): TailwindClassParserResult[] { + return classList.split(' ').map((c) => { + const result = TailwindClassParser.parse(c, config ?? undefined) + if (result.kind === 'error') { + return { type: 'unparsed', className: c } + } + return { type: 'parsed', ast: result } + }) +} + +export function getClassListFromParsedClassList( + parsedClassList: TailwindClassParserResult[], + config: Config | null, +): string { + return parsedClassList + .map((c) => { + if (c.type === 'unparsed') { + return c.className + } + return TailwindClassParser.classname( + c.ast as any, // FIXME the types are not exported from @xengine/tailwindcss-class-parser + config ?? undefined, + ) + }) + .join(' ') +} + +export type ClassListTransform = ( + parsedClassList: TailwindClassParserResult[], +) => TailwindClassParserResult[] + +export interface PropertiesToUpdate { + [property: string]: string +} + +export const addNewClasses = + (propertiesToAdd: PropertiesToUpdate): ClassListTransform => + (parsedClassList: TailwindClassParserResult[]) => { + const existingProperties = new Set( + mapDropNulls((cls) => (cls.type !== 'parsed' ? null : cls.ast.property), parsedClassList), + ) + + const newClasses: TailwindClassParserResult[] = mapDropNulls( + ([prop, value]) => + existingProperties.has(prop) + ? null + : { + type: 'parsed', + ast: { property: prop, value: value, variants: [], negative: false }, + }, + Object.entries(propertiesToAdd), + ) + + const classListWithNewClasses = [...parsedClassList, ...newClasses] + return classListWithNewClasses + } + +export const updateExistingClasses = + (propertiesToUpdate: PropertiesToUpdate): ClassListTransform => + (parsedClassList: TailwindClassParserResult[]) => { + const classListWithUpdatedClasses: TailwindClassParserResult[] = parsedClassList.map((cls) => { + if (cls.type !== 'parsed' || cls.ast.variants.length > 0) { + return cls + } + const updatedProperty = propertiesToUpdate[cls.ast.property] + if (updatedProperty == null) { + return cls + } + return { + type: 'parsed', + ast: { property: cls.ast.property, value: updatedProperty, variants: [], negative: false }, + } + }) + return classListWithUpdatedClasses + } + +export const removeClasses = + (propertiesToRemove: string[]): ClassListTransform => + (parsedClassList: TailwindClassParserResult[]) => { + const propertiesToRemoveSet = new Set(propertiesToRemove) + const classListWithRemovedClasses = parsedClassList.filter((cls) => { + if (cls.type !== 'parsed' || cls.ast.variants.length > 0) { + return cls + } + return !propertiesToRemoveSet.has(cls.ast.property) + }) + return classListWithRemovedClasses + } diff --git a/editor/src/core/tailwind/tailwind-options.tsx b/editor/src/core/tailwind/tailwind-options.tsx index 93710dce45f0..e7f60785b475 100644 --- a/editor/src/core/tailwind/tailwind-options.tsx +++ b/editor/src/core/tailwind/tailwind-options.tsx @@ -222,8 +222,8 @@ export function useFilteredOptions( }, [filter, maxResults, onEmptyResults]) } -function getClassNameAttribute(element: JSXElementChild | null): { - value: string | null +function getClassNameJSXAttribute(element: JSXElementChild | null): { + value: any | null isSettable: boolean } { if (element != null && isJSXElement(element)) { @@ -248,6 +248,26 @@ function getClassNameAttribute(element: JSXElementChild | null): { } } +export function getClassNameAttribute(element: JSXElementChild | null): { + value: string | null + isSettable: boolean +} { + const classNameJSXAttribute = getClassNameJSXAttribute(element) + if ( + classNameJSXAttribute.value == null || + // The type check here is necessary because in
the value + // of `className` would be `true` + typeof classNameJSXAttribute.value !== 'string' + ) { + return { + value: null, + isSettable: classNameJSXAttribute.isSettable, + } + } + + return classNameJSXAttribute +} + export function useGetSelectedClasses(): { selectedClasses: Array elementPaths: Array