diff --git a/apps/tailwind-components-example/.eslintrc.json b/apps/tailwind-components-example/.eslintrc.json new file mode 100644 index 00000000..bffb357a --- /dev/null +++ b/apps/tailwind-components-example/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/apps/tailwind-components-example/.gitignore b/apps/tailwind-components-example/.gitignore new file mode 100644 index 00000000..8f322f0d --- /dev/null +++ b/apps/tailwind-components-example/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/tailwind-components-example/README.md b/apps/tailwind-components-example/README.md new file mode 100644 index 00000000..d58f25dc --- /dev/null +++ b/apps/tailwind-components-example/README.md @@ -0,0 +1,3 @@ +# Introduction + +Easyblocks Quickstart project. diff --git a/apps/tailwind-components-example/next.config.js b/apps/tailwind-components-example/next.config.js new file mode 100644 index 00000000..b193fbb8 --- /dev/null +++ b/apps/tailwind-components-example/next.config.js @@ -0,0 +1,13 @@ +const path = require("node:path"); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + webpack: (config) => { + // apps/README.md#Apps and internal packages + config.resolve.modules.unshift(path.resolve(__dirname, "node_modules")); + + return config; + }, +}; + +module.exports = nextConfig; diff --git a/apps/tailwind-components-example/package.json b/apps/tailwind-components-example/package.json new file mode 100644 index 00000000..a38d725c --- /dev/null +++ b/apps/tailwind-components-example/package.json @@ -0,0 +1,30 @@ +{ + "name": "tailwind-components-example", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@easyblocks/core": "workspace:*", + "@easyblocks/editor": "workspace:*", + "@mhsdesign/jit-browser-tailwindcss": "^0.4.0", + "@types/lodash": "^4.14.191", + "@types/node": "20.4.2", + "@types/react": "18.2.20", + "@types/react-dom": "18.2.7", + "autoprefixer": "10.4.14", + "eslint": "8.45.0", + "eslint-config-next": "13.4.10", + "lodash": "^4.17.21", + "next": "13.4.12", + "postcss": "8.4.26", + "react": "18.2.0", + "react-dom": "18.2.0", + "tailwindcss": "3.3.3", + "typescript": "5.1.6" + } +} diff --git a/apps/tailwind-components-example/postcss.config.js b/apps/tailwind-components-example/postcss.config.js new file mode 100644 index 00000000..12a703d9 --- /dev/null +++ b/apps/tailwind-components-example/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/tailwind-components-example/src/app/easyblocks-editor/easyblocksConfig.tsx b/apps/tailwind-components-example/src/app/easyblocks-editor/easyblocksConfig.tsx new file mode 100644 index 00000000..0f35a852 --- /dev/null +++ b/apps/tailwind-components-example/src/app/easyblocks-editor/easyblocksConfig.tsx @@ -0,0 +1,154 @@ +"use client"; +import { + Config, + EasyblocksBackend, + NoCodeComponentDefinition, +} from "@easyblocks/core"; + +export const easyblocksConfig = ({ + definitions, +}: { + definitions: NoCodeComponentDefinition[]; +}): Config => { + if (!process.env.NEXT_PUBLIC_EASYBLOCKS_ACCESS_TOKEN) { + throw new Error("Missing NEXT_PUBLIC_EASYBLOCKS_ACCESS_TOKEN"); + } + + return { + backend: new EasyblocksBackend({ + accessToken: process.env.NEXT_PUBLIC_EASYBLOCKS_ACCESS_TOKEN, + }), + locales: [ + { + code: "en-US", + isDefault: true, + }, + { + code: "de-DE", + fallback: "en-US", + }, + ], + components: definitions, + tokens: { + colors: [ + { + id: "black", + label: "Black", + value: "#000000", + isDefault: true, + }, + { + id: "white", + label: "White", + value: "#ffffff", + }, + { + id: "coral", + label: "Coral", + value: "#ff7f50", + }, + ], + fonts: [ + { + id: "body", + label: "Body", + value: { + fontSize: 18, + lineHeight: 1.8, + fontFamily: "sans-serif", + }, + isDefault: true, + }, + { + id: "heading", + label: "Heading", + value: { + fontSize: 24, + fontFamily: "sans-serif", + lineHeight: 1.2, + fontWeight: 700, + }, + }, + ], + space: [ + { + id: "0", + label: "0", + value: "0px", + isDefault: true, + }, + { + id: "1", + label: "1", + value: "1px", + }, + { + id: "2", + label: "2", + value: "2px", + }, + { + id: "4", + label: "4", + value: "4px", + }, + { + id: "6", + label: "6", + value: "6px", + }, + { + id: "8", + label: "8", + value: "8px", + }, + { + id: "12", + label: "12", + value: "12px", + }, + { + id: "16", + label: "16", + value: "16px", + }, + { + id: "24", + label: "24", + value: "24px", + }, + { + id: "32", + label: "32", + value: "32px", + }, + { + id: "48", + label: "48", + value: "48px", + }, + { + id: "64", + label: "64", + value: "64px", + }, + { + id: "96", + label: "96", + value: "96px", + }, + { + id: "128", + label: "128", + value: "128px", + }, + { + id: "160", + label: "160", + value: "160px", + }, + ], + }, + hideCloseButton: true, + }; +}; diff --git a/apps/tailwind-components-example/src/app/easyblocks-editor/helpers.ts b/apps/tailwind-components-example/src/app/easyblocks-editor/helpers.ts new file mode 100644 index 00000000..176fde03 --- /dev/null +++ b/apps/tailwind-components-example/src/app/easyblocks-editor/helpers.ts @@ -0,0 +1,44 @@ +import { createTailwindcss } from "@mhsdesign/jit-browser-tailwindcss"; + +export function traverseAndExtractClasses(obj: any): string { + let result = ""; + function traverse(obj: any) { + if (Array.isArray(obj)) { + obj.forEach(traverse); + } else if (typeof obj === "object" && obj !== null) { + for (const key in obj) { + if (key.startsWith("__className") || key.startsWith("tw")) { + const value = obj[key]; + if (typeof value === "string") { + result += ` ${value}`; + } else if (typeof value === "object" || Array.isArray(value)) { + result += ` ${JSON.stringify(value)}`; + } + } else { + traverse(obj[key]); + } + } + } + } + + traverse(obj); + return result; +} + +export const tailwind = createTailwindcss({ + tailwindConfig: { + // disable normalize css + theme: { + extend: { + screens: { + sm: `568px`, + md: `768px`, + lg: `992px`, + xl: `1280px`, + "2xl": `1600px`, + }, + }, + }, + corePlugins: { preflight: false }, + }, +}); diff --git a/apps/tailwind-components-example/src/app/easyblocks-editor/layout.tsx b/apps/tailwind-components-example/src/app/easyblocks-editor/layout.tsx new file mode 100644 index 00000000..69c8afdc --- /dev/null +++ b/apps/tailwind-components-example/src/app/easyblocks-editor/layout.tsx @@ -0,0 +1,13 @@ +import "../globals.css"; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/apps/tailwind-components-example/src/app/easyblocks-editor/page.tsx b/apps/tailwind-components-example/src/app/easyblocks-editor/page.tsx new file mode 100644 index 00000000..01709489 --- /dev/null +++ b/apps/tailwind-components-example/src/app/easyblocks-editor/page.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { EasyblocksEditor } from "@easyblocks/editor"; +import { ReactElement, useEffect } from "react"; +import useMemoizedState from "./useMemomizedState"; +import { createTailwindcss } from "@mhsdesign/jit-browser-tailwindcss"; +import { easyblocksConfig } from "./easyblocksConfig"; +import { NoCodeComponentDefinition } from "@easyblocks/core"; +import { tailwind, traverseAndExtractClasses } from "./helpers"; + +const DummyCollectionDefinition: NoCodeComponentDefinition = { + id: "DummyCollection", + label: "DummyCollection", + schema: [], + styles: ({ values }) => { + return { + props: { + tw: { + Root: ``, + }, + }, + }; + }, +}; + +const DummyBannerDefinition: NoCodeComponentDefinition = { + id: "DummyBanner", + label: "DummyBanner", + schema: [ + { + prop: "backgroundColor", + label: "Background Color", + type: "color", + defaultValue: { + tokenId: "white", + }, + }, + { + prop: "padding", + label: "Pading", + type: "space", + }, + { + prop: "Title", + type: "component", + required: true, + accepts: ["@easyblocks/rich-text"], + }, + { + prop: "DummyCollection", + type: "component-collection", + accepts: ["DummyCollection"], + itemFields: [ + { + prop: "backgroundColorDummyCollection", + label: "Background Color Dummy Collection", + type: "color", + defaultValue: { + tokenId: "white", + }, + }, + ], + }, + ], + styles: ({ values }) => { + const DummyCollectionStyles = values.DummyCollection.map( + (dc: any) => `bg-[${dc.backgroundColorDummyCollection}]` + ); + + return { + props: { + tw: { + Root: `bg-[${values.backgroundColor}] p-[${values.padding}]`, + DummyCollectionOuterStyle: + DummyCollectionStyles.length > 0 + ? DummyCollectionStyles + : [undefined], + }, + }, + }; + }, +}; + +function DummyBanner(props: { + Root: ReactElement; + Title: ReactElement; + DummyCollection: Array; + DummyCollectionOuterStyle: Array; +}) { + const { + Root, + Title, + DummyCollection, + DummyCollectionOuterStyle: dummyCollectionOuterStyle, + } = props; + + return ( + + + {DummyCollection.map((DummyComponent, index) => { + const DummyCollectionOuterStyle = dummyCollectionOuterStyle[index]; + + return ( + + + + ); + })} + + ); +} + +function DummyCollection(props: { Root: ReactElement }) { + const { Root } = props; + + return This is a collection; +} + +export default function EasyblocksEditorPage() { + const [css, setCss] = useMemoizedState(""); + + useEffect(() => { + console.log("CSS Updated"); + }, [css]); + + useEffect(() => { + const renderTailwindCss = (eventType: string) => { + if (eventType === "renderableContent" || eventType === "meta") { + const createTailwind = async () => { + const stringForTailwind = traverseAndExtractClasses( + window.parent.editorWindowAPI?.compiled + ); + const generatedCss = await tailwind.generateStylesFromContent( + ` + /* without the "@tailwind base;" */ + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + [stringForTailwind] + ); + setCss(generatedCss); + }; + createTailwind(); + } + }; + + // poll for the editor window api to be ready + const interval = setInterval(() => { + if (window.parent.editorWindowAPI && window.parent != window.self) { + clearInterval(interval); + window.parent.editorWindowAPI.subscribe(renderTailwindCss); + renderTailwindCss("renderableContent"); // trigger the first time + } + }, 100); + }, []); + + return ( +
+ + +
+ ); +} diff --git a/apps/tailwind-components-example/src/app/easyblocks-editor/useMemomizedState.ts b/apps/tailwind-components-example/src/app/easyblocks-editor/useMemomizedState.ts new file mode 100644 index 00000000..713e9cf9 --- /dev/null +++ b/apps/tailwind-components-example/src/app/easyblocks-editor/useMemomizedState.ts @@ -0,0 +1,20 @@ +import { useState } from "react"; +import { isEqual } from "lodash"; + +const useMemoizedState = (initialValue: T): [T, (val: T) => void] => { + const [state, _setState] = useState(initialValue); + + const setState = (newState: T) => { + _setState((prev) => { + if (!isEqual(newState, prev)) { + return newState; + } else { + return prev; + } + }); + }; + + return [state, setState]; +}; + +export default useMemoizedState; diff --git a/apps/tailwind-components-example/src/app/favicon.ico b/apps/tailwind-components-example/src/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/apps/tailwind-components-example/src/app/favicon.ico differ diff --git a/apps/tailwind-components-example/src/app/globals.css b/apps/tailwind-components-example/src/app/globals.css new file mode 100644 index 00000000..bbab8b0d --- /dev/null +++ b/apps/tailwind-components-example/src/app/globals.css @@ -0,0 +1,5 @@ +body { + -webkit-font-smoothing: antialiased; + margin: 0; + padding: 0; +} diff --git a/apps/tailwind-components-example/src/typings/globals.d.ts b/apps/tailwind-components-example/src/typings/globals.d.ts new file mode 100644 index 00000000..ac109855 --- /dev/null +++ b/apps/tailwind-components-example/src/typings/globals.d.ts @@ -0,0 +1,8 @@ +import { EditorWindowAPI } from "@SweenyStudio/easyblocks-editor"; + +declare global { + interface Window { + editorWindowAPI: EditorWindowAPI; + isShopstoryEditor?: boolean; + } +} diff --git a/apps/tailwind-components-example/tailwind.config.js b/apps/tailwind-components-example/tailwind.config.js new file mode 100644 index 00000000..23cc8692 --- /dev/null +++ b/apps/tailwind-components-example/tailwind.config.js @@ -0,0 +1,43 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", + "./src/components/**/*.{js,ts,jsx,tsx,mdx}", + "./src/app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + colors: { + "black-1": "#252525", + "black-2": "#4f4f4f", + "white-1": "#f9f8f3", + "white-2": "#bdbdbd", + beige: "#f1f0ea", + "grey-1": "#a0a09d", + "grey-2": "#4F4F4F", + "neutral-50": "#fafafa", + "neutral-100": "#f5f5f5", + "neutral-200": "#e5e5e5", + "neutral-300": "#d4d4d4", + "neutral-400": "#a3a3a3", + "neutral-500": "#737373", + "neutral-600": "#525252", + "neutral-700": "#404040", + "neutral-800": "#262626", + "neutral-900": "#171717", + "neutral-950": "#0a0a0a", + }, + fontFamily: { + sans: ["test-national-2", "sans-serif"], + serif: ["serif"], + mono: ["test-soehne-mono"], + }, + }, + plugins: [], +}; diff --git a/apps/tailwind-components-example/tsconfig.json b/apps/tailwind-components-example/tsconfig.json new file mode 100644 index 00000000..9dbd4dd0 --- /dev/null +++ b/apps/tailwind-components-example/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + "./src/typings/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/packages/core/package.json b/packages/core/package.json index 9009a55a..a6a1d59c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,7 +24,8 @@ "slate": "0.77.2", "slate-react": "0.77.2", "type-fest": "^3.0.0", - "zod": "^3.21.4" + "zod": "^3.21.4", + "tailwind-merge": "^2.3.0" }, "devDependencies": { "@babel/core": "^7.12.10", diff --git a/packages/core/src/compiler/compileComponent.ts b/packages/core/src/compiler/compileComponent.ts index 9ab07d22..c0a32e6e 100644 --- a/packages/core/src/compiler/compileComponent.ts +++ b/packages/core/src/compiler/compileComponent.ts @@ -83,6 +83,7 @@ import { InternalRenderableComponentDefinition, } from "./types"; import { getFallbackLocaleForLocale } from "../locales"; +import { twMerge } from "tailwind-merge"; type ComponentCompilationArtifacts = { compiledComponentConfig: CompiledComponentConfig; @@ -652,6 +653,230 @@ export function compileComponent( cache ); + if (compiled.props.tw) { + if (compiled.props.tw.$res) { + // handle when the component is res + // remove the $res property from the object and conver it to an array + const nonRes = Object.entries(compiled.props.tw).filter( + ([key]) => key != "$res" + ); + + // flatten the components + interface Flatten { + [key: string]: { + [key: string]: any; + }; + } + + let flattened: Flatten = {}; + + // First we need to flatten the object so that arrays of components become single components that we will + // later convert back to arrays + nonRes.forEach(([key, value]) => { + const size = key; + Object.entries(value as any).forEach(([componentName, classes]) => { + if (Array.isArray(classes)) { + // when the property is an array it means we're dealing with an array of components + // we flatten these to individual components with an IDX key to simplify the logic + // we will convert this back to an array at the end + classes.forEach((c, idx) => { + flattened = { + ...flattened, + [size]: { + ...flattened[size], + [`${componentName}-IDX${idx}`]: c, + }, + }; + }); + } else { + // Normal component (not Array) + flattened = { + ...flattened, + [size]: { + ...flattened[size], + [componentName]: classes as string, + }, + }; + } + }); + }); + + interface Reversed { + [key: string]: { + [key: string]: any; + }; + } + + const reversed: Reversed = {}; + + // switch to be by component an size instead of by size and component + Object.entries(flattened).forEach(([size, component]) => { + Object.entries(component).forEach(([name, classes]) => { + if (!reversed[name]) { + reversed[name] = {}; + } + reversed[name] = { + ...reversed[name], + [size]: classes, + }; + }); + }); + + interface Component { + name: string; + sizes: { + id: string; + classList: string[]; + }[]; + className: string; + } + + const finalComponents: { name: string; className: string }[] = []; + + const sortedDevices = meta.vars.devices.sort( + (a: any, b: any) => a.width - b.width + ) as { id: string }[]; + + Object.entries(reversed).forEach(([componentName, sizes]) => { + let component: Component = { + name: componentName, + sizes: [], + className: "", + }; + + for (const d of sortedDevices) { + if (!sizes[d.id]) { + continue; + } + const classes = sizes[d.id]; + const mergedClasses = twMerge(classes); + const classList = mergedClasses.split(" "); + if (component.sizes.length === 0) { + component = { + name: componentName, + sizes: [{ id: d.id, classList }], + className: mergedClasses, + }; + } else { + const newClassList: string[] = []; + classList.forEach((c) => { + // if (component === undefined) return; + let abort = false; + for ( + let i = component.sizes.length - 1; + i >= 0 && abort === false; + i-- + ) { + const thisSize = component.sizes[i]; + const thisSizeClasslist = thisSize.classList; + // classList.forEach((c) => { + if (thisSizeClasslist.indexOf(c) != -1) { + // the exact class is already in the previous size so we can skip it for this size + abort = true; + return; + } + + // first merge the size we're looking at with this class then remove this class + // this will be used to tell if a class was dropped from the initial list + // if it's dropped it means that twMerge would have superceded it so we can include + // it in our class list for this size + const twMergeResult = twMerge([...thisSizeClasslist, c]) + .split(" ") + .filter((f) => f != c); + const missingClasses = thisSizeClasslist.filter( + (f) => !twMergeResult.includes(f) + ); + if (missingClasses.length > 0) { + abort = true; + newClassList.push(c); + } + // }); + } + }); + if (newClassList.length > 0) { + component.sizes.push({ id: d.id, classList: newClassList }); + component.className = `${component.className} ${newClassList + .map((c) => `${d.id}:${c}`) + .join(" ")}`; + } + } + } + + if (component) { + finalComponents.push({ + name: component.name, + className: twMerge(component.className), + }); + } + }); + + // convert array components back to arrays + + const finalComponentsContainingIDX = finalComponents.filter((c) => + c.name.includes("IDX") + ); + const finalComponentsWithoutIDX = finalComponents.filter( + (c) => !c.name.includes("IDX") + ); + + interface finalComponentsWithArrays { + name: string; + className: string | string[]; + } + + const finalComponentsWithArrays: finalComponentsWithArrays[] = [ + ...finalComponentsWithoutIDX, + ]; + + const uniqueFinalComponentsWithIDXNames = finalComponentsContainingIDX + .map((c) => c.name.split("-IDX")[0]) + .filter((v, i, a) => a.indexOf(v) === i); + + for (const name of uniqueFinalComponentsWithIDXNames) { + const components = finalComponentsContainingIDX.filter((c) => + c.name.startsWith(name) + ); + + const newComponent = { + name, + className: components.map((c) => c.className), + }; + + finalComponentsWithArrays.push(newComponent); + } + + for (const component of finalComponentsWithArrays) { + if (Array.isArray(component.className)) { + component.className.forEach((val, idx) => { + compiled.styled[component.name][idx].__className = val; + }); + } else { + compiled.styled[component.name].__className = component.className; + } + } + } else { + Object.keys(compiled.props.tw).forEach((key) => { + if (Array.isArray(compiled.styled[key])) { + compiled.styled[key].forEach((styledComponent: any, idx: number) => { + if (!styledComponent) { + throw new Error( + `Tailwind component ${key} is not defined in the styled object for the component ${compiled._component}` + ); + } + styledComponent.__className = compiled.props.tw[key][idx]; + }); + } else { + if (!compiled.styled[key]) { + throw new Error( + `Tailwind component ${key} is not defined in the styled object for the component ${compiled._component}` + ); + } + compiled.styled[key].__className = compiled.props.tw[key]; + } + }); + } + } + cache.set(ownProps.values._id, { values: ownProps, valuesAfterAuto: ownPropsAfterAuto!, diff --git a/packages/core/src/compiler/resop.ts b/packages/core/src/compiler/resop.ts index 19f7650a..8f994e33 100644 --- a/packages/core/src/compiler/resop.ts +++ b/packages/core/src/compiler/resop.ts @@ -350,8 +350,29 @@ export function resop2( const scalarOutputs: Record = {}; // run callback for scalar configs + devices.forEach((device) => { scalarOutputs[device.id] = callback(scalarInputs[device.id], device.id); + + // add styles for twProps - this is so we can optionally + // leave the styles object empty in the schema + if (scalarOutputs[device.id].props?.tw) { + if (!scalarOutputs[device.id].hasOwnProperty("styled")) { + scalarOutputs[device.id].styled = {}; + } + Object.entries(scalarOutputs[device.id].props?.tw).forEach( + ([name, value]) => { + // don't overwrite the style if it exists already + if (!scalarOutputs[device.id].styled?.hasOwnProperty(name)) { + if (typeof value === "string" && value !== null) { + scalarOutputs[device.id].styled![name] = {}; + } else if (Array.isArray(value)) { + scalarOutputs[device.id].styled![name] = value.map((v) => ({})); + } + } + } + ); + } }); /** diff --git a/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx b/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx index e81f8232..1bb2c5d1 100644 --- a/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx +++ b/packages/core/src/components/ComponentBuilder/ComponentBuilder.tsx @@ -447,6 +447,30 @@ function ComponentBuilder(props: ComponentBuilderProps): ReactElement | null { isSelected: __isSelected, }; + // move the __className property into the className prop for tw styled components + // this is used by tailwind components + Object.keys(styled).forEach((key) => { + if (Array.isArray(styled[key])) { + const newArray = styled[key].map((element: any) => { + if (element?.props?.__compiled?.__className) { + const className = element.props.__compiled.__className; + return React.cloneElement(element, { + className: className, + }); + } + return element; + }); + styled[key] = newArray; + } else { + if (styled[key]?.props?.__compiled?.__className) { + const className = styled[key].props.__compiled.__className; + styled[key] = React.cloneElement(styled[key], { + className: className, + }); + } + } + }); + const componentProps = { ...restPassedProps, ...mapExternalProps( diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 8d34002a..2cfd9449 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -35,12 +35,7 @@ import { traverseComponents, } from "@easyblocks/core/_internals"; import { Colors, Fonts, useToaster } from "@easyblocks/design-system"; -import { - assertDefined, - dotNotationGet, - uniqueId, - useForceRerender, -} from "@easyblocks/utils"; +import { dotNotationGet, uniqueId, useForceRerender } from "@easyblocks/utils"; import throttle from "lodash.throttle"; import React, { ComponentType, @@ -516,7 +511,7 @@ function calculateViewportRelatedStuff( // Calculate width, height and scale let width, height: number; let scaleFactor: number | null = null; - let offsetY: number = 0; + let offsetY = 0; if (!availableSize) { // lack of available size (first render) should wait until size is available to perform calculations @@ -859,6 +854,21 @@ const EditorContent = ({ window.editorWindowAPI.meta = meta; window.editorWindowAPI.compiled = renderableContent; window.editorWindowAPI.externalData = externalData; + window.editorWindowAPI.onUpdateCallbacks = + window.editorWindowAPI.onUpdateCallbacks || []; + + window.editorWindowAPI.subscribe = (callback) => { + window.editorWindowAPI.onUpdateCallbacks && + window.editorWindowAPI.onUpdateCallbacks.push(callback); + }; + + window.editorWindowAPI.unsubscribe = (callback) => { + if (!window.editorWindowAPI.onUpdateCallbacks) return; + const index = window.editorWindowAPI.onUpdateCallbacks.indexOf(callback); + if (index !== -1) { + window.editorWindowAPI.onUpdateCallbacks.splice(index, 1); + } + }; useEffect(() => { push({ @@ -879,6 +889,49 @@ const EditorContent = ({ externalData, ]); + // Watch each dependency individually so that we can incrementally update the useEasyblocksEditor hook + useEffect(() => { + window.editorWindowAPI.onUpdateCallbacks && + window.editorWindowAPI.onUpdateCallbacks.forEach((callback) => + callback("renderableContent") + ); + }, [renderableContent]); + + useEffect(() => { + window.editorWindowAPI.onUpdateCallbacks && + window.editorWindowAPI.onUpdateCallbacks.forEach((callback) => + callback("focussedField") + ); + }, [focussedField]); + + useEffect(() => { + window.editorWindowAPI.onUpdateCallbacks && + window.editorWindowAPI.onUpdateCallbacks.forEach((callback) => + callback("isEditing") + ); + }, [isEditing]); + + useEffect(() => { + window.editorWindowAPI.onUpdateCallbacks && + window.editorWindowAPI.onUpdateCallbacks.forEach((callback) => + callback("externalData") + ); + }, [externalData]); + + useEffect(() => { + window.editorWindowAPI.onUpdateCallbacks && + window.editorWindowAPI.onUpdateCallbacks.forEach((callback) => + callback("currentViewport") + ); + }, [currentViewport]); + + useEffect(() => { + window.editorWindowAPI.onUpdateCallbacks && + window.editorWindowAPI.onUpdateCallbacks.forEach((callback) => + callback("meta") + ); + }, [meta]); + useEffect(() => { function handleEditorEvents( event: ComponentPickerOpenedEvent | ItemInsertedEvent | ItemMovedEvent diff --git a/packages/editor/src/types.ts b/packages/editor/src/types.ts index 1ec8525d..40ba6690 100644 --- a/packages/editor/src/types.ts +++ b/packages/editor/src/types.ts @@ -97,12 +97,29 @@ export type Template = TemplateBase & { export type FieldMixedValue = { __mixed__: true }; +export type EditorWindowAPICallbackEventType = + | "renderableContent" + | "focussedField" + | "isEditing" + | "externalData" + | "currentViewport" + | "meta"; + export type EditorWindowAPI = { editorContext: EditorContextType; onUpdate?: () => void; // this function will be called by parent window when data is changed, child should "subscribe" to this function meta: CompilationMetadata; compiled: CompiledShopstoryComponentConfig; externalData: ExternalData; + onUpdateCallbacks?: Array< + (eventType: EditorWindowAPICallbackEventType) => void + >; + subscribe: ( + callback: (eventType: EditorWindowAPICallbackEventType) => void + ) => void; + unsubscribe: ( + callback: (eventType: EditorWindowAPICallbackEventType) => void + ) => void; }; export type InternalWidgetComponentProps = Omit<