mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-08 13:05:08 +09:00
This fixes the issue with computing data sources logic. insertTemplateData first calls propsStore.set and then dataSourceStore.set which triggers computing 2 times and first time prop refers to not existing data source. Here fixed by setting data sources first and then props.
549 lines
16 KiB
TypeScript
549 lines
16 KiB
TypeScript
import store from "immerhin";
|
|
import { toast } from "@webstudio-is/design-system";
|
|
import {
|
|
type Instance,
|
|
type Instances,
|
|
type StyleSource,
|
|
getStyleDeclKey,
|
|
} from "@webstudio-is/sdk";
|
|
import { findTreeInstanceIdsExcludingSlotDescendants } from "@webstudio-is/sdk";
|
|
import {
|
|
type WsComponentMeta,
|
|
generateDataFromEmbedTemplate,
|
|
type EmbedTemplateData,
|
|
} from "@webstudio-is/react-sdk";
|
|
import {
|
|
propsStore,
|
|
stylesStore,
|
|
selectedInstanceSelectorStore,
|
|
styleSourceSelectionsStore,
|
|
styleSourcesStore,
|
|
instancesStore,
|
|
selectedStyleSourceSelectorStore,
|
|
textEditingInstanceSelectorStore,
|
|
breakpointsStore,
|
|
registeredComponentMetasStore,
|
|
dataSourcesStore,
|
|
} from "./nano-states";
|
|
import {
|
|
type DroppableTarget,
|
|
type InstanceSelector,
|
|
findLocalStyleSourcesWithinInstances,
|
|
insertInstancesMutable,
|
|
reparentInstanceMutable,
|
|
getAncestorInstanceSelector,
|
|
insertPropsCopyMutable,
|
|
} from "./tree-utils";
|
|
import { removeByMutable } from "./array-utils";
|
|
import { isBaseBreakpoint } from "./breakpoints";
|
|
import { getElementByInstanceSelector } from "./dom-utils";
|
|
import { humanizeString } from "./string-utils";
|
|
|
|
const getLabelFromComponentName = (component: Instance["component"]) => {
|
|
if (component.includes(":")) {
|
|
// strip namespace
|
|
const [_namespace, baseName] = component.split(":");
|
|
component = baseName;
|
|
}
|
|
return humanizeString(component);
|
|
};
|
|
|
|
export const getInstanceLabel = (
|
|
instance: { component: string; label?: string },
|
|
meta: WsComponentMeta
|
|
) => {
|
|
return (
|
|
instance.label ||
|
|
meta.label ||
|
|
getLabelFromComponentName(instance.component)
|
|
);
|
|
};
|
|
|
|
export const findClosestEditableInstanceSelector = (
|
|
instanceSelector: InstanceSelector,
|
|
instances: Instances,
|
|
metas: Map<string, WsComponentMeta>
|
|
) => {
|
|
for (const instanceId of instanceSelector) {
|
|
const instance = instances.get(instanceId);
|
|
if (instance === undefined) {
|
|
return;
|
|
}
|
|
const meta = metas.get(instance.component);
|
|
if (meta === undefined) {
|
|
return;
|
|
}
|
|
if (meta.type !== "container") {
|
|
continue;
|
|
}
|
|
// only container with rich-text-child children and text can be edited
|
|
for (const child of instance.children) {
|
|
if (child.type === "id") {
|
|
const childInstance = instances.get(child.value);
|
|
if (childInstance === undefined) {
|
|
return;
|
|
}
|
|
const childMeta = metas.get(childInstance.component);
|
|
if (childMeta?.type !== "rich-text-child") {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
return getAncestorInstanceSelector(instanceSelector, instanceId);
|
|
}
|
|
};
|
|
|
|
export const findClosestDetachableInstanceSelector = (
|
|
instanceSelector: InstanceSelector,
|
|
instances: Instances,
|
|
metas: Map<string, WsComponentMeta>
|
|
) => {
|
|
for (const instanceId of instanceSelector) {
|
|
const instance = instances.get(instanceId);
|
|
if (instance === undefined) {
|
|
return;
|
|
}
|
|
const meta = metas.get(instance.component);
|
|
if (meta === undefined) {
|
|
return;
|
|
}
|
|
const detachable = meta.detachable ?? true;
|
|
if (meta.type !== "container" || detachable === false) {
|
|
continue;
|
|
}
|
|
return getAncestorInstanceSelector(instanceSelector, instanceId);
|
|
}
|
|
};
|
|
|
|
export const isInstanceDetachable = (instanceSelector: InstanceSelector) => {
|
|
const instances = instancesStore.get();
|
|
const metas = registeredComponentMetasStore.get();
|
|
const [instanceId] = instanceSelector;
|
|
const instance = instances.get(instanceId);
|
|
if (instance === undefined) {
|
|
return false;
|
|
}
|
|
const meta = metas.get(instance.component);
|
|
if (meta === undefined) {
|
|
return false;
|
|
}
|
|
return meta.detachable ?? true;
|
|
};
|
|
|
|
const traverseInstancesConstraints = (
|
|
metas: Map<string, WsComponentMeta>,
|
|
instances: Instances,
|
|
instanceId: Instance["id"],
|
|
requiredAncestors: Set<Instance["component"]>,
|
|
invalidAncestors: Set<Instance["component"]>,
|
|
componentSelector: string[] = []
|
|
) => {
|
|
const instance = instances.get(instanceId);
|
|
if (instance === undefined) {
|
|
return;
|
|
}
|
|
const meta = metas.get(instance.component);
|
|
if (meta === undefined) {
|
|
return;
|
|
}
|
|
if (meta.requiredAncestors) {
|
|
for (const requiredAncestor of meta.requiredAncestors) {
|
|
if (componentSelector.includes(requiredAncestor) === false) {
|
|
requiredAncestors.add(requiredAncestor);
|
|
}
|
|
}
|
|
}
|
|
if (meta.invalidAncestors) {
|
|
for (const invalidAncestor of meta.invalidAncestors) {
|
|
invalidAncestors.add(invalidAncestor);
|
|
}
|
|
}
|
|
for (const child of instance.children) {
|
|
if (child.type === "id") {
|
|
traverseInstancesConstraints(
|
|
metas,
|
|
instances,
|
|
child.value,
|
|
requiredAncestors,
|
|
invalidAncestors,
|
|
[instance.component, ...componentSelector]
|
|
);
|
|
}
|
|
}
|
|
};
|
|
|
|
export type InsertConstraints = {
|
|
requiredAncestors: Set<Instance["component"]>;
|
|
invalidAncestors: Set<Instance["component"]>;
|
|
};
|
|
|
|
export const computeInstancesConstraints = (
|
|
metas: Map<string, WsComponentMeta>,
|
|
instances: Instances,
|
|
rootInstanceIds: Instance["id"][]
|
|
): InsertConstraints => {
|
|
const requiredAncestors = new Set<string>();
|
|
const invalidAncestors = new Set<string>();
|
|
for (const instanceId of rootInstanceIds) {
|
|
traverseInstancesConstraints(
|
|
metas,
|
|
instances,
|
|
instanceId,
|
|
requiredAncestors,
|
|
invalidAncestors
|
|
);
|
|
}
|
|
return {
|
|
requiredAncestors,
|
|
invalidAncestors,
|
|
};
|
|
};
|
|
|
|
export const findClosestDroppableComponentIndex = (
|
|
metas: Map<string, WsComponentMeta>,
|
|
componentSelector: string[],
|
|
constraints: InsertConstraints
|
|
) => {
|
|
const { requiredAncestors, invalidAncestors } = constraints;
|
|
|
|
let containerIndex = -1;
|
|
let requiredFound = false;
|
|
for (let index = 0; index < componentSelector.length; index += 1) {
|
|
const ancestorComponent = componentSelector[index];
|
|
if (invalidAncestors.has(ancestorComponent) === true) {
|
|
containerIndex = -1;
|
|
requiredFound = false;
|
|
continue;
|
|
}
|
|
if (requiredAncestors.has(ancestorComponent) === true) {
|
|
requiredFound = true;
|
|
}
|
|
const ancestorMeta = metas.get(ancestorComponent);
|
|
if (containerIndex === -1 && ancestorMeta?.type === "container") {
|
|
containerIndex = index;
|
|
}
|
|
}
|
|
|
|
if (requiredFound || requiredAncestors.size === 0) {
|
|
return containerIndex;
|
|
}
|
|
return -1;
|
|
};
|
|
|
|
export const findClosestDroppableTarget = (
|
|
metas: Map<string, WsComponentMeta>,
|
|
instances: Instances,
|
|
instanceSelector: InstanceSelector,
|
|
insertConstraints: InsertConstraints
|
|
): undefined | DroppableTarget => {
|
|
const componentSelector: string[] = [];
|
|
for (const instanceId of instanceSelector) {
|
|
const component = instances.get(instanceId)?.component;
|
|
if (component === undefined) {
|
|
return;
|
|
}
|
|
componentSelector.push(component);
|
|
}
|
|
|
|
const droppableIndex = findClosestDroppableComponentIndex(
|
|
metas,
|
|
componentSelector,
|
|
insertConstraints
|
|
);
|
|
if (droppableIndex === -1) {
|
|
return;
|
|
}
|
|
if (droppableIndex === 0) {
|
|
return {
|
|
parentSelector: instanceSelector,
|
|
position: "end",
|
|
};
|
|
}
|
|
|
|
const dropTargetSelector = instanceSelector.slice(droppableIndex);
|
|
const dropTargetInstanceId = instanceSelector[droppableIndex];
|
|
const dropTargetInstance = instances.get(dropTargetInstanceId);
|
|
if (dropTargetInstance === undefined) {
|
|
return;
|
|
}
|
|
const lastChildInstanceId = instanceSelector[droppableIndex - 1];
|
|
const lastChildPosition = dropTargetInstance.children.findIndex(
|
|
(child) => child.type === "id" && child.value === lastChildInstanceId
|
|
);
|
|
return {
|
|
parentSelector: dropTargetSelector,
|
|
position: lastChildPosition + 1,
|
|
};
|
|
};
|
|
|
|
export const insertTemplateData = (
|
|
templateData: EmbedTemplateData,
|
|
dropTarget: DroppableTarget
|
|
) => {
|
|
const {
|
|
children,
|
|
instances: insertedInstances,
|
|
props: insertedProps,
|
|
dataSources: insertedDataSources,
|
|
} = templateData;
|
|
const rootInstanceId = insertedInstances[0].id;
|
|
store.createTransaction(
|
|
[
|
|
instancesStore,
|
|
// insert data sources before props to avoid error
|
|
// about missing data source when compute data source logic
|
|
dataSourcesStore,
|
|
propsStore,
|
|
styleSourceSelectionsStore,
|
|
styleSourcesStore,
|
|
stylesStore,
|
|
],
|
|
(
|
|
instances,
|
|
dataSources,
|
|
props,
|
|
styleSourceSelections,
|
|
styleSources,
|
|
styles
|
|
) => {
|
|
insertInstancesMutable(
|
|
instances,
|
|
props,
|
|
registeredComponentMetasStore.get(),
|
|
insertedInstances,
|
|
children,
|
|
dropTarget
|
|
);
|
|
insertPropsCopyMutable(props, insertedProps, new Map(), new Map());
|
|
for (const dataSource of insertedDataSources) {
|
|
dataSources.set(dataSource.id, dataSource);
|
|
}
|
|
|
|
// insert only new style sources and their styles to support
|
|
// embed template tokens which have persistent id
|
|
// so when user changes these styles and then again add component with token
|
|
// nothing breaks visually
|
|
const insertedStyleSources = new Set<StyleSource["id"]>();
|
|
for (const styleSource of templateData.styleSources) {
|
|
if (styleSources.has(styleSource.id) === false) {
|
|
insertedStyleSources.add(styleSource.id);
|
|
styleSources.set(styleSource.id, styleSource);
|
|
}
|
|
}
|
|
for (const styleDecl of templateData.styles) {
|
|
if (insertedStyleSources.has(styleDecl.styleSourceId)) {
|
|
styles.set(getStyleDeclKey(styleDecl), styleDecl);
|
|
}
|
|
}
|
|
for (const styleSourceSelection of templateData.styleSourceSelections) {
|
|
styleSourceSelections.set(
|
|
styleSourceSelection.instanceId,
|
|
styleSourceSelection
|
|
);
|
|
}
|
|
}
|
|
);
|
|
|
|
selectedInstanceSelectorStore.set([
|
|
rootInstanceId,
|
|
...dropTarget.parentSelector,
|
|
]);
|
|
selectedStyleSourceSelectorStore.set(undefined);
|
|
};
|
|
|
|
export const getComponentTemplateData = (component: string) => {
|
|
const metas = registeredComponentMetasStore.get();
|
|
const componentMeta = metas.get(component);
|
|
// when template not specified fallback to template with the component
|
|
const template = componentMeta?.template ?? [
|
|
{
|
|
type: "instance",
|
|
component,
|
|
children: [],
|
|
},
|
|
];
|
|
const breakpoints = breakpointsStore.get();
|
|
const breakpointValues = Array.from(breakpoints.values());
|
|
const baseBreakpoint = breakpointValues.find(isBaseBreakpoint);
|
|
if (baseBreakpoint === undefined) {
|
|
return;
|
|
}
|
|
return generateDataFromEmbedTemplate(template, metas, baseBreakpoint.id);
|
|
};
|
|
|
|
export const reparentInstance = (
|
|
targetInstanceSelector: InstanceSelector,
|
|
dropTarget: DroppableTarget
|
|
) => {
|
|
store.createTransaction([instancesStore, propsStore], (instances, props) => {
|
|
reparentInstanceMutable(
|
|
instances,
|
|
props,
|
|
registeredComponentMetasStore.get(),
|
|
targetInstanceSelector,
|
|
dropTarget
|
|
);
|
|
});
|
|
selectedInstanceSelectorStore.set(targetInstanceSelector);
|
|
selectedStyleSourceSelectorStore.set(undefined);
|
|
};
|
|
|
|
export const deleteInstance = (instanceSelector: InstanceSelector) => {
|
|
if (isInstanceDetachable(instanceSelector) === false) {
|
|
toast.error(
|
|
"This instance can not be moved outside of its parent component."
|
|
);
|
|
return;
|
|
}
|
|
store.createTransaction(
|
|
[
|
|
instancesStore,
|
|
propsStore,
|
|
styleSourceSelectionsStore,
|
|
styleSourcesStore,
|
|
stylesStore,
|
|
dataSourcesStore,
|
|
],
|
|
(
|
|
instances,
|
|
props,
|
|
styleSourceSelections,
|
|
styleSources,
|
|
styles,
|
|
dataSources
|
|
) => {
|
|
let targetInstanceId = instanceSelector[0];
|
|
const parentInstanceId = instanceSelector[1];
|
|
const grandparentInstanceId = instanceSelector[2];
|
|
let parentInstance =
|
|
parentInstanceId === undefined
|
|
? undefined
|
|
: instances.get(parentInstanceId);
|
|
|
|
// delete parent fragment too if its last child is going to be deleted
|
|
// use case for slots: slot became empty and remove display: contents
|
|
// to be displayed properly on canvas
|
|
if (
|
|
parentInstance?.component === "Fragment" &&
|
|
parentInstance.children.length === 1 &&
|
|
grandparentInstanceId !== undefined
|
|
) {
|
|
targetInstanceId = parentInstance.id;
|
|
parentInstance = instances.get(grandparentInstanceId);
|
|
}
|
|
|
|
const instanceIds = findTreeInstanceIdsExcludingSlotDescendants(
|
|
instances,
|
|
targetInstanceId
|
|
);
|
|
const localStyleSourceIds = findLocalStyleSourcesWithinInstances(
|
|
styleSources.values(),
|
|
styleSourceSelections.values(),
|
|
instanceIds
|
|
);
|
|
|
|
// may not exist when delete root
|
|
if (parentInstance) {
|
|
removeByMutable(
|
|
parentInstance.children,
|
|
(child) => child.type === "id" && child.value === targetInstanceId
|
|
);
|
|
}
|
|
|
|
for (const instanceId of instanceIds) {
|
|
instances.delete(instanceId);
|
|
}
|
|
// delete props, data sources and styles of deleted instance and its descendants
|
|
for (const prop of props.values()) {
|
|
if (instanceIds.has(prop.instanceId)) {
|
|
props.delete(prop.id);
|
|
}
|
|
}
|
|
for (const dataSource of dataSources.values()) {
|
|
if (
|
|
dataSource.scopeInstanceId !== undefined &&
|
|
instanceIds.has(dataSource.scopeInstanceId)
|
|
) {
|
|
dataSources.delete(dataSource.id);
|
|
}
|
|
}
|
|
for (const instanceId of instanceIds) {
|
|
styleSourceSelections.delete(instanceId);
|
|
}
|
|
for (const styleSourceId of localStyleSourceIds) {
|
|
styleSources.delete(styleSourceId);
|
|
}
|
|
for (const [styleDeclKey, styleDecl] of styles) {
|
|
if (localStyleSourceIds.has(styleDecl.styleSourceId)) {
|
|
styles.delete(styleDeclKey);
|
|
}
|
|
}
|
|
|
|
if (parentInstance) {
|
|
selectedInstanceSelectorStore.set(
|
|
getAncestorInstanceSelector(instanceSelector, parentInstance.id)
|
|
);
|
|
selectedStyleSourceSelectorStore.set(undefined);
|
|
}
|
|
}
|
|
);
|
|
};
|
|
|
|
export const deleteSelectedInstance = () => {
|
|
const textEditingInstanceSelector = textEditingInstanceSelectorStore.get();
|
|
const selectedInstanceSelector = selectedInstanceSelectorStore.get();
|
|
// cannot delete instance while editing
|
|
if (textEditingInstanceSelector) {
|
|
return;
|
|
}
|
|
// @todo tell user they can't delete root
|
|
if (
|
|
selectedInstanceSelector === undefined ||
|
|
selectedInstanceSelector.length === 1
|
|
) {
|
|
return;
|
|
}
|
|
deleteInstance(selectedInstanceSelector);
|
|
};
|
|
|
|
export const enterEditingMode = (event?: KeyboardEvent) => {
|
|
const selectedInstanceSelector = selectedInstanceSelectorStore.get();
|
|
if (selectedInstanceSelector === undefined) {
|
|
return;
|
|
}
|
|
const editableInstanceSelector = findClosestEditableInstanceSelector(
|
|
selectedInstanceSelector,
|
|
instancesStore.get(),
|
|
registeredComponentMetasStore.get()
|
|
);
|
|
if (editableInstanceSelector === undefined) {
|
|
return;
|
|
}
|
|
const element = getElementByInstanceSelector(editableInstanceSelector);
|
|
if (element === undefined) {
|
|
return;
|
|
}
|
|
// When an event is triggered from the Builder,
|
|
// the canvas element may be unfocused, so it's important to focus the element on the canvas.
|
|
element.focus();
|
|
// Prevents inserting a newline when entering text-editing mode
|
|
event?.preventDefault();
|
|
selectedInstanceSelectorStore.set(editableInstanceSelector);
|
|
textEditingInstanceSelectorStore.set(editableInstanceSelector);
|
|
};
|
|
|
|
export const escapeSelection = () => {
|
|
const selectedInstanceSelector = selectedInstanceSelectorStore.get();
|
|
const textEditingInstanceSelector = textEditingInstanceSelectorStore.get();
|
|
if (selectedInstanceSelector === undefined) {
|
|
return;
|
|
}
|
|
// exit text editing mode first without unselecting instance
|
|
if (textEditingInstanceSelector) {
|
|
textEditingInstanceSelectorStore.set(undefined);
|
|
return;
|
|
}
|
|
selectedInstanceSelectorStore.set(undefined);
|
|
selectedStyleSourceSelectorStore.set(undefined);
|
|
};
|