Files
webstudio/apps/builder/app/shared/instance-utils.ts
T
Bogdan Chadkin 891a96e0c9 Fix editing text inside button (#1739)
Ref https://github.com/webstudio-is/webstudio-builder/issues/1719

Buttons has issue with unavoidable space which prevents entering spaces.
Here bind lexical to temporary span inside of button. Now space works
fine.
2023-06-11 12:11:33 +03:00

393 lines
12 KiB
TypeScript

import store from "immerhin";
import {
Instances,
findTreeInstanceIdsExcludingSlotDescendants,
} from "@webstudio-is/project-build";
import {
type WsEmbedTemplate,
type WsComponentMeta,
generateDataFromEmbedTemplate,
} from "@webstudio-is/react-sdk";
import {
propsStore,
stylesStore,
selectedInstanceSelectorStore,
styleSourceSelectionsStore,
styleSourcesStore,
instancesStore,
selectedStyleSourceSelectorStore,
textEditingInstanceSelectorStore,
breakpointsStore,
registeredComponentMetasStore,
} from "./nano-states";
import {
type DroppableTarget,
type InstanceSelector,
findLocalStyleSourcesWithinInstances,
insertInstancesMutable,
reparentInstanceMutable,
getAncestorInstanceSelector,
insertPropsCopyMutable,
insertStyleSourcesCopyMutable,
insertStyleSourceSelectionsCopyMutable,
insertStylesCopyMutable,
} from "./tree-utils";
import { removeByMutable } from "./array-utils";
import { isBaseBreakpoint } from "./breakpoints";
import { getElementByInstanceSelector } from "./dom-utils";
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 findClosestDroppableComponentIndex = (
metas: Map<string, WsComponentMeta>,
componentSelector: string[],
childComponents: string[]
) => {
const requiredAncestors = new Set<string>();
const invalidAncestors = new Set<string>();
for (const childComponent of childComponents) {
const childMeta = metas.get(childComponent);
if (childMeta?.requiredAncestors) {
for (const ancestorComponent of childMeta.requiredAncestors) {
requiredAncestors.add(ancestorComponent);
}
}
if (childMeta?.invalidAncestors) {
for (const ancestorComponent of childMeta.invalidAncestors) {
invalidAncestors.add(ancestorComponent);
}
}
}
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,
dragComponents: string[]
): 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,
dragComponents
);
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 insertTemplate = (
template: WsEmbedTemplate,
dropTarget: DroppableTarget
) => {
const breakpoints = breakpointsStore.get();
const breakpointValues = Array.from(breakpoints.values());
const baseBreakpoint = breakpointValues.find(isBaseBreakpoint);
if (baseBreakpoint === undefined) {
return;
}
const {
children,
instances: insertedInstances,
props: insertedProps,
styleSourceSelections: insertedStyleSourceSelections,
styleSources: insertedStyleSources,
styles: insertedStyles,
} = generateDataFromEmbedTemplate(template, baseBreakpoint.id);
const rootInstanceId = insertedInstances[0].id;
store.createTransaction(
[
instancesStore,
propsStore,
styleSourceSelectionsStore,
styleSourcesStore,
stylesStore,
],
(instances, props, styleSourceSelections, styleSources, styles) => {
insertInstancesMutable(
instances,
props,
registeredComponentMetasStore.get(),
insertedInstances,
children,
dropTarget
);
insertPropsCopyMutable(props, insertedProps, new Map());
insertStyleSourcesCopyMutable(
styleSources,
insertedStyleSources,
new Set()
);
insertStyleSourceSelectionsCopyMutable(
styleSourceSelections,
insertedStyleSourceSelections,
new Map(),
new Map()
);
insertStylesCopyMutable(styles, insertedStyles, new Map(), new Map());
}
);
selectedInstanceSelectorStore.set([
rootInstanceId,
...dropTarget.parentSelector,
]);
selectedStyleSourceSelectorStore.set(undefined);
};
export const insertNewComponentInstance = (
component: string,
dropTarget: DroppableTarget
) => {
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: [],
},
];
insertTemplate(template, dropTarget);
};
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) => {
store.createTransaction(
[
instancesStore,
propsStore,
styleSourceSelectionsStore,
styleSourcesStore,
stylesStore,
],
(instances, props, styleSourceSelections, styleSources, styles) => {
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 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 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);
};