diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/add-style-input.tsx b/apps/builder/app/builder/features/style-panel/sections/advanced/add-style-input.tsx index e97fc858bfba..276eb2e881ec 100644 --- a/apps/builder/app/builder/features/style-panel/sections/advanced/add-style-input.tsx +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/add-style-input.tsx @@ -17,6 +17,7 @@ import { } from "@webstudio-is/design-system"; import { properties as propertiesData, + shorthandProperties, keywordValues, propertyDescriptions, parseCssValue, @@ -25,6 +26,7 @@ import { cssWideKeywords, generateStyleMap, hyphenateProperty, + mergeStyles, toValue, type StyleProperty, } from "@webstudio-is/css-engine"; @@ -49,9 +51,17 @@ const getAutocompleteItems = () => { return autoCompleteItems; } for (const property in propertiesData) { + const hyphenatedProperty = hyphenateProperty(property); + autoCompleteItems.push({ + property: hyphenatedProperty, + label: hyphenatedProperty, + }); + } + + for (const property of shorthandProperties) { autoCompleteItems.push({ property, - label: hyphenateProperty(property), + label: property, }); } @@ -63,10 +73,11 @@ const getAutocompleteItems = () => { if (ignoreValues.has(value)) { continue; } + const hyphenatedProperty = hyphenateProperty(property); autoCompleteItems.push({ - property, + property: hyphenatedProperty, value, - label: `${hyphenateProperty(property)}: ${value}`, + label: `${hyphenatedProperty}: ${value}`, }); } } @@ -91,21 +102,23 @@ const matchOrSuggestToCreate = ( matched.length = Math.min(matched.length, 100); if (matched.length === 0) { - const parsedStyles = parseStyleInput(search); + const parsedStyleMap = parseStyleInput(search); + const styleMap = mergeStyles(parsedStyleMap); + // When parsedStyles is more than one, user entered a shorthand. // We will suggest to insert their shorthand first. - if (parsedStyles.length > 1) { + if (styleMap.size > 1) { matched.push({ property: search, label: `Create "${search}"`, }); } // Now we will suggest to insert each longhand separately. - for (const style of parsedStyles) { + for (const [property, value] of styleMap) { matched.push({ - property: style.property, - value: toValue(style.value), - label: `Create "${generateStyleMap(new Map([[style.property, style.value]]))}"`, + property, + value: toValue(value), + label: `Create "${generateStyleMap(new Map([[property, value]]))}"`, }); } } @@ -114,13 +127,12 @@ const matchOrSuggestToCreate = ( }; /** - * * Advanced search control supports following interactions * - * find property - * create custom property - * submit css declarations - * paste css declarations + * - find property + * - create custom property + * - submit css declarations + * - paste css declarations * */ export const AddStyleInput = forwardRef< @@ -232,7 +244,6 @@ export const AddStyleInput = forwardRef< ; onAdd: () => void; children: ReactNode; }) => { const { label, children, properties, onAdd } = props; const [isOpen, setIsOpen] = useOpenState(label); - const styles = useComputedStyles(properties); + const styles = useComputedStyles(properties.map(camelCaseProperty)); return ( { - const parsedStyles = parseStyleInput(css); - if (parsedStyles.length === 0) { - return []; + const styleMap = parseStyleInput(css); + if (styleMap.size === 0) { + return new Map(); } const batch = createBatchUpdate(); - for (const { property, value } of parsedStyles) { - batch.setProperty(property)(value); + for (const [property, value] of styleMap) { + batch.setProperty(camelCaseProperty(property as CssProperty))(value); } batch.publish({ listed: true }); - return parsedStyles; + return styleMap; }; // Used to indent the values when they are on the next line. This way its easier to see // where the property ends and value begins, especially in case of presets. -const indentation = `20px`; +const initialIndentation = `20px`; const AdvancedPropertyLabel = ({ property, onReset, }: { - property: StyleProperty; + property: CssProperty; onReset?: () => void; }) => { - const styleDecl = useComputedStyleDecl(property); + const camelCasedProperty = camelCaseProperty(property); + const styleDecl = useComputedStyleDecl(camelCasedProperty); const label = hyphenateProperty(property); const description = propertyDescriptions[property]; const [isOpen, setIsOpen] = useState(false); @@ -137,7 +142,7 @@ const AdvancedPropertyLabel = ({ onClick: (event) => { if (event.altKey) { event.preventDefault(); - deleteProperty(property); + deleteProperty(camelCasedProperty); onReset?.(); return; } @@ -150,7 +155,7 @@ const AdvancedPropertyLabel = ({ description={description} styles={[styleDecl]} onReset={() => { - deleteProperty(property); + deleteProperty(camelCasedProperty); setIsOpen(false); onReset?.(); }} @@ -163,7 +168,7 @@ const AdvancedPropertyLabel = ({ text="mono" css={{ backgroundColor: "transparent", - marginLeft: `-${indentation}`, + marginLeft: `-${initialIndentation}`, }} > {label} @@ -173,29 +178,24 @@ const AdvancedPropertyLabel = ({ }; const AdvancedPropertyValue = ({ - autoFocus, property, onChangeComplete, onReset, inputRef: inputRefProp, }: { - autoFocus?: boolean; - property: StyleProperty; + property: CssProperty; onChangeComplete: ComponentProps< typeof CssValueInputContainer >["onChangeComplete"]; onReset: ComponentProps["onReset"]; inputRef?: RefObject; }) => { - const styleDecl = useComputedStyleDecl(property); + // @todo conversion should be removed once data is in dash case + const camelCasedProperty = camelCaseProperty(property); + const styleDecl = useComputedStyleDecl(camelCasedProperty); const inputRef = useRef(null); - useEffect(() => { - if (autoFocus) { - inputRef.current?.focus(); - inputRef.current?.select(); - } - }, [autoFocus]); const isColor = colord(toValue(styleDecl.usedValue)).isValid(); + return ( { const options = { isEphemeral: true, listed: true }; if (styleValue) { - setProperty(property)(styleValue, options); + setProperty(camelCasedProperty)(styleValue, options); } else { - deleteProperty(property, options); + deleteProperty(camelCasedProperty, options); } }} onChangeComplete={(styleValue) => { - setProperty(property)(styleValue); + setProperty(camelCasedProperty)(styleValue); }} /> ) } - property={property} + property={camelCasedProperty} styleSource={styleDecl.source.name} getOptions={() => [ - ...styleConfigByName(property).items.map((item) => ({ + ...styleConfigByName(camelCasedProperty).items.map((item) => ({ type: "keyword" as const, value: item.name, })), @@ -235,12 +235,15 @@ const AdvancedPropertyValue = ({ styleValue.type === "keyword" && styleValue.value.startsWith("--") ) { - setProperty(property)( + setProperty(camelCasedProperty)( { type: "var", value: styleValue.value.slice(2) }, { ...options, listed: true } ); } else { - setProperty(property)(styleValue, { ...options, listed: true }); + setProperty(camelCasedProperty)(styleValue, { + ...options, + listed: true, + }); } }} deleteProperty={deleteProperty} @@ -255,98 +258,100 @@ const AdvancedPropertyValue = ({ * To fix this, we skip rendering properties not visible in the viewport using the contentvisibilityautostatechange event, * and the contentVisibility and containIntrinsicSize CSS properties. */ -const AdvancedProperty = memo( +const LazyRender = ({ children }: ComponentProps<"div">) => { + const visibilityChangeEventSupported = useClientSupports( + () => "oncontentvisibilityautostatechange" in document.body + ); + const ref = useRef(null); + const [isVisible, setIsVisible] = useState(!visibilityChangeEventSupported); + + useEffect(() => { + if (!visibilityChangeEventSupported) { + return; + } + + if (ref.current == null) { + return; + } + + const controller = new AbortController(); + + ref.current.addEventListener( + "contentvisibilityautostatechange", + (event) => { + setIsVisible(!event.skipped); + }, + { + signal: controller.signal, + } + ); + + return () => { + controller.abort(); + }; + }, [visibilityChangeEventSupported]); + + return ( +
+ {isVisible ? children : undefined} +
+ ); +}; + +const AdvancedDeclarationLonghand = memo( ({ property, - autoFocus, onChangeComplete, onReset, valueInputRef, + indentation = initialIndentation, }: { - property: StyleProperty; - autoFocus?: boolean; + property: CssProperty; + indentation?: string; onReset?: () => void; onChangeComplete?: ComponentProps< typeof CssValueInputContainer >["onChangeComplete"]; valueInputRef?: RefObject; }) => { - const visibilityChangeEventSupported = useClientSupports( - () => "oncontentvisibilityautostatechange" in document.body - ); - const ref = useRef(null); - const [isVisible, setIsVisible] = useState(!visibilityChangeEventSupported); - - useEffect(() => { - if (!visibilityChangeEventSupported) { - return; - } - - if (ref.current == null) { - return; - } - - const controller = new AbortController(); - - ref.current.addEventListener( - "contentvisibilityautostatechange", - (event) => { - setIsVisible(!event.skipped); - }, - { - signal: controller.signal, - } - ); - - return () => { - controller.abort(); - }; - }, [visibilityChangeEventSupported]); - return ( - {isVisible && ( - <> - - - : - - - - - - )} + + + : + + ); } @@ -354,31 +359,35 @@ const AdvancedProperty = memo( export const Section = () => { const [isAdding, setIsAdding] = useState(false); - const advancedStyles = useStore($advancedStyles); + const advancedStyles = useStore($advancedStylesLonghands); const selectedInstanceKey = useStore($selectedInstanceKey); - // Memorizing recent properties by instance, so that when user switches between instances and comes back + // Memorizing recent properties by instance id, so that when user switches between instances and comes back // they are still in-place const [recentPropertiesMap, setRecentPropertiesMap] = useState< - Map> + Map> >(new Map()); const addPropertyInputRef = useRef(null); const recentValueInputRef = useRef(null); const searchInputRef = useRef(null); const [searchProperties, setSearchProperties] = - useState>(); + useState>(); const containerRef = useRef(null); const [minHeight, setMinHeight] = useState(0); const advancedProperties = Array.from( advancedStyles.keys() - ) as Array; - - const currentProperties = searchProperties ?? advancedProperties; + ) as Array; const recentProperties = selectedInstanceKey ? (recentPropertiesMap.get(selectedInstanceKey) ?? []) : []; + const currentProperties = + searchProperties ?? + advancedProperties.filter( + (property) => recentProperties.includes(property) === false + ); + const showRecentProperties = recentProperties.length > 0 && searchProperties === undefined; @@ -386,27 +395,31 @@ export const Section = () => { setMinHeight(containerRef.current?.getBoundingClientRect().height ?? 0); }; - const updateRecentProperties = (properties: Array) => { + const updateRecentProperties = (properties: Array) => { if (selectedInstanceKey === undefined) { return; } const newRecentPropertiesMap = new Map(recentPropertiesMap); newRecentPropertiesMap.set( selectedInstanceKey, - Array.from(new Set([...recentProperties, ...properties])) + Array.from(new Set(properties)) ); setRecentPropertiesMap(newRecentPropertiesMap); }; const handleInsertStyles = (cssText: string) => { - const styles = insertStyles(cssText); - const insertedProperties = styles.map(({ property }) => property); - updateRecentProperties(insertedProperties); - return styles; + const styleMap = insertStyles(cssText); + const insertedProperties = Array.from( + styleMap.keys() + ) as Array; + updateRecentProperties([...recentProperties, ...insertedProperties]); + return styleMap; }; const handleShowAddStyleInput = () => { - setIsAdding(true); + flushSync(() => { + 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(); }; @@ -435,15 +448,16 @@ export const Section = () => { keys: ["property", "value"], }).map(({ property }) => property); - setSearchProperties(matched as StyleProperty[]); + setSearchProperties(matched as CssProperty[]); }; - const handleAbortAddStyles = () => { + const afterAddingStyles = () => { setIsAdding(false); requestAnimationFrame(() => { // We are either focusing the last value input from the recent list if available or the search input. const element = recentValueInputRef.current ?? searchInputRef.current; element?.focus(); + element?.select(); }); }; @@ -473,11 +487,10 @@ export const Section = () => { recentProperties.map((property, index, properties) => { const isLast = index === properties.length - 1; return ( - { if (event.type === "enter") { handleShowAddStyleInput(); @@ -505,11 +518,11 @@ export const Section = () => { { const styles = handleInsertStyles(cssText); - if (styles.length > 0) { - setIsAdding(false); + if (styles.size > 0) { + afterAddingStyles(); } }} - onClose={handleAbortAddStyles} + onClose={afterAddingStyles} onFocus={() => { if (isAdding === false) { handleShowAddStyleInput(); @@ -530,13 +543,13 @@ export const Section = () => { style={{ minHeight }} ref={containerRef} > - {currentProperties - .filter( - (property) => recentProperties.includes(property) === false - ) - .map((property) => ( - - ))} + {currentProperties.map((property) => { + return ( + + + + ); + })} 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 3c27bfd521ff..b449200940e7 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 @@ -14,9 +14,9 @@ import { type StyleMap, } from "@webstudio-is/css-engine"; import { useStore } from "@nanostores/react"; -import { $advancedStyles } from "./stores"; +import { $advancedStylesLonghands } from "./stores"; -export const propertyContainerAttribute = "data-property"; +export const copyAttribute = "data-declaration"; export const CopyPasteMenu = ({ children, @@ -27,7 +27,7 @@ export const CopyPasteMenu = ({ properties: Array; onPaste: (cssText: string) => void; }) => { - const advancedStyles = useStore($advancedStyles); + const advancedStylesLonghands = useStore($advancedStylesLonghands); const lastClickedProperty = useRef(); const handlePaste = () => { @@ -38,7 +38,7 @@ export const CopyPasteMenu = ({ // We want to only copy properties that are currently in front of the user. // That includes search or any future filters. const currentStyleMap: StyleMap = new Map(); - for (const [property, value] of advancedStyles) { + for (const [property, value] of advancedStylesLonghands) { const isEmpty = toValue(value) === ""; if (properties.includes(property) && isEmpty === false) { currentStyleMap.set(hyphenateProperty(property), value); @@ -51,10 +51,12 @@ export const CopyPasteMenu = ({ const handleCopy = () => { const property = lastClickedProperty.current; + if (property === undefined) { return; } - const value = advancedStyles.get(property); + const value = advancedStylesLonghands.get(property); + if (value === undefined) { return; } @@ -71,9 +73,11 @@ export const CopyPasteMenu = ({ if (!(event.target instanceof HTMLElement)) { return; } - const property = event.target.closest( - `[${propertyContainerAttribute}]` - )?.dataset.property; + const property = + event.target + .closest(`[${copyAttribute}]`) + ?.getAttribute(copyAttribute) ?? undefined; + lastClickedProperty.current = property; }} > 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 index 075f9e8a3a06..8816aef28395 100644 --- 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 @@ -4,115 +4,87 @@ 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: "" }, - }, - ]); + expect(result).toEqual( + new Map([["--custom-color", { type: "keyword", value: "unset" }]]) + ); }); - test("parses regular property", () => { + test("parses longhand property", () => { const result = parseStyleInput("color"); - expect(result).toEqual([ - { - selector: "selector", - property: "color", - value: { type: "unset", value: "" }, - }, - ]); + expect(result).toEqual( + new Map([["color", { type: "keyword", value: "unset" }]]) + ); + }); + + test("parses shorthand property", () => { + const result = parseStyleInput("margin"); + expect(result).toEqual( + new Map([ + ["margin-top", { type: "keyword", value: "unset" }], + ["margin-right", { type: "keyword", value: "unset" }], + ["margin-bottom", { type: "keyword", value: "unset" }], + ["margin-left", { type: "keyword", value: "unset" }], + ]) + ); }); test("trims whitespace", () => { const result = parseStyleInput(" color "); - expect(result).toEqual([ - { - selector: "selector", - property: "color", - value: { type: "unset", value: "" }, - }, - ]); + expect(result).toEqual( + new Map([["color", { type: "keyword", value: "unset" }]]) + ); }); test("handles unparsable regular property", () => { const result = parseStyleInput("notapro perty"); - expect(result).toEqual([]); + expect(result).toEqual(new Map()); }); 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: "" }, - }, - ]); + expect(result).toEqual( + new Map([["--notaproperty", { type: "keyword", value: "unset" }]]) + ); }); test("parses single property-value pair", () => { const result = parseStyleInput("color: red"); - expect(result).toEqual([ - { - selector: "selector", - property: "color", - value: { type: "keyword", value: "red" }, - }, - ]); + expect(result).toEqual( + new Map([["color", { 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" }, - }, - ]); + expect(result).toEqual( + new Map([ + ["color", { type: "keyword", value: "red" }], + ["display", { 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" }, - }, - ]); + expect(result).toEqual( + new Map([ + ["color", { type: "keyword", value: "red" }], + ["--somethinginvalid", { 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" }, - }, - ]); + expect(result).toEqual( + new Map([["--custom-color", { 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" }, - }, - ]); + expect(result).toEqual( + new Map([["color", { 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 index f58cead297b9..fb769ae78581 100644 --- 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 @@ -1,14 +1,36 @@ import { - type ParsedStyleDecl, properties, parseCss, camelCaseProperty, + shorthandProperties, } from "@webstudio-is/css-data"; -import type { CssProperty, StyleProperty } from "@webstudio-is/css-engine"; +import { type CssProperty, type StyleMap } from "@webstudio-is/css-engine"; import { lexer } from "css-tree"; -type StyleDecl = Omit & { - property: StyleProperty; +// When user provides only a property name, we need to make it `property:;` to be able to parse it. +const ensureValue = (css: string) => { + css = css.trim(); + + // Is it a custom property "--foo"? + if (css.startsWith("--") && lexer.match("", css).matched) { + return `${css}:;`; + } + // Is it a known longhand property? + if (camelCaseProperty(css as CssProperty) in properties) { + return `${css}:;`; + } + // Is it a known shorthand property? + if ( + shorthandProperties.includes(css as (typeof shorthandProperties)[number]) + ) { + return `${css}:;`; + } + // Is it a custom property without dashes "--foo"? + if (lexer.match("", `--${css}`).matched) { + return `--${css}:;`; + } + + return css; }; /** @@ -20,64 +42,26 @@ type StyleDecl = Omit & { * - 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: "" }, - }, - ]; - } +export const parseStyleInput = (css: string): StyleMap => { + css = ensureValue(css); - // Is it a known regular property? - if (camelCaseProperty(css as CssProperty) in properties) { - return [ - { - selector: "selector", - property: camelCaseProperty(css as CssProperty), - value: { type: "unset", value: "" }, - }, - ]; - } + const styles = parseCss(`selector{${css}}`); - // Is it a custom property "--foo"? - if (lexer.match("", `--${css}`).matched) { - return [ - { - selector: "selector", - property: `--${css}`, - value: { type: "unset", value: "" }, - }, - ]; - } + const styleMap: StyleMap = new Map(); - const hyphenatedStyles = parseCss(`selector{${css}}`); - const newStyles: StyleDecl[] = []; - - for (const { property, ...styleDecl } of hyphenatedStyles) { + for (const { property, value } 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. - styleDecl.value.type === "invalid" || - (styleDecl.value.type === "unparsed" && - property.startsWith("--") === false) + value.type === "invalid" || + (value.type === "unparsed" && property.startsWith("--") === false) ) { - newStyles.push({ - ...styleDecl, - property: `--${property}`, - }); + styleMap.set(`--${property}`, value); } else { - newStyles.push({ - ...styleDecl, - property: camelCaseProperty(property), - }); + // @todo This should be returning { type: "guaranteedInvalid" } + styleMap.set(property, value); } } - - return newStyles; + return styleMap; }; diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/stores.ts b/apps/builder/app/builder/features/style-panel/sections/advanced/stores.ts index 6df362505f6f..c49cf28d5ae8 100644 --- a/apps/builder/app/builder/features/style-panel/sections/advanced/stores.ts +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/stores.ts @@ -1,5 +1,9 @@ import { computed } from "nanostores"; -import { type StyleMap, type StyleProperty } from "@webstudio-is/css-engine"; +import { + hyphenateProperty, + type CssProperty, + type StyleMap, +} from "@webstudio-is/css-engine"; import { $matchingBreakpoints, getDefinedStyles } from "../../shared/model"; import { sections } from "../sections"; import { @@ -10,15 +14,16 @@ import { import { $selectedInstancePath } from "~/shared/awareness"; import { $settings } from "~/builder/shared/client-settings"; -const initialProperties = new Set([ +// @todo will be fully deleted https://github.com/webstudio-is/webstudio/issues/4871 +const initialProperties = new Set([ "cursor", - "mixBlendMode", + "mix-blend-mode", "opacity", - "pointerEvents", - "userSelect", + "pointer-events", + "user-select", ]); -export const $advancedStyles = computed( +export const $advancedStylesLonghands = computed( [ // prevent showing properties inherited from root // to not bloat advanced panel @@ -52,31 +57,33 @@ export const $advancedStyles = computed( }); // All properties used by the panels except the advanced panel - const visualProperties = new Set([]); + const visualProperties = new Set([]); for (const { properties } of sections.values()) { for (const property of properties) { - visualProperties.add(property); + visualProperties.add(hyphenateProperty(property) as CssProperty); } } for (const style of definedStyles) { const { property, value, listed } = style; + const hyphenatedProperty = hyphenateProperty(property) as CssProperty; // When property is listed, it was added from advanced panel. // If we are in advanced mode, we show them all. if ( - visualProperties.has(property) === false || + visualProperties.has(hyphenatedProperty) === false || listed || settings.stylePanelMode === "advanced" ) { - advancedStyles.set(property, value); + advancedStyles.set(hyphenatedProperty, value); } } // In advanced mode we assume user knows the properties they need, so we don't need to show these. - // @todo we need to find a better place for them in any case + // @todo https://github.com/webstudio-is/webstudio/issues/4871 if (settings.stylePanelMode !== "advanced") { for (const initialProperty of initialProperties) { - advancedStyles.set(initialProperty, { type: "unset", value: "" }); + advancedStyles.set(initialProperty, { type: "guaranteedInvalid" }); } } + return advancedStyles; } ); 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 97ae60c8fc91..7ff2850fd1df 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 @@ -493,7 +493,6 @@ export const CssValueInput = ({ const value = props.intermediateValue ?? props.value ?? initialValue; const valueRef = useRef(value); valueRef.current = value; - // Used to show description const [highlightedValue, setHighlighedValue] = useState< StyleValue | undefined diff --git a/apps/builder/app/builder/shared/collapsible-section.tsx b/apps/builder/app/builder/shared/collapsible-section.tsx index 7cbd4c263dcf..1d7f4998294f 100644 --- a/apps/builder/app/builder/shared/collapsible-section.tsx +++ b/apps/builder/app/builder/shared/collapsible-section.tsx @@ -132,31 +132,29 @@ export const CollapsibleSectionRoot = ({ }: CollapsibleSectionBaseProps) => { return ( - <> - - {trigger ?? ( - - {label} - - )} - - - - - {children} - - - - + + {trigger ?? ( + + {label} + + )} + + + + + {children} + + + ); }; diff --git a/packages/css-data/bin/mdn-data.ts b/packages/css-data/bin/mdn-data.ts index ca7445e6581a..b28f2477e46f 100755 --- a/packages/css-data/bin/mdn-data.ts +++ b/packages/css-data/bin/mdn-data.ts @@ -223,9 +223,19 @@ const walkSyntax = ( walk(parsed); }; -type FilteredProperties = { [property: string]: Value }; +const autogeneratedHint = "// This file was generated by pnpm mdn-data\n"; + +const writeToFile = (fileName: string, constant: string, data: unknown) => { + const content = + autogeneratedHint + + `export const ${constant} = ` + + JSON.stringify(data, null, 2) + + " as const;"; + + writeFileSync(join(targetDir, fileName), content, "utf8"); +}; -const experimentalProperties = [ +const supportedExperimentalProperties = [ "appearance", "aspect-ratio", "text-size-adjust", @@ -242,6 +252,7 @@ const experimentalProperties = [ "offset-anchor", ]; +// Properties we don't support in this form. const unsupportedProperties = [ "--*", // shorthand properties @@ -254,75 +265,107 @@ const unsupportedProperties = [ "background-position", ]; -const animatableProperties: string[] = []; -const filteredProperties: FilteredProperties = (() => { +type FilteredProperties = { [property: string]: Value }; + +const filterData = () => { let property: Property; - const result = {} as FilteredProperties; + const allLonghands = {} as FilteredProperties; + const allShorthands = {} as FilteredProperties; + const animatableLonghands = {} as FilteredProperties; + const animatableShorthands = {} as FilteredProperties; for (property in properties) { + if (unsupportedProperties.includes(property)) { + continue; + } + const config = properties[property]; - const isSupportedProperty = - // make sure the property standard and described in mdn - (config.status === "standard" && "mdn_url" in config) || - experimentalProperties.includes(property); - const isShorthandProperty = Array.isArray(config.initial); + const isStandardProperty = + config.status === "standard" && "mdn_url" in config; + + const isSupportedExperimentalProperty = + supportedExperimentalProperties.includes(property); + + if ( + isStandardProperty === false && + isSupportedExperimentalProperty === false + ) { + continue; + } + const isAnimatableProperty = property.startsWith("-") === false && config.animationType !== "discrete" && config.animationType !== "notAnimatable"; - if (unsupportedProperties.includes(property) || isShorthandProperty) { - continue; - } - if (isSupportedProperty) { + const isShorthandProperty = Array.isArray(config.initial); + + if (isShorthandProperty) { + allShorthands[property] = config; if (isAnimatableProperty) { - animatableProperties.push(property); + animatableShorthands[property] = config; } - result[property as Property] = config; + continue; + } + + allLonghands[property] = config; + if (isAnimatableProperty) { + animatableLonghands[property] = config; } } - return result; -})(); -const propertiesData = { - ...customData.propertiesData, + return { + allLonghands, + allShorthands, + animatableLonghands, + animatableShorthands, + }; }; -let property: string; -for (property in filteredProperties) { - const config = filteredProperties[property]; - const unitGroups = new Set(); - walkSyntax(config.syntax, (node) => { - if (node.type === "Type") { - if (node.name === "integer" || node.name === "number") { - unitGroups.add("number"); - return; - } +const getPropertiesData = ( + customPropertiesData: typeof customData.propertiesData, + filteredProperties: FilteredProperties +) => { + const propertiesData = { ...customPropertiesData }; + + let property: string; + for (property in filteredProperties) { + const config = filteredProperties[property]; + const unitGroups = new Set(); + walkSyntax(config.syntax, (node) => { + if (node.type === "Type") { + if (node.name === "integer" || node.name === "number") { + unitGroups.add("number"); + return; + } - // type names match unit groups - if (node.name in units) { - unitGroups.add(node.name); - return; + // type names match unit groups + if (node.name in units) { + unitGroups.add(node.name); + return; + } } + }); + + if (Array.isArray(config.initial)) { + throw new Error( + `Property ${property} contains non string initial value ${config.initial.join( + ", " + )}` + ); } - }); - if (Array.isArray(config.initial)) { - throw new Error( - `Property ${property} contains non string initial value ${config.initial.join( - ", " - )}` - ); + propertiesData[camelCaseProperty(property as CssProperty)] = { + unitGroups: Array.from(unitGroups), + inherited: config.inherited, + initial: parseInitialValue(property, config.initial, unitGroups), + ...("mdn_url" in config && { mdnUrl: config.mdn_url }), + }; } - propertiesData[camelCaseProperty(property as CssProperty)] = { - unitGroups: Array.from(unitGroups), - inherited: config.inherited, - initial: parseInitialValue(property, config.initial, unitGroups), - ...("mdn_url" in config && { mdnUrl: config.mdn_url }), - }; -} + return propertiesData; +}; const pseudoElements = Object.keys(selectors) .filter((selector) => { @@ -330,31 +373,14 @@ const pseudoElements = Object.keys(selectors) }) .map((selector) => selector.slice(2)); -const targetDir = join(process.cwd(), process.argv.slice(2).pop() as string); - -mkdirSync(targetDir, { recursive: true }); - -const writeToFile = (fileName: string, constant: string, data: unknown) => { - const autogeneratedHint = "// This file was generated by pnpm mdn-data\n"; - const content = - autogeneratedHint + - `export const ${constant} = ` + - JSON.stringify(data, null, 2) + - " as const;"; - - writeFileSync(join(targetDir, fileName), content, "utf8"); -}; - -// Non-standard properties are just missing in mdn data -const nonStandardValues = { - "background-clip": ["text"], -}; - -// https://www.w3.org/TR/css-values/#common-keywords -const commonKeywords = ["initial", "inherit", "unset"]; - -const keywordValues = (() => { +const getKeywordValues = (filteredProperties: FilteredProperties) => { const result = { ...customData.keywordValues }; + // Non-standard properties are just missing in mdn data + const nonStandardValues = { + "background-clip": ["text"], + }; + // https://www.w3.org/TR/css-values/#common-keywords + const commonKeywords = ["initial", "inherit", "unset"]; for (const property in filteredProperties) { const key = camelCaseProperty(property as CssProperty); @@ -391,38 +417,59 @@ const keywordValues = (() => { } return result; -})(); +}; +const getTypes = (propertiesData: typeof customData.propertiesData) => { + let types = ""; + + const camelCasedProperties = Object.keys(propertiesData).map((property) => + JSON.stringify(property) + ); + types += `export type CamelCasedProperty = ${camelCasedProperties.join(" | ")};\n\n`; + const hyphenatedProperties = Object.keys(propertiesData).map((property) => + JSON.stringify(hyphenateProperty(property)) + ); + types += `export type HyphenatedProperty = ${hyphenatedProperties.join(" | ")};\n\n`; + + const unitLiterals = Object.values(units) + .flat() + .map((unit) => JSON.stringify(unit)); + types += `export type Unit = ${unitLiterals.join(" | ")};\n`; + + return types; +}; + +const filteredData = filterData(); + +const longhandPropertiesData = getPropertiesData( + customData.propertiesData, + filteredData.allLonghands +); + +const targetDir = join(process.cwd(), process.argv.slice(2).pop() as string); +mkdirSync(targetDir, { recursive: true }); writeToFile("units.ts", "units", units); -writeToFile("properties.ts", "properties", propertiesData); -writeToFile("keyword-values.ts", "keywordValues", keywordValues); +writeToFile("properties.ts", "properties", longhandPropertiesData); +writeToFile( + "shorthand-properties.ts", + "shorthandProperties", + Object.keys(filteredData.allShorthands) +); +writeToFile( + "keyword-values.ts", + "keywordValues", + getKeywordValues(filteredData.allLonghands) +); writeToFile( "animatable-properties.ts", "animatableProperties", - animatableProperties + Object.keys(filteredData.animatableLonghands) ); - writeToFile("pseudo-elements.ts", "pseudoElements", pseudoElements); -let types = ""; - -const camelCasedProperties = Object.keys(propertiesData).map((property) => - JSON.stringify(property) -); -types += `export type CamelCasedProperty = ${camelCasedProperties.join(" | ")};\n\n`; -const hyphenatedProperties = Object.keys(propertiesData).map((property) => - JSON.stringify(hyphenateProperty(property)) -); -types += `export type HyphenatedProperty = ${hyphenatedProperties.join(" | ")};\n\n`; - -const unitLiterals = Object.values(units) - .flat() - .map((unit) => JSON.stringify(unit)); -types += `export type Unit = ${unitLiterals.join(" | ")};\n`; - const typesFile = join( process.cwd(), "../css-engine/src/__generated__/types.ts" ); mkdirSync(dirname(typesFile), { recursive: true }); -writeFileSync(typesFile, types); +writeFileSync(typesFile, autogeneratedHint + getTypes(longhandPropertiesData)); diff --git a/packages/css-data/bin/property-value-descriptions.ts b/packages/css-data/bin/property-value-descriptions.ts index f980b3e047f8..faab843505b7 100755 --- a/packages/css-data/bin/property-value-descriptions.ts +++ b/packages/css-data/bin/property-value-descriptions.ts @@ -1,10 +1,11 @@ /* eslint-disable func-style */ import * as fs from "node:fs"; import * as path from "node:path"; -import type { CreateChatCompletionResponse } from "openai"; -import { keywordValues } from "../src/__generated__/keyword-values"; import warnOnce from "warn-once"; import pRetry from "p-retry"; +import type { CreateChatCompletionResponse } from "openai"; +import { keywordValues } from "../src/__generated__/keyword-values"; +import { shorthandProperties } from "../src/__generated__/shorthand-properties"; import { customLonghandPropertyNames } from "../src/custom-data"; const propertiesPrompt = fs.readFileSync( @@ -66,7 +67,10 @@ const batchSize = 16; /** * Properties descriptions */ -const newPropertiesNames = Object.keys(keywordValues) +const newPropertiesNames = [ + ...Object.keys(keywordValues), + ...shorthandProperties, +] // Slice to generate only X - useful for testing. // .slice(0, 30) .filter( diff --git a/packages/css-data/src/__generated__/property-value-descriptions.ts b/packages/css-data/src/__generated__/property-value-descriptions.ts index b13676ec708c..3f5c22cff54a 100644 --- a/packages/css-data/src/__generated__/property-value-descriptions.ts +++ b/packages/css-data/src/__generated__/property-value-descriptions.ts @@ -505,6 +505,86 @@ export const propertiesGenerated = { fieldSizing: "Controls the algorithm used to calculate the width of form controls.", zoom: "Specifies the zoom level of a document.", + animation: + "Animations make it possible to animate transitions from one CSS style configuration to another.", + background: + "The background property is a shorthand for setting multiple background properties at once.", + border: + "Borders outline the border area of an element and are used for decoration and visual separation.", + "border-block": + "The border-block property is a shorthand for setting the individual properties for the vertical block axis borders.", + "border-block-end": + "The border-block-end property is a shorthand for setting the individual properties for the end block axis border.", + "border-block-start": + "The border-block-start property is a shorthand for setting the individual properties for the start block axis border.", + "border-bottom": + "The border-bottom property is a shorthand for setting the individual properties for the bottom border.", + "border-color": + "The border-color property sets the color of the four borders around an element.", + "border-image": + "The border-image property is a shorthand for setting the border-image-source, border-image-slice, border-image-width, and border-image-repeat properties.", + "border-inline": + "The border-inline property is a shorthand for setting the individual properties for the inline axis borders.", + "border-inline-end": + "The border-inline-end property is a shorthand for setting the individual properties for the end inline axis border.", + "border-inline-start": + "The border-inline-start property is a shorthand for setting the individual properties for the start inline axis border.", + "border-left": + "The border-left property is a shorthand for setting the individual properties for the left border.", + "border-radius": + "Border-radius is used to create rounded corners on an element's box.", + "border-right": + "The border-right property is a shorthand for setting the individual properties for the right border.", + "border-style": + "The border-style property sets the style of the four borders.", + "border-top": "Controls the top border of an element.", + "border-width": "Sets the width of the borders of an element.", + "column-rule": "Manages the line drawn between columns.", + columns: "Specifies the number and width of columns.", + "contain-intrinsic-size": + "Defines the size of an element's intrinsic content.", + container: "Establishes a new block formatting context.", + flex: "Specifies the length of a flexible item.", + "flex-flow": "Sets how flex items are placed in the flex container.", + font: "Specifies font styles for text.", + gap: "Sets the gap between grid items.", + grid: "Defines a grid or a subgrid.", + "grid-area": "Specifies the size of a grid item.", + "grid-column": "Specifies a grid item's position within the grid.", + "grid-row": "Specifies a grid item's row within the grid.", + "grid-template": "Sets the values for grid layout properties.", + inset: "Specifies the position of an element.", + "inset-block": + "Controls the block axis position of an absolutely positioned element.", + "inset-inline": + "Controls the inline axis position of an absolutely positioned element.", + "list-style": "Sets all the properties for a list in one declaration.", + margin: "Sets the margin on all four sides of an element at once.", + "margin-block": "Sets the margin on the block axis of an element.", + "margin-inline": "Sets the margin on the inline axis of an element.", + mask: "Lets you use an image or a CSS gradient to define the shape of the mask.", + "mask-border": + "Lets you use an image or a CSS gradient to define the border image of the mask.", + offset: "Controls the positioning of a positioned element.", + outline: "Sets the style of the outline around an element.", + padding: "Sets the padding on all four sides of an element at once.", + "padding-block": "Sets the padding on the block axis of an element.", + "padding-inline": "Sets the padding on the inline axis of an element.", + "place-content": "Aligns a flex container's lines within it.", + "place-items": "Aligns a flex container's items within it.", + "place-self": "Allows the default alignment of a flex item to be overridden.", + "scroll-margin": "Controls the top and bottom sides of the scrollbar", + "scroll-margin-block": "Controls the top and bottom sides of the scrollbar", + "scroll-margin-inline": "Controls the left and right sides of the scrollbar", + "scroll-padding": + "Sets the amount of space between the element's content and its padding", + "scroll-padding-block": + "Sets the amount of space between the element's content and its top and bottom padding", + "scroll-padding-inline": + "Sets the amount of space between the element's content and its left and right padding", + "text-decoration": + "Adds decoration to text, such as underlines or line-through", + "text-emphasis": "Sets special emphasis on text, like dots or circles", } as Record; export const propertiesOverrides = {} as Record; diff --git a/packages/css-data/src/__generated__/shorthand-properties.ts b/packages/css-data/src/__generated__/shorthand-properties.ts new file mode 100644 index 000000000000..8295f27ffe4a --- /dev/null +++ b/packages/css-data/src/__generated__/shorthand-properties.ts @@ -0,0 +1,60 @@ +// This file was generated by pnpm mdn-data +export const shorthandProperties = [ + "animation", + "background", + "border", + "border-block", + "border-block-end", + "border-block-start", + "border-bottom", + "border-color", + "border-image", + "border-inline", + "border-inline-end", + "border-inline-start", + "border-left", + "border-radius", + "border-right", + "border-style", + "border-top", + "border-width", + "column-rule", + "columns", + "contain-intrinsic-size", + "container", + "flex", + "flex-flow", + "font", + "gap", + "grid", + "grid-area", + "grid-column", + "grid-row", + "grid-template", + "inset", + "inset-block", + "inset-inline", + "list-style", + "margin", + "margin-block", + "margin-inline", + "mask", + "mask-border", + "offset", + "outline", + "padding", + "padding-block", + "padding-inline", + "place-content", + "place-items", + "place-self", + "scroll-margin", + "scroll-margin-block", + "scroll-margin-inline", + "scroll-padding", + "scroll-padding-block", + "scroll-padding-inline", + "text-decoration", + "text-emphasis", + "transition", +] as const; diff --git a/packages/css-data/src/index.ts b/packages/css-data/src/index.ts index ca4ec477b889..e10c76fe2b85 100644 --- a/packages/css-data/src/index.ts +++ b/packages/css-data/src/index.ts @@ -17,6 +17,7 @@ export * from "./property-parsers/index"; export * from "./parse-css-value"; export * from "./parse-css"; export * from "./shorthands"; +export { shorthandProperties } from "./__generated__/shorthand-properties"; export { parseTailwindToWebstudio } from "./tailwind-parser/parse"; diff --git a/packages/css-data/src/parse-css.test.ts b/packages/css-data/src/parse-css.test.ts index edd206b34fba..708fd392e67d 100644 --- a/packages/css-data/src/parse-css.test.ts +++ b/packages/css-data/src/parse-css.test.ts @@ -22,6 +22,17 @@ describe("Parse CSS", () => { ]); }); + // @todo this is wrong + test.skip("parse declaration with missing value", () => { + expect(parseCss(`.test { color:;}`)).toEqual([ + { + selector: ".test", + property: "color", + value: { type: "guaranteedInvalid" }, + }, + ]); + }); + test("parse supported shorthand values", () => { const css = ` .test { diff --git a/packages/css-engine/src/__generated__/types.ts b/packages/css-engine/src/__generated__/types.ts index 5a61f0b29c4d..fe7924cbf993 100644 --- a/packages/css-engine/src/__generated__/types.ts +++ b/packages/css-engine/src/__generated__/types.ts @@ -1,3 +1,4 @@ +// This file was generated by pnpm mdn-data export type CamelCasedProperty = | "WebkitFontSmoothing" | "MozOsxFontSmoothing" diff --git a/packages/css-engine/src/schema.ts b/packages/css-engine/src/schema.ts index 50a75bea99ee..e7cf1af485e7 100644 --- a/packages/css-engine/src/schema.ts +++ b/packages/css-engine/src/schema.ts @@ -107,6 +107,10 @@ export const InvalidValue = z.object({ }); export type InvalidValue = z.infer; +/** + * Use GuaranteedInvalidValue if you need a temp placeholder before user enters a value + * @deprecated + */ const UnsetValue = z.object({ type: z.literal("unset"), value: z.literal(""),