Skip to content

fix: prevent inserting into instance with placeholder #4901

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Feb 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/builder/app/builder/shared/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import {
import { $selectedInstancePath, selectInstance } from "~/shared/awareness";
import { openCommandPanel } from "../features/command-panel";
import { builderApi } from "~/shared/builder-api";

import {
findClosestNonTextualContainer,
isInstanceDetachable,
Expand Down
12 changes: 5 additions & 7 deletions apps/builder/app/canvas/features/text-editor/text-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ import {
insertListItemAt,
insertTemplateAt,
} from "~/builder/features/workspace/canvas-tools/outline/block-utils";
import { editablePlaceholderComponents } from "~/canvas/shared/styles";

const BindInstanceToNodePlugin = ({
refs,
Expand Down Expand Up @@ -1580,6 +1579,7 @@ export const TextEditor = ({
const handleNext = useEffectEvent(
(state: EditorState, args: HandleNextParams) => {
const rootInstanceId = $selectedPage.get()?.rootInstanceId;
const metas = $registeredComponentMetas.get();

if (rootInstanceId === undefined) {
return;
Expand All @@ -1589,7 +1589,7 @@ export const TextEditor = ({
findAllEditableInstanceSelector(
[rootInstanceId],
instances,
$registeredComponentMetas.get(),
metas,
editableInstanceSelectors
);

Expand Down Expand Up @@ -1638,14 +1638,12 @@ export const TextEditor = ({
if (instance === undefined) {
continue;
}

// Components with pseudo-elements (e.g., ::marker) that prevent content from collapsing
const componentsWithPseudoElementChildren =
editablePlaceholderComponents;
const meta = metas.get(instance.component);

// opinionated: Non-collapsed elements without children can act as spacers (they have size for some reason).
if (
!componentsWithPseudoElementChildren.includes(instance.component) &&
// Components with pseudo-elements (e.g., ::marker) that prevent content from collapsing
meta?.placeholder === undefined &&
instance?.children.length === 0
) {
const elt = getElementByInstanceSelector(nextSelector);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ import {
getIndexesWithinAncestors,
type AnyComponent,
textContentAttribute,
editingPlaceholderVariable,
editablePlaceholderVariable,
} from "@webstudio-is/react-sdk";
import { rawTheme } from "@webstudio-is/design-system";
import {
Expand Down Expand Up @@ -68,8 +66,10 @@ import {
} from "~/canvas/elements";
import { Block } from "../build-mode/block";
import { BlockTemplate } from "../build-mode/block-template";
import { getInstanceLabel } from "~/shared/instance-utils";
import { editablePlaceholderComponents } from "~/canvas/shared/styles";
import {
editablePlaceholderAttribute,
editingPlaceholderVariable,
} from "~/canvas/shared/styles";

const ContentEditable = ({
placeholder,
Expand Down Expand Up @@ -376,19 +376,14 @@ const getEditableComponentPlaceholder = (
metas: Map<string, WsComponentMeta>,
mode: "editing" | "editable"
) => {
if (!editablePlaceholderComponents.includes(instance.component)) {
const meta = metas.get(instance.component);
if (meta?.placeholder === undefined) {
return;
}

const isContentBlockChild =
undefined !== findBlockSelector(instanceSelector, instances);

const meta = metas.get(instance.component);

const label = meta
? getInstanceLabel(instance, meta)
: (instance.label ?? instance.component);

const isParagraph = instance.component === "Paragraph";

if (isParagraph && isContentBlockChild) {
Expand All @@ -398,7 +393,7 @@ const getEditableComponentPlaceholder = (
undefined;
}

return label;
return meta.placeholder;
};

export const WebstudioComponentCanvas = forwardRef<
Expand Down Expand Up @@ -498,14 +493,6 @@ export const WebstudioComponentCanvas = forwardRef<
Component = BlockTemplate;
}

const placeholder = getEditableComponentPlaceholder(
instance,
instanceSelector,
instances,
metas,
"editable"
);

const mergedProps = mergeProps(restProps, instanceProps, "delete");

const props: {
Expand All @@ -514,20 +501,19 @@ export const WebstudioComponentCanvas = forwardRef<
[selectorIdAttribute]: string;
} & Record<string, unknown> = {
...mergedProps,
...(placeholder !== undefined
? {
style: {
...mergedProps.style,
[editablePlaceholderVariable]: `'${placeholder.replaceAll("'", "\\'")}'`,
},
}
: null),
// current props should override bypassed from parent
// important for data-ws-* props
tabIndex: 0,
[selectorIdAttribute]: instanceSelector.join(","),
[componentAttribute]: instance.component,
[idAttribute]: instance.id,
[editablePlaceholderAttribute]: getEditableComponentPlaceholder(
instance,
instanceSelector,
instances,
metas,
"editable"
),
};

// React ignores defaultValue changes after first render.
Expand Down
33 changes: 9 additions & 24 deletions apps/builder/app/canvas/shared/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,7 @@ import {
createImageValueTransformer,
addFontRules,
} from "@webstudio-is/sdk";
import {
collapsedAttribute,
idAttribute,
editingPlaceholderVariable,
editablePlaceholderVariable,
componentAttribute,
} from "@webstudio-is/react-sdk";
import { collapsedAttribute, idAttribute } from "@webstudio-is/react-sdk";
import {
StyleValue,
type TransformValue,
Expand Down Expand Up @@ -68,31 +62,22 @@ export const mountStyles = () => {
helpersSheet.render();
};

/**
* Opinionated list of non collapsible components in the builder
*/
export const editablePlaceholderComponents = [
"Paragraph",
"Heading",
"ListItem",
"Blockquote",
"Link",
];

const editablePlaceholderSelector = editablePlaceholderComponents
.map((component) => `[${componentAttribute}= "${component}"]`)
.join(", ");
export const editablePlaceholderAttribute = "data-ws-editable-placeholder";
// @todo replace with modern typed attr() when supported in all browsers
// see the second edge case
// https://developer.mozilla.org/en-US/docs/Web/CSS/attr#backwards_compatibility
export const editingPlaceholderVariable = "--ws-editing-placeholder";

const helperStylesShared = [
// Display a placeholder text for elements that are editable but currently empty
`:is(${editablePlaceholderSelector}):empty::before {
content: var(${editablePlaceholderVariable}, '\\200B');
`:is([${editablePlaceholderAttribute}]):empty::before {
content: attr(${editablePlaceholderAttribute});
opacity: 0.3;
}
`,

// Display a placeholder text for elements that are editing but empty (Lexical adds p>br children)
`:is(${editablePlaceholderSelector})[contenteditable] > p:only-child:has(br:only-child) {
`:is([${editablePlaceholderAttribute}])[contenteditable] > p:only-child:has(br:only-child) {
position: relative;
display: block;
&:after {
Expand Down
30 changes: 30 additions & 0 deletions apps/builder/app/shared/instance-utils.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,36 @@ describe("find closest insertable", () => {
});
});

test("finds closest container without textual placeholder", () => {
const { instances } = renderData(
<$.Body ws:id="bodyId">
<$.Paragraph ws:id="paragraphId"></$.Paragraph>
</$.Body>
);
$instances.set(instances);
selectInstance(["paragraphId", "bodyId"]);
expect(findClosestInsertable(newBoxFragment)).toEqual({
parentSelector: ["bodyId"],
position: 1,
});
});

test("finds closest container even with when parent has placeholder", () => {
const { instances } = renderData(
<$.Body ws:id="bodyId">
<$.Paragraph ws:id="paragraphId">
<$.Box ws:id="spanId" tag="span"></$.Box>
</$.Paragraph>
</$.Body>
);
$instances.set(instances);
selectInstance(["boxId", "paragraphId", "bodyId"]);
expect(findClosestInsertable(newBoxFragment)).toEqual({
parentSelector: ["paragraphId", "bodyId"],
position: 0,
});
});

test("forbids inserting into :root", () => {
const { instances } = renderData(<$.Body ws:id="bodyId"></$.Body>);
$instances.set(instances);
Expand Down
6 changes: 4 additions & 2 deletions apps/builder/app/shared/matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,10 @@ export const findClosestNonTextualContainer = ({
if (instance === undefined) {
continue;
}
let hasText = false;
const meta = metas.get(instance.component);
// placeholder exists only inside of empty instances
let hasText =
meta?.placeholder !== undefined && instance.children.length === 0;
for (const child of instance.children) {
if (child.type === "text" || child.type === "expression") {
hasText = true;
Expand All @@ -392,7 +395,6 @@ export const findClosestNonTextualContainer = ({
if (hasText) {
continue;
}
const meta = metas.get(instance.component);
if (meta?.type === "container") {
return index;
}
Expand Down
4 changes: 0 additions & 4 deletions packages/react-sdk/src/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,6 @@ export const showAttribute = "data-ws-show" as const;
export const indexAttribute = "data-ws-index" as const;
export const collapsedAttribute = "data-ws-collapsed" as const;
export const textContentAttribute = "data-ws-text-content" as const;
export const editablePlaceholderVariable =
"--data-ws-editable-placeholder" as const;
export const editingPlaceholderVariable =
"--data-ws-editing-placeholder" as const;

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-components-react/src/blockquote.ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const presetStyle = {

export const meta: WsComponentMeta = {
type: "container",
placeholder: "Blockquote",
icon: BlockquoteIcon,
states: defaultStates,
presetStyle,
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-components-react/src/heading.ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const presetStyle = {

export const meta: WsComponentMeta = {
type: "container",
placeholder: "Heading",
icon: HeadingIcon,
constraints: {
relation: "ancestor",
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-components-react/src/link.ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const presetStyle = {

export const meta: WsComponentMeta = {
type: "container",
placeholder: "Link",
icon: LinkIcon,
constraints: {
relation: "ancestor",
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-components-react/src/list-item.ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const presetStyle = {

export const meta: WsComponentMeta = {
type: "container",
placeholder: "List item",
constraints: {
// cannot use parent relation here
// because list item can be put inside of collection or slot
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-components-react/src/paragraph.ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const presetStyle = {

export const meta: WsComponentMeta = {
type: "container",
placeholder: "Paragraph",
icon: TextAlignLeftIcon,
constraints: {
relation: "ancestor",
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/src/schema/component-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export const WsComponentMeta = z.object({
// embed - images, videos or other embeddable components, without children
// rich-text-child - formatted text fragment, not listed in components list
type: z.enum(["container", "control", "embed", "rich-text-child"]),
/**
* a property used as textual placeholder when no content specified while in builder
* also signals to not insert components inside unless dropped explicitly
*/
placeholder: z.string().optional(),
constraints: Matchers.optional(),
// when this field is specified component receives
// prop with index of same components withiin specified ancestor
Expand Down
Loading