Skip to content

Commit

Permalink
experimental: Use placeholders for editable elements (#4644)
Browse files Browse the repository at this point in the history
## Description

ref #4595

### TODO:
- [x] - Found bug that deleting last LI does not delete UL
- [x] - Consider removing default texts for paragraph link blockquote
and headings i.e. `Heading text you can edit`


https://p-cfc5b051-3b7a-4a59-a994-44d8d2f8b836-dot-styles.development.webstudio.is/

The following components are considered non-collapsible in **Edit** and
**Content** modes:
*Paragraph*, *Heading*, *ListItem*, *Blockquote*, *Link*.

We are adding placeholders for these components when they are empty,
using the following logic (partially inspired by Notion):

1. **If a component is empty and not being edited**, we display the same
label as shown in the components tree.

![image](https://github.com/user-attachments/assets/a9c41829-ee62-427e-910e-0acd6df1c513)

2. **If a component is empty and being edited**, the behavior depends on
its location:
   - **Inside a content block:**  
- For *Paragraph*, we display: `Write something or press '/' for
commands...`.
     - For all other components, we display the label.  
   - **Outside a content block:**  
     - Always display the label.  

<img width="435" alt="image"
src="https://github.com/user-attachments/assets/96e0c79c-92dc-40c3-a768-db8322a38c3b"
/>


### Update: Default Content Block Component Changed

The default content block component has been updated. Add the new
component

### PS
On Publish and Preview previous behaviour.




## Steps for reproduction

1. click button
4. expect xyz

## 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:
0000)
- [ ] 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
  • Loading branch information
istarkov authored Dec 23, 2024
1 parent 66ff273 commit fb67334
Show file tree
Hide file tree
Showing 16 changed files with 156 additions and 81 deletions.
43 changes: 31 additions & 12 deletions apps/builder/app/canvas/features/text-editor/text-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1027,9 +1027,8 @@ const RichTextContentPluginInternal = ({
if (!isSelectionInSameComponent) {
node?.remove();

const rootNodeContent = $getRoot().getTextContent().trim();
// Delete current
if (rootNodeContent.length === 0) {
if ($getRoot().getTextContentSize() === 0) {
const blockChildSelector =
findBlockChildSelector(rootInstanceSelector);

Expand Down Expand Up @@ -1086,18 +1085,26 @@ const RichTextContentPluginInternal = ({
}

if (event.key === "Backspace" || event.key === "Delete") {
const rootNodeContent = $getRoot().getTextContent().trim();

if (rootNodeContent.length === 0) {
if ($getRoot().getTextContentSize() === 0) {
const currentInstance = $instances
.get()
.get(rootInstanceSelector[0]);

if (currentInstance?.component === "ListItem") {
onNext(editor.getEditorState(), { reason: "left" });

const parentInstanceSelector = rootInstanceSelector.slice(1);
const parentInstance = $instances
.get()
.get(parentInstanceSelector[0]);

const isLastChild = parentInstance?.children.length === 1;

updateWebstudioData((data) => {
deleteInstanceMutable(data, rootInstanceSelector);
deleteInstanceMutable(
data,
isLastChild ? parentInstanceSelector : rootInstanceSelector
);
});

event.preventDefault();
Expand Down Expand Up @@ -1127,10 +1134,9 @@ const RichTextContentPluginInternal = ({
.get()
.get(rootInstanceSelector[0]);

const rootNodeContent = $getRoot().getTextContent().trim();
if (
currentInstance?.component === "ListItem" &&
rootNodeContent.length > 0
$getRoot().getTextContentSize() > 0
) {
// Instead of creating block component we need to add a new ListItem
insertListItemAt(rootInstanceSelector);
Expand Down Expand Up @@ -1189,11 +1195,21 @@ const RichTextContentPluginInternal = ({

if (
currentInstance?.component === "ListItem" &&
rootNodeContent.length === 0
$getRoot().getTextContentSize() === 0
) {
const parentInstanceSelector = rootInstanceSelector.slice(1);
const parentInstance = $instances
.get()
.get(parentInstanceSelector[0]);

const isLastChild = parentInstance?.children.length === 1;

// Pressing Enter within an empty list item deletes the empty item
updateWebstudioData((data) => {
deleteInstanceMutable(data, rootInstanceSelector);
deleteInstanceMutable(
data,
isLastChild ? parentInstanceSelector : rootInstanceSelector
);
});
}

Expand Down Expand Up @@ -1589,7 +1605,11 @@ export const TextEditor = ({
}

// Components with pseudo-elements (e.g., ::marker) that prevent content from collapsing
const componentsWithPseudoElementChildren = ["ListItem"];
const componentsWithPseudoElementChildren = [
"ListItem",
"Paragraph",
"Heading",
];

// opinionated: Non-collapsed elements without children can act as spacers (they have size for some reason).
if (
Expand Down Expand Up @@ -1671,7 +1691,6 @@ export const TextEditor = ({
<RichTextPlugin
ErrorBoundary={LexicalErrorBoundary}
contentEditable={contentEditable}
placeholder={<></>}
/>
<LinkPlugin />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import {
descendantComponent,
blockComponent,
blockTemplateComponent,
editingPlaceholderVariable,
WsComponentMeta,
editablePlaceholderVariable,
} from "@webstudio-is/react-sdk";
import { rawTheme } from "@webstudio-is/design-system";
import {
Expand All @@ -37,6 +40,7 @@ import {
$instances,
$registeredComponentMetas,
$selectedInstanceRenderState,
findBlockSelector,
} from "~/shared/nano-states";
import { $textEditingInstanceSelector } from "~/shared/nano-states";
import {
Expand All @@ -58,10 +62,14 @@ 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";

const ContentEditable = ({
placeholder,
renderComponentWithRef,
}: {
placeholder: string | undefined;
renderComponentWithRef: (
elementRef: ForwardedRef<HTMLElement>
) => JSX.Element;
Expand Down Expand Up @@ -136,10 +144,17 @@ const ContentEditable = ({
rootElement.style.removeProperty("white-space");
rootElement.style.setProperty("white-space-collapse", "pre-wrap");

if (placeholder !== undefined) {
rootElement.style.setProperty(
editingPlaceholderVariable,
`'${placeholder.replaceAll("'", "\\'")}'`
);
}

return () => {
abortController.abort();
};
}, [editor]);
}, [editor, placeholder]);

return renderComponentWithRef(ref);
};
Expand Down Expand Up @@ -348,12 +363,46 @@ const getTextContent = (instanceProps: Record<string, unknown>) => {
return value as ReactNode;
};

const getEditableComponentPlaceholder = (
instanceSelector: InstanceSelector,
instances: Instances,
metas: Map<string, WsComponentMeta>,
mode: "editing" | "editable"
) => {
const instance = instances.get(instanceSelector[0])!;

if (!editablePlaceholderComponents.includes(instance.component)) {
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) {
return mode === "editing"
? "Write something or press '/' for commands..."
: // The paragraph contains only an "editing" placeholder within the content block.
undefined;
}

return label;
};

export const WebstudioComponentCanvas = forwardRef<
HTMLElement,
WebstudioComponentProps
>(({ instance, instanceSelector, components, ...restProps }, ref) => {
const instanceId = instance.id;
const instances = useStore($instances);
const metas = useStore($registeredComponentMetas);

const textEditingInstanceSelector = useStore($textEditingInstanceSelector);

Expand Down Expand Up @@ -444,12 +493,29 @@ export const WebstudioComponentCanvas = forwardRef<
Component = BlockTemplate;
}

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

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

const props: {
[componentAttribute]: string;
[idAttribute]: string;
[selectorIdAttribute]: string;
} & Record<string, unknown> = {
...mergeProps(restProps, instanceProps, "delete"),
...mergedProps,
...(placeholder !== undefined
? {
style: {
...mergedProps.style,
[editablePlaceholderVariable]: `'${placeholder.replaceAll("'", "\\'")}'`,
},
}
: null),
// current props should override bypassed from parent
// important for data-ws-* props
tabIndex: 0,
Expand Down Expand Up @@ -487,6 +553,12 @@ export const WebstudioComponentCanvas = forwardRef<
instances={instances}
contentEditable={
<ContentEditable
placeholder={getEditableComponentPlaceholder(
instanceSelector,
instances,
metas,
"editing"
)}
renderComponentWithRef={(elementRef) => (
<Component {...props} ref={mergeRefs(ref, elementRef)}>
{initialContentEditableContent.current}
Expand Down
40 changes: 40 additions & 0 deletions apps/builder/app/canvas/shared/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import {
import {
collapsedAttribute,
idAttribute,
editingPlaceholderVariable,
addGlobalRules,
createImageValueTransformer,
descendantComponent,
rootComponent,
editablePlaceholderVariable,
componentAttribute,
} from "@webstudio-is/react-sdk";
import {
type TransformValue,
Expand Down Expand Up @@ -61,7 +64,44 @@ 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(", ");

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

// Display a placeholder text for elements that are editing but empty (Lexical adds p>br children)
`:is(${editablePlaceholderSelector})[contenteditable]:has(p:only-child > br:only-child) {
position: relative;
& > p:after {
content: var(${editingPlaceholderVariable});
position: absolute;
left: 0;
right: 0;
top: 0;
min-width: 100px;
opacity: 0.3;
}
}
`,

// Using :where allows to prevent increasing specificity, so that helper is overwritten by user styles.
`[${idAttribute}]:where([${collapsedAttribute}]:not(body)) {
outline: 1px dashed rgba(0,0,0,0.7);
Expand Down
1 change: 0 additions & 1 deletion fixtures/ssg/app/__generated__/index.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 2 additions & 22 deletions packages/react-sdk/src/core-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,17 +267,7 @@ const blockMeta: WsComponentMeta = {
{
component: "ListItem",
type: "instance",
children: [{ type: "text", value: "list item you can edit" }],
},
{
component: "ListItem",
type: "instance",
children: [{ type: "text", value: "list item you can edit" }],
},
{
component: "ListItem",
type: "instance",
children: [{ type: "text", value: "list item you can edit" }],
children: [],
},
],
},
Expand All @@ -296,17 +286,7 @@ const blockMeta: WsComponentMeta = {
{
component: "ListItem",
type: "instance",
children: [{ type: "text", value: "list item you can edit" }],
},
{
component: "ListItem",
type: "instance",
children: [{ type: "text", value: "list item you can edit" }],
},
{
component: "ListItem",
type: "instance",
children: [{ type: "text", value: "list item you can edit" }],
children: [],
},
],
},
Expand Down
4 changes: 4 additions & 0 deletions packages/react-sdk/src/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ 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
Loading

0 comments on commit fb67334

Please sign in to comment.