From 15e9719ae6d34a90dcc283aaf596fea5651b6809 Mon Sep 17 00:00:00 2001 From: RheeseyB <1044774+Rheeseyb@users.noreply.github.com> Date: Wed, 6 Dec 2023 13:39:16 +0000 Subject: [PATCH] Scope imported CSS to only affect the canvas (#4604) * fix(canvas) Scope imported CSS to only affect the canvas * fix(canvas) Fix css handling of keyframes * fix(tests) Fix package-manager.spec.ts --- editor/package.json | 4 +- editor/pnpm-lock.yaml | 30 +- .../package-manager/package-manager.spec.ts | 6 +- editor/src/core/shared/css-utils.spec.ts | 306 ++++++++++++++++++ editor/src/core/shared/css-utils.ts | 67 ++++ editor/src/core/webpack-loaders/css-loader.ts | 5 +- 6 files changed, 398 insertions(+), 20 deletions(-) create mode 100644 editor/src/core/shared/css-utils.spec.ts create mode 100644 editor/src/core/shared/css-utils.ts diff --git a/editor/package.json b/editor/package.json index 34c1e82d142d..f5e6c214a151 100644 --- a/editor/package.json +++ b/editor/package.json @@ -142,7 +142,7 @@ "clipboard-polyfill": "2.4.6", "console-feed": "2.8.11", "create-react-class": "15.6.3", - "css-tree": "1.1.3", + "css-tree": "2.3.1", "eslint-plugin-import": "2.25.3", "eslint-plugin-jsx-a11y": "6.5.1", "eslint-plugin-react": "7.23.2", @@ -261,7 +261,7 @@ "@types/classnames": "2.2.4", "@types/codemirror": "0.0.40", "@types/create-react-class": "15.6.2", - "@types/css-tree": "1.0.6", + "@types/css-tree": "2.3.4", "@types/diff": "4.0.2", "@types/dom-to-image": "2.6.0", "@types/enzyme": "3.1.9", diff --git a/editor/pnpm-lock.yaml b/editor/pnpm-lock.yaml index 83efbdcf8848..ca098f9ed27f 100644 --- a/editor/pnpm-lock.yaml +++ b/editor/pnpm-lock.yaml @@ -85,7 +85,7 @@ specifiers: '@types/classnames': 2.2.4 '@types/codemirror': 0.0.40 '@types/create-react-class': 15.6.2 - '@types/css-tree': 1.0.6 + '@types/css-tree': 2.3.4 '@types/diff': 4.0.2 '@types/dom-to-image': 2.6.0 '@types/enzyme': 3.1.9 @@ -165,7 +165,7 @@ specifiers: console-feed: 2.8.11 create-react-class: 15.6.3 css-loader: 0.28.4 - css-tree: 1.1.3 + css-tree: 2.3.1 csstype: 3.0.3 dependency-cruiser: ^9.17.1 diff: 5.0.0 @@ -385,7 +385,7 @@ dependencies: clipboard-polyfill: 2.4.6 console-feed: 2.8.11_ceyt5bfod5miybgg2gmf5ylbc4 create-react-class: 15.6.3 - css-tree: 1.1.3 + css-tree: 2.3.1 eslint-plugin-import: 2.25.3_xoh4mdbbxtgv4pgsofz5xwre24 eslint-plugin-jsx-a11y: 6.5.1_5wlsrnjcorawie777qour2ptie_eslint@7.32.0 eslint-plugin-react: 7.23.2_eslint@7.32.0 @@ -507,7 +507,7 @@ devDependencies: '@types/classnames': 2.2.4 '@types/codemirror': 0.0.40 '@types/create-react-class': 15.6.2 - '@types/css-tree': 1.0.6 + '@types/css-tree': 2.3.4 '@types/diff': 4.0.2 '@types/dom-to-image': 2.6.0 '@types/enzyme': 3.1.9 @@ -4992,8 +4992,8 @@ packages: '@types/react': 18.0.9 dev: true - /@types/css-tree/1.0.6: - resolution: {integrity: sha512-zjSMDm4C7J1azi9SdT1XwNaVCzeHZx+Y5AVebcg/mrtUULNxZjeamc8oQpUDKpaudM5R+Sy7RFvm2xphfwN64w==} + /@types/css-tree/2.3.4: + resolution: {integrity: sha512-wdxxe7zEpOXfy5C3FmwinAIc/6p6du/wOKMGZf07JHuHHRIvLtLq8h66zi3Yn7PCyswxbp3Ujx9h+vSuMvfN/w==} dev: true /@types/diff/4.0.2: @@ -7907,7 +7907,7 @@ packages: dev: true /component-indexof/0.0.3: - resolution: {integrity: sha1-EdCRMSI5648yyPJa6csAL/6NPCQ=} + resolution: {integrity: sha512-puDQKvx/64HZXb4hBwIcvQLaLgux8o1CbWl39s41hrIIZDl1lJiD5jc22gj3RBeGK0ovxALDYpIbyjqDUUl0rw==} dev: false /compressible/2.0.18: @@ -8307,12 +8307,12 @@ packages: fastparse: 1.1.2 dev: true - /css-tree/1.1.3: - resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} - engines: {node: '>=8.0.0'} + /css-tree/2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} dependencies: - mdn-data: 2.0.14 - source-map: 0.6.1 + mdn-data: 2.0.30 + source-map-js: 1.0.2 dev: false /css-what/5.0.1: @@ -13662,8 +13662,8 @@ packages: safe-buffer: 5.2.1 dev: true - /mdn-data/2.0.14: - resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + /mdn-data/2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} dev: false /media-typer/0.3.0: @@ -17614,7 +17614,6 @@ packages: /source-map-js/1.0.2: resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} engines: {node: '>=0.10.0'} - dev: true /source-map-loader/0.2.3: resolution: {integrity: sha512-MYbFX9DYxmTQFfy2v8FC1XZwpwHKYxg3SK8Wb7VPBKuhDjz8gi9re2819MsG4p49HDyiOSUKlmZ+nQBArW5CGw==} @@ -17665,6 +17664,7 @@ packages: /source-map/0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + dev: true /source-map/0.7.3: resolution: {integrity: sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==} diff --git a/editor/src/core/es-modules/package-manager/package-manager.spec.ts b/editor/src/core/es-modules/package-manager/package-manager.spec.ts index 406c4aaf434e..181a4fcdf2fb 100644 --- a/editor/src/core/es-modules/package-manager/package-manager.spec.ts +++ b/editor/src/core/es-modules/package-manager/package-manager.spec.ts @@ -33,10 +33,11 @@ import { svgToBase64 } from '../../shared/file-utils' import { createBuiltInDependenciesList } from './built-in-dependencies-list' import * as moduleResolutionExamples from '../test-cases/module-resolution-examples.json' import { createNodeModules } from './test-utils' +import { CanvasContainerID } from '../../../components/canvas/canvas-types' require('jest-fetch-mock').enableMocks() -const simpleCssContent = '.utopiaClass { background-color: red; }' +const simpleCssContent = '.utopiaClass{background-color:red}' beforeEach(() => { resetDepPackagerCache() @@ -378,7 +379,8 @@ describe('ES Dependency Manager — Downloads extra files as-needed', () => { const styleTag = document.getElementById( `${InjectedCSSFilePrefix}/node_modules/mypackage/dist/style.css`, ) - expect(styleTag?.innerHTML).toEqual(simpleCssContent) + const rescopedCSS = `#${CanvasContainerID} ${simpleCssContent}` + expect(styleTag?.innerHTML).toEqual(rescopedCSS) expect(innerOnRemoteModuleDownload).toBeCalledTimes(0) done() diff --git a/editor/src/core/shared/css-utils.spec.ts b/editor/src/core/shared/css-utils.spec.ts new file mode 100644 index 000000000000..d5b27895e260 --- /dev/null +++ b/editor/src/core/shared/css-utils.spec.ts @@ -0,0 +1,306 @@ +import { rescopeCSSToTargetCanvasOnly } from './css-utils' +import * as prettier from 'prettier' + +function formatCss(css: string): string { + return prettier.format(css, { parser: 'css' }) +} + +describe('rescopeCSSToTargetCanvasOnly', () => { + it('Handles the default project CSS', () => { + const input = ` + body { + font-family: San Francisco, SF UI, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + } + + @font-face { + font-family: 'ITC Garamond '; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local(ITC Garamond ) format('ttf'); + } + + .appheadercontainer, .listcardcontainer, .gridcardcontainer { + container-type: inline-size; + } + + @container (min-width: 700px) { + .apptitle { + font-size: 3.5em; + } + + .listcard { + height: 180px + } + .gridcard { + height: 325px + } + } + + @container (max-width: 700px) { + .gridcard { + height: 215px + } + } + ` + + const output = formatCss(rescopeCSSToTargetCanvasOnly(input)) + expect(output).toEqual( + formatCss(` + #canvas-container { + font-family: San Francisco, SF UI, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + } + @font-face { + font-family: 'ITC Garamond '; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local(ITC Garamond ) format('ttf'); + } + #canvas-container .appheadercontainer, #canvas-container .listcardcontainer, #canvas-container .gridcardcontainer { + container-type: inline-size; + } + @container (min-width: 700px) { + #canvas-container .apptitle { + font-size: 3.5em; + } + #canvas-container .listcard { + height: 180px + } + #canvas-container .gridcard { + height: 325px + } + } + @container (max-width: 700px) { + #canvas-container .gridcard { + height: 215px + } + } + `), + ) + }) + + it('Handles the sample remix project css', () => { + const input = ` + @font-face { + font-family: primary; + src: url(https://cdn.utopia.pizza/editor/sample-assets/stretchpro.woff); + } + + @font-face { + font-family: primary-basic; + src: url(https://cdn.utopia.pizza/editor/sample-assets/stretchpro-basic.woff); + } + + :root { + --off-white: #ece6b0; + --purple: #7cf08c; + --orange: #0f7e6b; + --yellow: #dd4a76; + --primary: primary; + --primary-basic: primary-basic; + --secondary: 'Roboto Mono'; + --safety: 'sans-serif'; + } + + #my-thing { + view-transition-name: main-header; + } + + .my-class { + view-transition-name: main-header; + contain: layout; + } + + @keyframes fade-in { + from { opacity: 0; } + } + + @keyframes fade-out { + to { opacity: 0; } + } + + @keyframes slide-from-right { + from { transform: translateX(30px); } + } + + @keyframes slide-to-left { + to { transform: translateX(-30px); } + } + + ::view-transition-old(root) { + animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out, + 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; + } + + ::view-transition-new(root) { + animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, + 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; + } + ` + + const output = formatCss(rescopeCSSToTargetCanvasOnly(input)) + expect(output).toEqual( + formatCss(` + @font-face { + font-family: primary; + src: url(https://cdn.utopia.pizza/editor/sample-assets/stretchpro.woff); + } + @font-face { + font-family: primary-basic; + src: url(https://cdn.utopia.pizza/editor/sample-assets/stretchpro-basic.woff); + } + :root { + --off-white: #ece6b0; + --purple: #7cf08c; + --orange: #0f7e6b; + --yellow: #dd4a76; + --primary: primary; + --primary-basic: primary-basic; + --secondary: 'Roboto Mono'; + --safety: 'sans-serif'; + } + #canvas-container #my-thing { + view-transition-name: main-header; + } + #canvas-container .my-class { + view-transition-name: main-header; + contain: layout; + } + @keyframes fade-in { + from { opacity: 0; } + } + @keyframes fade-out { + to { opacity: 0; } + } + @keyframes slide-from-right { + from { transform: translateX(30px); } + } + @keyframes slide-to-left { + to { transform: translateX(-30px); } + } + ::view-transition-old(root) { + animation: 90ms cubic-bezier(0.4, 0, 1, 1) both fade-out, + 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; + } + ::view-transition-new(root) { + animation: 210ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, + 300ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; + } + `), + ) + }) + + it('Handles container queries', () => { + const input = ` + /* Default heading styles for the card title */ + .card h2 { + font-size: 1em; + } + + /* If the container is larger than 700px */ + @container (min-width: 700px) { + .card h2 { + font-size: 2em; + } + } + ` + + const output = formatCss(rescopeCSSToTargetCanvasOnly(input)) + expect(output).toEqual( + formatCss(` + #canvas-container .card h2 { + font-size: 1em; + } + @container (min-width: 700px) { + #canvas-container .card h2 { + font-size: 2em; + } + } + `), + ) + }) + + it('Handles media queries', () => { + const input = ` + /* At the top level of your code */ + @media screen and (min-width: 900px) { + article { + padding: 1rem 3rem; + } + } + + /* Nested within another conditional at-rule */ + @supports (display: flex) { + @media screen and (min-width: 900px) { + article { + display: flex; + } + } + } + ` + + const output = formatCss(rescopeCSSToTargetCanvasOnly(input)) + expect(output).toEqual( + formatCss(` + @media screen and (min-width: 900px) { + #canvas-container article { + padding: 1rem 3rem; + } + } + @supports (display: flex) { + @media screen and (min-width: 900px) { + #canvas-container article { + display: flex; + } + } + } + `), + ) + }) + + it('Handles keyframes', () => { + const input = ` + @keyframes identifier { + from { + top: 0; + left: 0; + } + 30% { + top: 50px; + } + 68%, + 72% { + left: 50px; + } + to { + top: 100px; + left: 100%; + } + } + ` + + const output = formatCss(rescopeCSSToTargetCanvasOnly(input)) + expect(output).toEqual( + formatCss(` + @keyframes identifier { + from { + top: 0; + left: 0; + } + 30% { + top: 50px; + } + 68%, + 72% { + left: 50px; + } + to { + top: 100px; + left: 100%; + } + } + `), + ) + }) +}) diff --git a/editor/src/core/shared/css-utils.ts b/editor/src/core/shared/css-utils.ts new file mode 100644 index 000000000000..0c14a3d78707 --- /dev/null +++ b/editor/src/core/shared/css-utils.ts @@ -0,0 +1,67 @@ +import * as csstree from 'css-tree' +import { CanvasContainerID } from '../../components/canvas/canvas-types' + +const SelectorTypes = ['ClassSelector', 'IdSelector', 'TypeSelector'] +const SelectorsToSkip = [ + // general case type selectors to skip + 'html', + 'head', + + // keyframe specific type selectors + 'from', + 'to', +] + +export function rescopeCSSToTargetCanvasOnly(input: string): string { + let ast = csstree.parse(input) + + csstree.walk(ast, (node) => { + // We want to find all selectors, and prepend '#canvas-container ' (i.e. the canvas-container + // ID Selector and a ' ' combinator) so that they will only apply to descendents of the canvas + if (node.type === 'Selector') { + const firstChild = node.children.first + + if (firstChild == null) { + return + } + + if (!SelectorTypes.includes(firstChild.type)) { + return + } + + if (firstChild.type === 'TypeSelector' && SelectorsToSkip.includes(firstChild.name)) { + // Skip special selectors + return + } + + if (firstChild.type === 'TypeSelector' && firstChild.name === 'body') { + // The closest analogy to the body here is the #canvas-container itself, + // so let's just replace it + node.children.shift() + node.children.prependData( + csstree.fromPlainObject({ + type: 'IdSelector', + name: CanvasContainerID, + }), + ) + } else { + // For everything else we want to prepent '#canvas-container ' + node.children.prependData( + csstree.fromPlainObject({ + type: 'Combinator', + name: ' ', + }), + ) + + node.children.prependData( + csstree.fromPlainObject({ + type: 'IdSelector', + name: CanvasContainerID, + }), + ) + } + } + }) + + return csstree.generate(ast) +} diff --git a/editor/src/core/webpack-loaders/css-loader.ts b/editor/src/core/webpack-loaders/css-loader.ts index dce62e28c986..3b302bc11eb2 100644 --- a/editor/src/core/webpack-loaders/css-loader.ts +++ b/editor/src/core/webpack-loaders/css-loader.ts @@ -1,3 +1,4 @@ +import { rescopeCSSToTargetCanvasOnly } from '../shared/css-utils' import type { LoadModule, MatchFile, ModuleLoader } from './loader-types' import { loadModuleResult } from './loader-types' @@ -8,6 +9,8 @@ const matchFile: MatchFile = (filename: string) => { } const loadModule: LoadModule = (filename: string, contents: string) => { + const canvasScopedCSS = rescopeCSSToTargetCanvasOnly(contents) + const loadedContents = ` Object.defineProperty(module, 'exports', { get() { @@ -15,7 +18,7 @@ const loadModule: LoadModule = (filename: string, contents: string) => { (function() { 'use strict'; const filename = ${JSON.stringify(filename)} - const content = ${JSON.stringify(contents)} + const content = ${JSON.stringify(canvasScopedCSS)} const elementId = ${JSON.stringify(InjectedCSSFilePrefix)} + filename; const maybeExistingTag = document.getElementById(elementId); if (maybeExistingTag != null) {