diff --git a/apps/builder/app/builder/features/command-panel/command-panel.tsx b/apps/builder/app/builder/features/command-panel/command-panel.tsx index 3e4bd4f899b8..7a8150c7ebdb 100644 --- a/apps/builder/app/builder/features/command-panel/command-panel.tsx +++ b/apps/builder/app/builder/features/command-panel/command-panel.tsx @@ -102,6 +102,7 @@ type ComponentOption = { category: TemplateMeta["category"]; icon: undefined | string; order: undefined | number; + firstInstance: { component: string }; }; const getComponentScore = (meta: ComponentOption) => { @@ -158,6 +159,7 @@ const $componentOptions = computed( category, icon: meta.icon, order: meta.order, + firstInstance: { component: name }, }); } for (const [name, meta] of templates) { @@ -188,6 +190,7 @@ const $componentOptions = computed( category: meta.category, icon: meta.icon ?? componentMeta?.icon, order: meta.order, + firstInstance: meta.template.instances[0], }); } componentOptions.sort( @@ -205,7 +208,7 @@ const ComponentOptionsGroup = ({ options }: { options: ComponentOption[] }) => { heading={Components} actions={["add"]} > - {options.map(({ component, label, category, icon }) => { + {options.map(({ component, label, category, icon, firstInstance }) => { return ( { > - + {label}{" "} @@ -265,7 +268,9 @@ const $tagOptions = computed( newInstances.set(childInstance.id, childInstance); newInstances.set(instance.id, { ...instance, - children: [...instance.children, { type: "id", value: childInstance.id }], + // avoid preserving original children to not invalidate tag + // when some descendants do not satisfy content model + children: [{ type: "id", value: childInstance.id }], }); for (const tag of tags) { childInstance.tag = tag; 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 893259bfd7cc..dbe064cd45b7 100644 --- a/apps/builder/app/builder/features/style-panel/shared/model.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/model.tsx @@ -208,7 +208,7 @@ const getDefinedStyles = ({ ]; }; -const $model = computed( +export const $styleObjectModel = computed( [ $styles, $styleSourceSelections, @@ -241,7 +241,7 @@ const $model = computed( export const $computedStyleDeclarations = computed( [ - $model, + $styleObjectModel, $selectedInstancePathWithRoot, $selectedOrLastStyleSourceSelector, $registeredComponentMetas, @@ -331,7 +331,11 @@ export const $availableColorVariables = computed( export const createComputedStyleDeclStore = (property: CssProperty) => { return computed( - [$model, $selectedInstancePathWithRoot, $selectedOrLastStyleSourceSelector], + [ + $styleObjectModel, + $selectedInstancePathWithRoot, + $selectedOrLastStyleSourceSelector, + ], (model, instancePath, styleSourceSelector) => { return getComputedStyleDecl({ model, @@ -345,7 +349,7 @@ export const createComputedStyleDeclStore = (property: CssProperty) => { }; export const useStyleObjectModel = () => { - return useStore($model); + return useStore($styleObjectModel); }; export const useComputedStyleDecl = (property: CssProperty) => { @@ -378,7 +382,7 @@ export const useParentComputedStyleDecl = (property: CssProperty) => { const $store = useMemo( () => computed( - [$model, $closestStylableInstanceSelector], + [$styleObjectModel, $closestStylableInstanceSelector], (model, instanceSelector) => { return getComputedStyleDecl({ model, @@ -397,7 +401,7 @@ export const getInstanceStyleDecl = ( instanceSelector: InstanceSelector ) => { return getComputedStyleDecl({ - model: $model.get(), + model: $styleObjectModel.get(), instanceSelector, property, }); diff --git a/apps/builder/app/builder/features/topbar/publish.tsx b/apps/builder/app/builder/features/topbar/publish.tsx index 5693bc64af70..673aa5ffe3ed 100644 --- a/apps/builder/app/builder/features/topbar/publish.tsx +++ b/apps/builder/app/builder/features/topbar/publish.tsx @@ -8,6 +8,7 @@ import { startTransition, useRef, useId, + type ReactNode, } from "react"; import { useStore } from "@nanostores/react"; import { @@ -81,6 +82,10 @@ import DomainCheckbox, { domainToPublishName } from "./domain-checkbox"; import { CopyToClipboard } from "~/builder/shared/copy-to-clipboard"; import { $openProjectSettings } from "~/shared/nano-states/project-settings"; import { RelativeTime } from "~/builder/shared/relative-time"; +import { showAttribute } from "@webstudio-is/react-sdk"; +import { toValue, type CssProperty } from "@webstudio-is/css-engine"; +import { getComputedStyleDecl } from "~/shared/style-object-model"; +import { $styleObjectModel } from "../style-panel/shared/model"; import cmsUpgradeBanner from "../settings-panel/cms-upgrade-banner.svg?url"; type ChangeProjectDomainProps = { @@ -221,11 +226,11 @@ const ChangeProjectDomain = ({ }; const $usedProFeatures = computed( - [$pages, $dataSources, $instances, $propsIndex], - (pages, dataSources, instances, propsIndex) => { + [$pages, $dataSources, $instances, $propsIndex, $styleObjectModel], + (pages, dataSources, instances, propsIndex, styleObjectModel) => { const features = new Map< string, - undefined | { awareness?: Awareness; info?: string } + undefined | { awareness?: Awareness; info?: ReactNode } >(); if (pages === undefined) { return features; @@ -273,7 +278,22 @@ const $usedProFeatures = computed( const badgeFeature = 'No "Built with Webstudio" badge'; // Badge should be rendered on free sites on every page. features.set(badgeFeature, { - info: "Adding the badge to your homepage helps us offer a free version of the service. Please open the Components panel by clicking the “+” icon on the left, and add the “Built with Webstudio” component to your page. Feel free to adjust the badge’s styles to match your design.", + info: ( + + Adding the badge to your "home" page helps us offer a free version of + the service. Please open the Components panel by clicking the “+” icon + on the left, and add the “Built with Webstudio” component to your + page. +
+ - Feel free to adjust the badge's style to match your design - after + all, it's just a link, and you can place it wherever you like. +
+ - Please don’t add that badge to every page, because search engines + will view it negatively. +
- Hiding the link in any way is considered a violation of the + terms. +
+ ), }); // We want to check the badge only on the home page const homePageInstanceIds = findTreeInstanceIds( @@ -285,14 +305,57 @@ const $usedProFeatures = computed( // Find a potential link that looks like a badge. if (instance?.tag === "a") { const props = propsIndex.propsByInstanceId.get(instance.id); + let hasWsHref = false; + let highTrust = true; + let show = true; + for (const prop of props ?? []) { if ( prop.name === "href" && - prop.value === "https://webstudio.is/?via=badge" + prop.type === "string" && + prop.value.includes("https://webstudio.is") ) { - features.delete(badgeFeature); + hasWsHref = true; + } + if (prop.name === "rel" && prop.type === "string") { + if ( + prop.value.includes("nofollow") || + prop.value.includes("ugc") || + prop.value.includes("sponsored") + ) { + highTrust = false; + } + } + if (prop.name === showAttribute) { + show = prop.type === "boolean" && prop.value; } } + + const getValue = (property: CssProperty) => { + return toValue( + getComputedStyleDecl({ + model: styleObjectModel, + instanceSelector: [instance.id], + property, + }).usedValue + ); + }; + + // Check styles. + if ( + getValue("display") === "none" || + getValue("visibility") === "hidden" || + getValue("opacity") === "0" || + getValue("opacity") === "0%" + ) { + show = false; + } + + // @todo check all parents + if (hasWsHref && highTrust && show) { + features.delete(badgeFeature); + break; + } } } return features; @@ -711,6 +774,108 @@ const buttonLinkClass = css({ ...textVariants.link, }).toString(); +const UpgradeBanner = () => { + const usedProFeatures = useStore($usedProFeatures); + const { canAddDomain, maxDomainsAllowedPerUser } = useCanAddDomain(); + const { userPublishCount, maxPublishesAllowedPerUser } = + useUserPublishCount(); + + if (userPublishCount >= maxPublishesAllowedPerUser) { + return ( + + + Upgrade to publish more than {maxPublishesAllowedPerUser} times per + day: + + + Upgrade + + + ); + } + + if (usedProFeatures.size > 0) { + return ( + + Upgrade for CMS + Following Pro features are used: + + {Array.from(usedProFeatures).map( + ([message, { awareness, info } = {}], index) => ( +
  • + + {awareness ? ( + + ) : ( + message + )} + {info && ( + + } /> + + )} + +
  • + ) + )} +
    + You can delete these features or upgrade. + + + + Upgrade to Pro + + +
    + ); + } + if (canAddDomain === false) { + return ( + + Free domains limit reached + + You have reached the limit of {maxDomainsAllowedPerUser} custom + domains on your account.{" "} + + Upgrade to a Pro account + {" "} + to add unlimited domains and publish to each domain individually. + + + Upgrade + + + ); + } +}; + const Content = (props: { projectId: Project["id"]; onExportClick: () => void; @@ -726,99 +891,12 @@ const Content = (props: { } const projectState = "idle"; - const { canAddDomain, maxDomainsAllowedPerUser } = useCanAddDomain(); const { userPublishCount, maxPublishesAllowedPerUser } = useUserPublishCount(); return (
    - {userPublishCount >= maxPublishesAllowedPerUser ? ( - - - Upgrade to publish more than {maxPublishesAllowedPerUser} times - per day: - - - Upgrade - - - ) : usedProFeatures.size > 0 && hasProPlan === false ? ( - - Upgrade for CMS - - Upgrade to publish with following features: - - - {Array.from(usedProFeatures).map( - ([message, { awareness, info } = {}], index) => ( -
  • - - {awareness ? ( - - ) : ( - message - )} - {info && ( - - } /> - - )} - -
  • - ) - )} -
    - - - - Upgrade to Pro - - -
    - ) : canAddDomain === false ? ( - - Free domains limit reached - - You have reached the limit of {maxDomainsAllowedPerUser} custom - domains on your account.{" "} - - Upgrade to a Pro account - {" "} - to add unlimited domains and publish to each domain individually. - - - Upgrade - - - ) : null} + {hasProPlan === false && } { if ($isPreviewMode.get()) { return; } - // prevent typing in inputs only in canvas mode - event.preventDefault(); + if ( + event.target instanceof HTMLInputElement || + event.target instanceof HTMLTextAreaElement + ) { + // prevent typing in inputs only in canvas mode + event.preventDefault(); + } }; // Note: Event handlers behave unexpectedly when used inside a dialog component. diff --git a/apps/builder/app/shared/content-model.test.tsx b/apps/builder/app/shared/content-model.test.tsx index 58fa198a02bf..8f6ec62bce38 100644 --- a/apps/builder/app/shared/content-model.test.tsx +++ b/apps/builder/app/shared/content-model.test.tsx @@ -489,6 +489,38 @@ test("edge case: support a > img", () => { ).toBeTruthy(); }); +test("support video > source", () => { + expect( + isTreeSatisfyingContentModel({ + ...renderData( + + + + + + ), + metas: defaultMetas, + instanceSelector: ["bodyId"], + }) + ).toBeTruthy(); +}); + +test("support xml node with tags", () => { + expect( + isTreeSatisfyingContentModel({ + ...renderData( + + <$.XmlNode tag="url"> + <$.XmlNode tag="loc"> + + + ), + metas: defaultMetas, + instanceSelector: ["bodyId"], + }) + ).toBeTruthy(); +}); + describe("component content model", () => { test("restrict children with specific component", () => { expect( diff --git a/apps/builder/app/shared/content-model.ts b/apps/builder/app/shared/content-model.ts index 97751828708a..2f0b40195ef6 100644 --- a/apps/builder/app/shared/content-model.ts +++ b/apps/builder/app/shared/content-model.ts @@ -37,6 +37,10 @@ const getTag = ({ metas: Metas; props: Props; }) => { + // ignore tag property on xml nodes + if (instance.component === "XmlNode") { + return; + } const meta = metas.get(instance.component); const metaTag = Object.keys(meta?.presetStyle ?? {}).at(0); return instance.tag ?? getTagByInstanceId(props).get(instance.id) ?? metaTag; @@ -114,7 +118,10 @@ const getElementChildren = ( let elementChildren: string[] = tag === undefined ? ["transparent"] : elementsByTag[tag].children; if (elementChildren.includes("transparent") && allowedCategories) { - elementChildren = allowedCategories; + // merge categories from parent and current element when transparent occured + elementChildren = elementChildren.flatMap((category) => + category === "transparent" ? allowedCategories : category + ); } // introduce custom non-interactive category to restrict nesting interactive elements // like button > button or a > input diff --git a/apps/builder/app/shared/sync-client.ts b/apps/builder/app/shared/sync-client.ts index 8b730b846f3e..6d1e9d0d82a5 100644 --- a/apps/builder/app/shared/sync-client.ts +++ b/apps/builder/app/shared/sync-client.ts @@ -67,10 +67,15 @@ export class ImmerhinSyncObject implements SyncObject { } setState(state: Map) { for (const [namespace, $store] of this.store.containers) { - // Immer cannot handle Map instances from another realm. - // Use `clone` to recreate the data with the current realm's classes. - // This works because the structured clone algorithm skips prototype chains; classes must be defined in both realms. - $store.set(structuredClone(state.get(namespace))); + // catch errors triggered by CSP configuration when user put iframe onto canvas + try { + // Immer cannot handle Map instances from another realm. + // Use `clone` to recreate the data with the current realm's classes. + // This works because the structured clone algorithm skips prototype chains; classes must be defined in both realms. + $store.set(structuredClone(state.get(namespace))); + } catch { + // empty block + } } } applyTransaction(transaction: Transaction) { @@ -112,10 +117,15 @@ export class NanostoresSyncObject implements SyncObject { } applyTransaction(transaction: Transaction) { this.operation = "add"; - // `instanceof` checks do not work with instances like Map, File, etc., from another realm. - // Use `clone` to recreate the data with the current realm's classes. - // This works because the structured clone algorithm skips prototype chains; classes must be defined in both realms. - this.store.set(structuredClone(transaction.payload)); + // catch errors triggered by CSP configuration when user put iframe onto canvas + try { + // `instanceof` checks do not work with instances like Map, File, etc., from another realm. + // Use `clone` to recreate the data with the current realm's classes. + // This works because the structured clone algorithm skips prototype chains; classes must be defined in both realms. + this.store.set(structuredClone(transaction.payload)); + } catch { + // empty block + } this.operation = "local"; } revertTransaction(_transaction: RevertedTransaction) { diff --git a/packages/sdk/src/__generated__/normalize.css.ts b/packages/sdk/src/__generated__/normalize.css.ts index 9a354d1a28c0..4f9880900841 100644 --- a/packages/sdk/src/__generated__/normalize.css.ts +++ b/packages/sdk/src/__generated__/normalize.css.ts @@ -141,6 +141,7 @@ export const body: StyleDecl[] = [ export const hr: StyleDecl[] = [ { property: "height", value: { type: "unit", unit: "number", value: 0 } }, { property: "color", value: { type: "keyword", value: "inherit" } }, + { property: "box-sizing", value: { type: "keyword", value: "border-box" } }, ]; export const b: StyleDecl[] = [ diff --git a/packages/sdk/src/normalize.css b/packages/sdk/src/normalize.css index 96185b8198dd..f5258beaa5f6 100644 --- a/packages/sdk/src/normalize.css +++ b/packages/sdk/src/normalize.css @@ -99,12 +99,15 @@ body { /** * 1. Add the correct height in Firefox. * 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) + * 3. width: 100% inside flexbox will overflow
    out of it */ hr { /* 1 */ height: 0; /* 2 */ color: inherit; + /* 3 */ + box-sizing: border-box; } /**