diff --git a/apps/builder/app/builder/features/navigator/css-preview.tsx b/apps/builder/app/builder/features/navigator/css-preview.tsx index d1afaf3cfff7..023e5ab41b7e 100644 --- a/apps/builder/app/builder/features/navigator/css-preview.tsx +++ b/apps/builder/app/builder/features/navigator/css-preview.tsx @@ -65,7 +65,7 @@ const getCssText = ( return; } result.push(`/* ${comment} */`); - result.push(generateStyleMap({ style: mergeStyles(style) })); + result.push(generateStyleMap(mergeStyles(style))); }; add("Style Sources", sourceStyles); diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/add-styles-input.tsx b/apps/builder/app/builder/features/style-panel/sections/advanced/add-style-input.tsx similarity index 81% rename from apps/builder/app/builder/features/style-panel/sections/advanced/add-styles-input.tsx rename to apps/builder/app/builder/features/style-panel/sections/advanced/add-style-input.tsx index c057a88f038f..e97fc858bfba 100644 --- a/apps/builder/app/builder/features/style-panel/sections/advanced/add-styles-input.tsx +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/add-style-input.tsx @@ -1,4 +1,3 @@ -import { lexer } from "css-tree"; import { forwardRef, useRef, useState, type KeyboardEvent } from "react"; import { matchSorter } from "match-sorter"; import { @@ -24,11 +23,14 @@ import { } from "@webstudio-is/css-data"; import { cssWideKeywords, + generateStyleMap, hyphenateProperty, + toValue, type StyleProperty, } from "@webstudio-is/css-engine"; import { deleteProperty, setProperty } from "../../shared/use-style-data"; import { composeEventHandlers } from "~/shared/event-utils"; +import { parseStyleInput } from "./parse-style-input"; type SearchItem = { property: string; label: string; value?: string }; @@ -88,25 +90,24 @@ const matchOrSuggestToCreate = ( // Limit the array to 100 elements matched.length = Math.min(matched.length, 100); - const property = search.trim(); - if ( - property.startsWith("--") && - lexer.match("", property).matched - ) { - matched.unshift({ - property, - label: `Create "${property}"`, - }); - } - // When there is no match we suggest to create a custom property. - if ( - matched.length === 0 && - lexer.match("", `--${property}`).matched - ) { - matched.unshift({ - property: `--${property}`, - label: `--${property}: unset;`, - }); + if (matched.length === 0) { + const parsedStyles = parseStyleInput(search); + // When parsedStyles is more than one, user entered a shorthand. + // We will suggest to insert their shorthand first. + if (parsedStyles.length > 1) { + matched.push({ + property: search, + label: `Create "${search}"`, + }); + } + // Now we will suggest to insert each longhand separately. + for (const style of parsedStyles) { + matched.push({ + property: style.property, + value: toValue(style.value), + label: `Create "${generateStyleMap(new Map([[style.property, style.value]]))}"`, + }); + } } return matched; @@ -122,7 +123,7 @@ const matchOrSuggestToCreate = ( * paste css declarations * */ -export const AddStylesInput = forwardRef< +export const AddStyleInput = forwardRef< HTMLInputElement, { onClose: () => void; @@ -147,7 +148,14 @@ export const AddStylesInput = forwardRef< onChange: (value) => setItem({ property: value ?? "", label: value ?? "" }), onItemSelect: (item) => { clear(); - onSubmit(`${item.property}: ${item.value ?? "unset"}`); + // When there is no value, property can be: + // - property without value: gap + // - declaration with value: gap: 10px + // - block: gap: 10px; margin: 20px; + if (item.value === undefined) { + return onSubmit(item.property); + } + onSubmit(`${item.property}: ${item.value}`); }, onItemHighlight: (item) => { const previousHighlightedItem = highlightedItemRef.current; @@ -207,6 +215,17 @@ export const AddStylesInput = forwardRef< handleDelete, ]); + const handleBlur = composeEventHandlers([ + inputProps.onBlur, + () => { + // When user clicks on a combobox item, input will receive blur event, + // but we don't want that to be handled upstream because input may get hidden without click getting handled. + if (combobox.isOpen === false) { + onBlur(); + } + }, + ]); + return (
@@ -215,10 +234,7 @@ export const AddStylesInput = forwardRef< {...inputProps} autoFocus onFocus={onFocus} - onBlur={(event) => { - inputProps.onBlur(event); - onBlur(); - }} + onBlur={handleBlur} inputRef={forwardedRef} onKeyDown={handleKeyDown} placeholder="Add styles" diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/advanced.tsx b/apps/builder/app/builder/features/style-panel/sections/advanced/advanced.tsx index d64ba9a95bf1..ca01dc6b7e72 100644 --- a/apps/builder/app/builder/features/style-panel/sections/advanced/advanced.tsx +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/advanced.tsx @@ -26,7 +26,7 @@ import { theme, Tooltip, } from "@webstudio-is/design-system"; -import { parseCss, propertyDescriptions } from "@webstudio-is/css-data"; +import { propertyDescriptions } from "@webstudio-is/css-data"; import { hyphenateProperty, toValue, @@ -55,7 +55,8 @@ import { useClientSupports } from "~/shared/client-supports"; import { CopyPasteMenu, propertyContainerAttribute } from "./copy-paste-menu"; import { $advancedStyles } from "./stores"; import { $settings } from "~/builder/shared/client-settings"; -import { AddStylesInput } from "./add-styles-input"; +import { AddStyleInput } from "./add-style-input"; +import { parseStyleInput } from "./parse-style-input"; // Only here to keep the same section module interface export const properties = []; @@ -97,13 +98,8 @@ const AdvancedStyleSection = (props: { ); }; -const insertStyles = (text: string) => { - let parsedStyles = parseCss(`selector{${text}}`); - if (parsedStyles.length === 0) { - // Try a single property without a value. - parsedStyles = parseCss(`selector{${text}: unset}`); - } - +const insertStyles = (css: string) => { + const parsedStyles = parseStyleInput(css); if (parsedStyles.length === 0) { return []; } @@ -386,9 +382,10 @@ export const Section = () => { setRecentProperties( Array.from(new Set([...recentProperties, ...insertedProperties])) ); + return styles; }; - const handleShowAddStylesInput = () => { + const handleShowAddStyleInput = () => { setIsAdding(true); // User can click twice on the add button, so we need to focus the input on the second click after autoFocus isn't working. addPropertyInputRef.current?.focus(); @@ -434,7 +431,7 @@ export const Section = () => { { autoFocus={isLast} onChangeComplete={(event) => { if (event.type === "enter") { - handleShowAddStylesInput(); + handleShowAddStyleInput(); } }} onReset={() => { @@ -482,15 +479,17 @@ export const Section = () => { { overflow: "hidden", height: 0 } } > - { - setIsAdding(false); - handleInsertStyles(cssText); + const styles = handleInsertStyles(cssText); + if (styles.length > 0) { + setIsAdding(false); + } }} onClose={handleAbortAddStyles} onFocus={() => { if (isAdding === false) { - handleShowAddStylesInput(); + handleShowAddStyleInput(); } }} onBlur={() => { diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/copy-paste-menu.tsx b/apps/builder/app/builder/features/style-panel/sections/advanced/copy-paste-menu.tsx index 99bf799797b6..3c27bfd521ff 100644 --- a/apps/builder/app/builder/features/style-panel/sections/advanced/copy-paste-menu.tsx +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/copy-paste-menu.tsx @@ -45,7 +45,7 @@ export const CopyPasteMenu = ({ } } - const css = generateStyleMap({ style: mergeStyles(currentStyleMap) }); + const css = generateStyleMap(mergeStyles(currentStyleMap)); navigator.clipboard.writeText(css); }; @@ -59,7 +59,7 @@ export const CopyPasteMenu = ({ return; } const style = new Map([[property, value]]); - const css = generateStyleMap({ style }); + const css = generateStyleMap(style); navigator.clipboard.writeText(css); }; diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/parse-style-input.test.ts b/apps/builder/app/builder/features/style-panel/sections/advanced/parse-style-input.test.ts new file mode 100644 index 000000000000..075f9e8a3a06 --- /dev/null +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/parse-style-input.test.ts @@ -0,0 +1,118 @@ +import { describe, test, expect } from "vitest"; +import { parseStyleInput } from "./parse-style-input"; + +describe("parseStyleInput", () => { + test("parses custom property", () => { + const result = parseStyleInput("--custom-color"); + expect(result).toEqual([ + { + selector: "selector", + property: "--custom-color", + value: { type: "unset", value: "" }, + }, + ]); + }); + + test("parses regular property", () => { + const result = parseStyleInput("color"); + expect(result).toEqual([ + { + selector: "selector", + property: "color", + value: { type: "unset", value: "" }, + }, + ]); + }); + + test("trims whitespace", () => { + const result = parseStyleInput(" color "); + expect(result).toEqual([ + { + selector: "selector", + property: "color", + value: { type: "unset", value: "" }, + }, + ]); + }); + + test("handles unparsable regular property", () => { + const result = parseStyleInput("notapro perty"); + expect(result).toEqual([]); + }); + + test("converts unknown property to custom property assuming user forgot to add --", () => { + const result = parseStyleInput("notaproperty"); + expect(result).toEqual([ + { + selector: "selector", + property: "--notaproperty", + value: { type: "unset", value: "" }, + }, + ]); + }); + + test("parses single property-value pair", () => { + const result = parseStyleInput("color: red"); + expect(result).toEqual([ + { + selector: "selector", + property: "color", + value: { type: "keyword", value: "red" }, + }, + ]); + }); + + test("parses multiple property-value pairs", () => { + const result = parseStyleInput("color: red; display: block"); + expect(result).toEqual([ + { + selector: "selector", + property: "color", + value: { type: "keyword", value: "red" }, + }, + { + selector: "selector", + property: "display", + value: { type: "keyword", value: "block" }, + }, + ]); + }); + + test("parses multiple property-value pairs, one is invalid", () => { + const result = parseStyleInput("color: red; somethinginvalid: block"); + expect(result).toEqual([ + { + selector: "selector", + property: "color", + value: { type: "keyword", value: "red" }, + }, + { + selector: "selector", + property: "--somethinginvalid", + value: { type: "unparsed", value: "block" }, + }, + ]); + }); + + test("parses custom property with value", () => { + const result = parseStyleInput("--custom-color: red"); + expect(result).toEqual([ + { + selector: "selector", + property: "--custom-color", + value: { type: "unparsed", value: "red" }, + }, + ]); + }); + + test("handles malformed style block", () => { + const result = parseStyleInput("color: red; invalid;"); + expect(result).toEqual([ + { + selector: "selector", + property: "color", + value: { type: "keyword", value: "red" }, + }, + ]); + }); +}); diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/parse-style-input.ts b/apps/builder/app/builder/features/style-panel/sections/advanced/parse-style-input.ts new file mode 100644 index 000000000000..01cabcdcf54a --- /dev/null +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/parse-style-input.ts @@ -0,0 +1,71 @@ +import { + properties, + parseCss, + type ParsedStyleDecl, +} from "@webstudio-is/css-data"; +import { type StyleProperty } from "@webstudio-is/css-engine"; +import { camelCase } from "change-case"; +import { lexer } from "css-tree"; + +/** + * Does several attempts to parse: + * - Custom property "--foo" + * - Known regular property "color" + * - Custom property without -- (user forgot to add) + * - Custom property and value: --foo: red + * - Property and value: color: red + * - Multiple properties: color: red; background: blue + */ +export const parseStyleInput = (css: string): Array => { + css = css.trim(); + // Is it a custom property "--foo"? + if (css.startsWith("--") && lexer.match("", css).matched) { + return [ + { + selector: "selector", + property: css as StyleProperty, + value: { type: "unset", value: "" }, + }, + ]; + } + + // Is it a known regular property? + const camelCasedProperty = camelCase(css); + if (camelCasedProperty in properties) { + return [ + { + selector: "selector", + property: css as StyleProperty, + value: { type: "unset", value: "" }, + }, + ]; + } + + // Is it a custom property "--foo"? + if (lexer.match("", `--${css}`).matched) { + return [ + { + selector: "selector", + property: `--${css}`, + value: { type: "unset", value: "" }, + }, + ]; + } + + const styles = parseCss(`selector{${css}}`); + + for (const style of styles) { + // somethingunknown: red; -> --somethingunknown: red; + if ( + // Note: currently in tests it returns unparsed, but in the client it returns invalid, + // because we use native APIs when available in parseCss. + style.value.type === "invalid" || + (style.value.type === "unparsed" && + style.property.startsWith("--") === false) + ) { + style.property = `--${style.property}`; + } + } + + return styles; +}; diff --git a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx index cd9b120c8a67..1d4805b86f80 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx @@ -820,7 +820,7 @@ export const CssValueInput = ({ // - allows to close the menu // - prevents baspace from deleting the value AFTER its already reseted to default, e.g. we get "aut" instead of "auto" event.preventDefault(); - //closeMenu(); + closeMenu(); onReset(); } }; diff --git a/packages/css-engine/src/core/rules.ts b/packages/css-engine/src/core/rules.ts index 93e938324914..06143315476b 100644 --- a/packages/css-engine/src/core/rules.ts +++ b/packages/css-engine/src/core/rules.ts @@ -52,15 +52,16 @@ const mergeDeclarations = (declarations: Iterable) => { export type StyleMap = Map; -export const generateStyleMap = ({ - style, - indent = 0, - transformValue, -}: { - style: StyleMap; - indent?: number; - transformValue?: TransformValue; -}) => { +export const generateStyleMap = ( + style: StyleMap, + { + indent = 0, + transformValue, + }: { + indent?: number; + transformValue?: TransformValue; + } = {} +) => { const spaces = " ".repeat(indent); let lines = ""; for (const [property, value] of style) { @@ -269,8 +270,7 @@ export class NestingRule { leftSelector.localeCompare(rightSelector) ) .map(([selector, style]) => { - const content = generateStyleMap({ - style: prefixStyles(style), + const content = generateStyleMap(prefixStyles(style), { indent: indent + 2, transformValue, });