Skip to content

Commit

Permalink
feat: RichText edit / command menu (#4621)
Browse files Browse the repository at this point in the history
## Description

ref #4595

Search is working

<img width="246" alt="image"
src="https://github.com/user-attachments/assets/5dc850e4-6a36-4996-9aaa-f051ac7e977f"
/>

Arrow keys, enter, mouse click is working.



https://p-15889dd9-ed47-46db-9411-fa18c1efb2fe-dot-edit.development.webstudio.is/

- [x] - Enter and click
- [ ] - Repeat > N then close menu (later)
- [x] - In case of select item clear cmd `/blabla`
- [x] - Put cursor inside 1st editable block of the new instance
- [x] - Should work only inside editable content

## Steps for reproduction

1. click button
2. 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 20, 2024
1 parent 6cb74d4 commit ea88935
Show file tree
Hide file tree
Showing 21 changed files with 1,244 additions and 473 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import { useStore } from "@nanostores/react";
import { styled } from "@webstudio-is/design-system";

import {
$instances,
$modifierKeys,
$textEditingInstanceSelector,
$textEditorContextMenu,
$textEditorContextMenuCommand,
findTemplates,
} from "~/shared/nano-states";
import { applyScale } from "./outline";
import { $scale } from "~/builder/shared/nano-states";
import { TemplatesMenu } from "./outline/block-instance-outline";
import { insertTemplateAt } from "./outline/block-utils";
import { useCallback, useEffect, useState } from "react";
import { useEffectEvent } from "~/shared/hook-utils/effect-event";
import type { InstanceSelector } from "~/shared/tree-utils";
import type { Instance } from "@webstudio-is/sdk";
import { shallowEqual } from "shallow-equal";
import { emitCommand } from "~/builder/shared/commands";

const TriggerButton = styled("button", {
position: "absolute",
appearance: "none",
backgroundColor: "transparent",
outline: "none",
pointerEvents: "all",
border: "none",
overflow: "hidden",
padding: 0,
});

const InertController = ({
onChange,
}: {
onChange: (inert: boolean) => void;
}) => {
const handleChange = useEffectEvent(onChange);

useEffect(() => {
const timeout = setTimeout(() => {
handleChange(false);
}, 0);

return () => {
clearTimeout(timeout);
};
}, [handleChange]);

return null;
};

const mod = (n: number, m: number): number => {
return ((n % m) + m) % m;
};

const triggerTooltipContent = <>"Templates"</>;

const Menu = ({
cursorRect,
anchor,
templates,
}: {
cursorRect: DOMRect;
anchor: InstanceSelector;
templates: [instance: Instance, instanceSelector: InstanceSelector][];
}) => {
const [inert, setInert] = useState(true);
const modifierKeys = useStore($modifierKeys);
const scale = useStore($scale);
const rect = applyScale(cursorRect, scale);

const [filtered, setFiltered] = useState({ repeat: 0, templates });
const [value, setValue] = useState<InstanceSelector | undefined>(
templates[0]?.[1] ?? undefined
);

const [intermediateValue, setIntermediateValue] = useState<
InstanceSelector | undefined
>();

const handleValueChangeComplete = useCallback(
(templateSelector: InstanceSelector) => {
const insertBefore = modifierKeys.altKey;
insertTemplateAt(templateSelector, anchor, insertBefore);
emitCommand("newInstanceText");
},
[anchor, modifierKeys.altKey]
);

const currentValue = intermediateValue ?? value;

useEffect(() => {
return $textEditorContextMenuCommand.listen((command) => {
if (command === undefined) {
return;
}
const type = command.type;

switch (type) {
case "filter": {
const filter = command.value.toLowerCase();
const filteredTemplates = templates.filter(([template]) => {
const title = template.label ?? template.component;
return title.toLowerCase().includes(filter);
});

setFiltered((prev) => {
if (filteredTemplates.length === 0) {
return { repeat: prev.repeat + 1, templates: [] };
}

return { repeat: 0, templates: filteredTemplates };
});

setValue(filteredTemplates[0]?.[1] ?? undefined);
break;
}

case "selectNext": {
const index = filtered.templates.findIndex(([_, selector]) =>
shallowEqual(selector, currentValue)
);
const nextIndex = mod(index + 1, filtered.templates.length);
setValue(filtered.templates[nextIndex]?.[1] ?? undefined);
setIntermediateValue(undefined);
break;
}
case "selectPrevious": {
const index = filtered.templates.findIndex(([_, selector]) =>
shallowEqual(selector, currentValue)
);
const prevIndex = mod(index - 1, filtered.templates.length);
setValue(filtered.templates[prevIndex]?.[1] ?? undefined);
setIntermediateValue(undefined);
break;
}

case "enter": {
if (currentValue !== undefined) {
handleValueChangeComplete(currentValue);
}
break;
}

default:
(type) satisfies never;
}
});
}, [filtered.templates, templates, currentValue, handleValueChangeComplete]);

// @todo repeat and close

return (
<>
<TemplatesMenu
open={true}
onOpenChange={(open) => {
if (open) {
return;
}
$textEditorContextMenu.set(undefined);
}}
anchor={anchor}
triggerTooltipContent={triggerTooltipContent}
templates={filtered.templates}
value={currentValue}
onValueChangeComplete={handleValueChangeComplete}
onValueChange={setIntermediateValue}
modal={false}
inert={inert}
preventFocusOnHover={true}
>
<TriggerButton
css={{
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
}}
></TriggerButton>
</TemplatesMenu>
<InertController onChange={setInert} />
</>
);
};

export const TextEditorContextMenu = () => {
const textEditingInstanceSelector = useStore($textEditingInstanceSelector);
const textEditorContextMenu = useStore($textEditorContextMenu);
const instances = useStore($instances);

if (textEditorContextMenu === undefined) {
return;
}

if (textEditingInstanceSelector === undefined) {
return;
}

const templates = findTemplates(
textEditingInstanceSelector.selector,
instances
);

if (templates === undefined) {
return;
}

return (
<Menu
key={JSON.stringify(textEditingInstanceSelector.selector)}
cursorRect={textEditorContextMenu.cursorRect}
anchor={textEditingInstanceSelector.selector}
templates={templates}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { useSubscribeDragAndDropState } from "./use-subscribe-drag-drop-state";
import { applyScale } from "./outline";
import { $clampingRect, $scale } from "~/builder/shared/nano-states";
import { BlockChildHoveredInstanceOutline } from "./outline/block-instance-outline";
import { TextEditorContextMenu } from "./block-editor-context-menu";

const containerStyle = css({
position: "absolute",
Expand Down Expand Up @@ -82,6 +83,7 @@ export const CanvasTools = () => {
<HoveredInstanceOutline />
<CollaborativeInstanceOutline />
<BlockChildHoveredInstanceOutline />
<TextEditorContextMenu />
</>
)}
</>
Expand Down
Loading

0 comments on commit ea88935

Please sign in to comment.