Files
webstudio/apps/builder/app/shared/nano-states/components.ts
T
Oleg Isonen 76e4b467c5 feat: allow small screen size in preview (#3897)
## Description

A user wants to be able to test in preview mode using dev tools screen
simulation.

## Steps for reproduction

1. resize the window to mobile size
2. see there is no. "too small alert"
3. see under 640px topbar elements got reduced
4. see that canvas is almost full width

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
5de6)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
2024-08-09 17:55:08 +02:00

249 lines
7.6 KiB
TypeScript

import { shallowEqual } from "shallow-equal";
import type { ExoticComponent } from "react";
import { atom } from "nanostores";
import {
type AnyComponent,
type WsComponentMeta,
type WsComponentPropsMeta,
type Hook,
type HookContext,
namespaceMeta,
getIndexesWithinAncestors,
} from "@webstudio-is/react-sdk";
import type { Instance } from "@webstudio-is/sdk";
import { decodeDataSourceVariable } from "@webstudio-is/sdk";
import type { InstanceSelector } from "../tree-utils";
import { $dataSources, $memoryProps, $props } from "./misc";
import { $instances, $selectedInstanceSelector } from "./instances";
import { $selectedPage } from "./pages";
import { $dataSourceVariables } from "./variables";
import { nanoid } from "nanoid";
const createHookContext = (): HookContext => {
const metas = $registeredComponentMetas.get();
const instances = $instances.get();
const page = $selectedPage.get();
const indexesWithinAncestors = getIndexesWithinAncestors(
metas,
instances,
page ? [page.rootInstanceId] : []
);
return {
indexesWithinAncestors,
getPropValue: (instanceId, propName) => {
const props = $props.get();
for (const prop of props.values()) {
if (prop.instanceId === instanceId && prop.name === propName) {
if (
prop.type === "string" ||
prop.type === "number" ||
prop.type === "boolean" ||
prop.type === "string[]"
) {
return prop.value;
}
}
}
},
setMemoryProp: (instanceSelector, propName, value) => {
if (instanceSelector.length === 0) {
console.error("instanceSelector is empty");
return;
}
const props = $memoryProps.get();
const newProps = props.get(JSON.stringify(instanceSelector)) ?? new Map();
const propBase = {
id: nanoid(),
instanceId: instanceSelector[0]!,
name: propName,
};
if (value !== undefined) {
switch (typeof value) {
case "string":
newProps.set(propName, {
...propBase,
type: "string",
value,
});
break;
case "number":
newProps.set(propName, {
...propBase,
type: "number",
value,
});
break;
case "boolean":
newProps.set(propName, {
...propBase,
type: "boolean",
value,
});
break;
default:
throw new Error(`Unsupported type ${typeof value}`);
}
} else {
newProps.delete(propName);
}
props.set(JSON.stringify(instanceSelector), newProps);
$memoryProps.set(new Map(props));
},
setPropVariable: (instanceId, propName, value) => {
const dataSourceVariables = new Map($dataSourceVariables.get());
const dataSources = new Map($dataSources.get());
const props = $props.get();
for (const prop of props.values()) {
if (
prop.instanceId === instanceId &&
prop.name === propName &&
prop.type === "expression"
) {
// extract id without parsing expression
const potentialVariableId = decodeDataSourceVariable(prop.value);
if (
potentialVariableId !== undefined &&
dataSources.has(potentialVariableId)
) {
const dataSourceId = potentialVariableId;
dataSourceVariables.set(dataSourceId, value);
}
}
}
$dataSourceVariables.set(dataSourceVariables);
},
};
};
// subscribe builder events and invoke all component hooks
export const subscribeComponentHooks = () => {
let lastSelectedInstanceSelector: undefined | InstanceSelector = undefined;
const unsubscribeSelectedInstanceSelector =
$selectedInstanceSelector.subscribe((instanceSelector) => {
// prevent executing hooks when selected instance is not changed
if (shallowEqual(lastSelectedInstanceSelector, instanceSelector)) {
return;
}
const hooks = $registeredComponentHooks.get();
const instances = $instances.get();
if (lastSelectedInstanceSelector) {
for (const hook of hooks) {
hook.onNavigatorUnselect?.(createHookContext(), {
instancePath: lastSelectedInstanceSelector.flatMap((id) => {
const instance = instances.get(id);
return instance ? [instance] : [];
}),
instanceSelector: lastSelectedInstanceSelector,
});
}
}
if (instanceSelector) {
for (const hook of hooks) {
hook.onNavigatorSelect?.(createHookContext(), {
instancePath: instanceSelector.flatMap((id) => {
const instance = instances.get(id);
return instance ? [instance] : [];
}),
instanceSelector,
});
}
}
// store converts values to readonly
lastSelectedInstanceSelector = instanceSelector as InstanceSelector;
});
return () => {
unsubscribeSelectedInstanceSelector();
};
};
export const $registeredComponents = atom(new Map<string, AnyComponent>());
export const $registeredComponentHooks = atom<Hook[]>([]);
export const $registeredComponentMetas = atom(
new Map<string, WsComponentMeta>()
);
export const $registeredComponentPropsMetas = atom(
new Map<string, WsComponentPropsMeta>()
);
export const registerComponentLibrary = ({
namespace,
components,
metas,
propsMetas,
hooks,
}: {
namespace?: string;
// simplify adding component libraries
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components: Record<Instance["component"], ExoticComponent<any>>;
metas: Record<Instance["component"], WsComponentMeta>;
propsMetas: Record<Instance["component"], WsComponentPropsMeta>;
hooks?: Hook[];
}) => {
const prefix = namespace === undefined ? "" : `${namespace}:`;
const prevComponents = $registeredComponents.get();
const nextComponents = new Map(prevComponents);
for (const [componentName, component] of Object.entries(components)) {
nextComponents.set(`${prefix}${componentName}`, component);
}
$registeredComponents.set(nextComponents);
const prevMetas = $registeredComponentMetas.get();
const nextMetas = new Map(prevMetas);
for (const [componentName, meta] of Object.entries(metas)) {
nextMetas.set(
`${prefix}${componentName}`,
namespace === undefined
? meta
: namespaceMeta(meta, namespace, new Set(Object.keys(metas)))
);
}
$registeredComponentMetas.set(nextMetas);
if (hooks) {
const prevHooks = $registeredComponentHooks.get();
const nextHooks = [...prevHooks, ...hooks];
$registeredComponentHooks.set(nextHooks);
}
const prevPropsMetas = $registeredComponentPropsMetas.get();
const nextPropsMetas = new Map(prevPropsMetas);
for (const [componentName, propsMeta] of Object.entries(propsMetas)) {
const { initialProps = [], props } = propsMeta;
const requiredProps: string[] = [];
for (const [name, value] of Object.entries(props)) {
if (value.required && initialProps.includes(name) === false) {
requiredProps.push(name);
}
}
nextPropsMetas.set(`${prefix}${componentName}`, {
// order of initialProps must be preserved
initialProps: [...initialProps, ...requiredProps],
props,
});
}
$registeredComponentPropsMetas.set(nextPropsMetas);
};
export const synchronizedComponentsMetaStores = [
["registeredComponentMetas", $registeredComponentMetas],
["registeredComponentPropsMetas", $registeredComponentPropsMetas],
] as const;