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<