From d9be3974e079bfa48d6185dcf2d775145a361acf Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Wed, 19 Mar 2025 13:34:18 +0700 Subject: [PATCH 1/2] fix: sort mixed min and max breakpoints properly Added a couple of tests with style engine to figure out proper sorting. --- .../features/style-panel/shared/model.tsx | 2 + .../plugin-webflow/plugin-webflow.test.tsx | 10 +- .../app/shared/style-object-model.test.tsx | 105 ++++++++++++++++++ .../css-engine/src/core/compare-media.test.ts | 34 +++--- packages/css-engine/src/core/compare-media.ts | 7 ++ packages/tsconfig/base.json | 2 +- 6 files changed, 140 insertions(+), 20 deletions(-) diff --git a/apps/builder/app/builder/features/style-panel/shared/model.tsx b/apps/builder/app/builder/features/style-panel/shared/model.tsx index 545ab28f604d..0475d138870b 100644 --- a/apps/builder/app/builder/features/style-panel/shared/model.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/model.tsx @@ -240,6 +240,8 @@ const $model = computed( } ); +export { $model as _$model }; + export const $computedStyleDeclarations = computed( [ $model, diff --git a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx index c830c3e951d4..29ba5ce332db 100644 --- a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx +++ b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx @@ -3003,6 +3003,11 @@ describe("Styles", () => { background-color: rgba(0, 208, 255, 1) } } + @media all and (min-width: 1280px) { + Div Block 2 { + background-color: rgba(0, 255, 128, 1) + } + } @media all and (max-width: 991px) { Div Block 2 { background-color: rgba(68, 0, 255, 1) @@ -3017,11 +3022,6 @@ describe("Styles", () => { Div Block 2 { background-color: rgba(255, 0, 4, 1) } - } - @media all and (min-width: 1280px) { - Div Block 2 { - background-color: rgba(0, 255, 128, 1) - } }" `); }); diff --git a/apps/builder/app/shared/style-object-model.test.tsx b/apps/builder/app/shared/style-object-model.test.tsx index d9fb2d0f636d..0e92e096fb1b 100644 --- a/apps/builder/app/shared/style-object-model.test.tsx +++ b/apps/builder/app/shared/style-object-model.test.tsx @@ -17,6 +17,13 @@ import { getComputedStyleDecl, getPresetStyleDeclKey, } from "./style-object-model"; +import { + $breakpoints, + $selectedBreakpointId, + $styles, + $styleSourceSelections, +} from "./nano-states"; +import { _$model } from "~/builder/features/style-panel/shared/model"; /** * Create model fixture with a few features @@ -1608,3 +1615,101 @@ describe("style value source", () => { }); }); }); + +describe("compute matching breakpoints", () => { + test("min-width only breakpoints", () => { + const model = createModel({ + css: ` + @media mobile { + bodyLocal { + color: red; + } + } + `, + jsx: <$.Body ws:id="body" ws:tag="body" class="bodyLocal">, + }); + $styles.set(model.styles); + $styleSourceSelections.set(model.styleSourceSelections); + $breakpoints.set( + new Map([ + ["desktop", { id: "desktop", label: "", minWidth: 991 }], + ["mobile", { id: "mobile", label: "", minWidth: 479 }], + ["base", { id: "base", label: "" }], + ]) + ); + $selectedBreakpointId.set("desktop"); + const computedStyleDecl = getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }); + expect(computedStyleDecl.computedValue).toEqual({ + type: "keyword", + value: "red", + }); + }); + + test("max-width only breakpoints", () => { + const model = createModel({ + css: ` + @media mobile { + bodyLocal { + color: red; + } + } + `, + jsx: <$.Body ws:id="body" ws:tag="body" class="bodyLocal">, + }); + $styles.set(model.styles); + $styleSourceSelections.set(model.styleSourceSelections); + $breakpoints.set( + new Map([ + ["base", { id: "base", label: "" }], + ["mobile", { id: "mobile", label: "", maxWidth: 479 }], + ["desktop", { id: "desktop", label: "", maxWidth: 991 }], + ]) + ); + $selectedBreakpointId.set("desktop"); + const computedStyleDecl = getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }); + expect(computedStyleDecl.computedValue).toEqual({ + type: "keyword", + value: "black", + }); + }); + + test("mixed min-width and max-width breakpoints", () => { + const model = createModel({ + css: ` + @media mobile { + bodyLocal { + color: red; + } + } + `, + jsx: <$.Body ws:id="body" ws:tag="body" class="bodyLocal">, + }); + $styles.set(model.styles); + $styleSourceSelections.set(model.styleSourceSelections); + $breakpoints.set( + new Map([ + ["desktop", { id: "desktop", label: "", minWidth: 991 }], + ["base", { id: "base", label: "" }], + ["mobile", { id: "mobile", label: "", maxWidth: 479 }], + ]) + ); + $selectedBreakpointId.set("desktop"); + const computedStyleDecl = getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }); + expect(computedStyleDecl.computedValue).toEqual({ + type: "keyword", + value: "black", + }); + }); +}); diff --git a/packages/css-engine/src/core/compare-media.test.ts b/packages/css-engine/src/core/compare-media.test.ts index f9ca5f832e0e..f453dcb01e96 100644 --- a/packages/css-engine/src/core/compare-media.test.ts +++ b/packages/css-engine/src/core/compare-media.test.ts @@ -41,25 +41,31 @@ describe("Compare media", () => { }); test("mixed max and min", () => { - const initial = [ + expect( + [ + {}, + { maxWidth: 991 }, + { maxWidth: 479 }, + { maxWidth: 767 }, + { minWidth: 1440 }, + { minWidth: 1280 }, + { minWidth: 1920 }, + ].toSorted(compareMedia) + ).toStrictEqual([ {}, - { maxWidth: 991 }, - { maxWidth: 479 }, - { maxWidth: 767 }, - { minWidth: 1440 }, { minWidth: 1280 }, + { minWidth: 1440 }, { minWidth: 1920 }, - ]; - const expected = [ - {}, { maxWidth: 991 }, { maxWidth: 767 }, { maxWidth: 479 }, - { minWidth: 1280 }, - { minWidth: 1440 }, - { minWidth: 1920 }, - ]; - const sorted = initial.sort(compareMedia); - expect(sorted).toStrictEqual(expected); + ]); + // test both directions of sorting + expect( + [{ maxWidth: 479 }, { minWidth: 991 }, {}].toSorted(compareMedia) + ).toStrictEqual([{}, { minWidth: 991 }, { maxWidth: 479 }]); + expect( + [{ minWidth: 991 }, {}, { maxWidth: 479 }].toSorted(compareMedia) + ).toStrictEqual([{}, { minWidth: 991 }, { maxWidth: 479 }]); }); }); diff --git a/packages/css-engine/src/core/compare-media.ts b/packages/css-engine/src/core/compare-media.ts index fa6aad791d10..1ec22712ecc2 100644 --- a/packages/css-engine/src/core/compare-media.ts +++ b/packages/css-engine/src/core/compare-media.ts @@ -25,6 +25,13 @@ export const compareMedia = ( return optionB.maxWidth - optionA.maxWidth; } + if (optionA.minWidth !== undefined && optionB.maxWidth !== undefined) { + return optionB.maxWidth - optionA.minWidth; + } + if (optionA.maxWidth !== undefined && optionB.minWidth !== undefined) { + return optionB.minWidth - optionA.maxWidth; + } + // Media with maxWith should render before minWith just to have the same sorting visually in the UI as in CSSOM. return "minWidth" in optionA ? 1 : -1; }; diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json index aded5149fd25..f68e89f6f328 100644 --- a/packages/tsconfig/base.json +++ b/packages/tsconfig/base.json @@ -3,7 +3,7 @@ "display": "Default", "compilerOptions": { "module": "ES2022", - "target": "ES2022", + "target": "ES2023", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "inlineSources": false, From d293854fa0759529c8d3907d2af646e82eb3d407 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Wed, 19 Mar 2025 18:10:35 +0700 Subject: [PATCH 2/2] Cover more cases --- .../features/style-panel/shared/model.tsx | 30 +++++-- .../app/shared/style-object-model.test.tsx | 90 +++++++++++++++---- 2 files changed, 94 insertions(+), 26 deletions(-) diff --git a/apps/builder/app/builder/features/style-panel/shared/model.tsx b/apps/builder/app/builder/features/style-panel/shared/model.tsx index 0475d138870b..bc5b27430cfc 100644 --- a/apps/builder/app/builder/features/style-panel/shared/model.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/model.tsx @@ -6,6 +6,7 @@ import { propertiesData } from "@webstudio-is/css-data"; import { compareMedia, hyphenateProperty, + matchMedia, toVarFallback, type CssProperty, type StyleValue, @@ -41,6 +42,7 @@ import { type InstancePath, } from "~/shared/awareness"; import type { InstanceSelector } from "~/shared/tree-utils"; +import { $canvasWidth } from "~/builder/shared/nano-states"; const $presetStyles = computed($registeredComponentMetas, (metas) => { const presetStyles = new Map(); @@ -111,16 +113,28 @@ const $instanceComponents = computed( ); export const $matchingBreakpoints = computed( - [$breakpoints, $selectedBreakpoint], - (breakpoints, selectedBreakpoint) => { - const sortedBreakpoints = Array.from(breakpoints.values()).sort( - compareMedia - ); + [$breakpoints, $selectedBreakpoint, $canvasWidth], + (breakpoints, selectedBreakpoint, canvasWidth) => { + // zero is not correct, need to use current width for base breakpoint + // add always add base + const selectedWidth = + selectedBreakpoint?.minWidth ?? + selectedBreakpoint?.maxWidth ?? + canvasWidth ?? + 0; + const sortedBreakpoints = Array.from(breakpoints.values()) + .sort(compareMedia) + .sort((left, right) => { + // put selected breakpoint always in the end + // to make style from matching breakpoints remote + const leftScore = left.id === selectedBreakpoint?.id ? 1 : 0; + const rightScore = right.id === selectedBreakpoint?.id ? 1 : 0; + return leftScore - rightScore; + }); const matchingBreakpoints: Breakpoint["id"][] = []; for (const breakpoint of sortedBreakpoints) { - matchingBreakpoints.push(breakpoint.id); - if (breakpoint.id === selectedBreakpoint?.id) { - break; + if (matchMedia(breakpoint, selectedWidth)) { + matchingBreakpoints.push(breakpoint.id); } } return matchingBreakpoints; diff --git a/apps/builder/app/shared/style-object-model.test.tsx b/apps/builder/app/shared/style-object-model.test.tsx index 0e92e096fb1b..c7c0b7efddf2 100644 --- a/apps/builder/app/shared/style-object-model.test.tsx +++ b/apps/builder/app/shared/style-object-model.test.tsx @@ -1638,12 +1638,13 @@ describe("compute matching breakpoints", () => { ]) ); $selectedBreakpointId.set("desktop"); - const computedStyleDecl = getComputedStyleDecl({ - model: _$model.get(), - instanceSelector: ["body"], - property: "color", - }); - expect(computedStyleDecl.computedValue).toEqual({ + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).computedValue + ).toEqual({ type: "keyword", value: "red", }); @@ -1670,18 +1671,19 @@ describe("compute matching breakpoints", () => { ]) ); $selectedBreakpointId.set("desktop"); - const computedStyleDecl = getComputedStyleDecl({ - model: _$model.get(), - instanceSelector: ["body"], - property: "color", - }); - expect(computedStyleDecl.computedValue).toEqual({ + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).computedValue + ).toEqual({ type: "keyword", value: "black", }); }); - test("mixed min-width and max-width breakpoints", () => { + test("mixed min-width and max-width with selected min-width", () => { const model = createModel({ css: ` @media mobile { @@ -1702,14 +1704,66 @@ describe("compute matching breakpoints", () => { ]) ); $selectedBreakpointId.set("desktop"); - const computedStyleDecl = getComputedStyleDecl({ - model: _$model.get(), - instanceSelector: ["body"], - property: "color", + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).computedValue + ).toEqual({ + type: "keyword", + value: "black", }); - expect(computedStyleDecl.computedValue).toEqual({ + $selectedBreakpointId.set("base"); + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).source + ).toEqual( + expect.objectContaining({ name: "remote", breakpointId: "mobile" }) + ); + }); + + test("mixed min-width and max-width with selected max-width", () => { + const model = createModel({ + css: ` + @media desktop { + bodyLocal { + color: red; + } + } + `, + jsx: <$.Body ws:id="body" ws:tag="body" class="bodyLocal">, + }); + $styles.set(model.styles); + $styleSourceSelections.set(model.styleSourceSelections); + $breakpoints.set( + new Map([ + ["desktop", { id: "desktop", label: "", minWidth: 991 }], + ["base", { id: "base", label: "" }], + ["mobile", { id: "mobile", label: "", maxWidth: 479 }], + ]) + ); + $selectedBreakpointId.set("mobile"); + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).computedValue + ).toEqual({ type: "keyword", value: "black", }); + $selectedBreakpointId.set("base"); + expect( + getComputedStyleDecl({ + model: _$model.get(), + instanceSelector: ["body"], + property: "color", + }).source + ).toEqual(expect.objectContaining({ name: "default" })); }); });