Skip to content
Draft
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
2 changes: 2 additions & 0 deletions packages/frappe-ui-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@tailwindcss/typography": "^0.5.19",
"@tiptap/extension-blockquote": "^3.17.1",
"@tiptap/extension-code-block-lowlight": "^3.17.1",
"@tiptap/extension-emoji": "^3.19.0",
"@tiptap/extension-highlight": "^3.17.1",
"@tiptap/extension-horizontal-rule": "^3.17.1",
"@tiptap/extension-list": "^3.17.1",
Expand All @@ -56,6 +57,7 @@
"@tiptap/pm": "^3.17.1",
"@tiptap/react": "^3.17.1",
"@tiptap/starter-kit": "^3.17.1",
"@tiptap/suggestion": "^3.19.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.19",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* External dependencies.
*/
import type {
SuggestionKeyDownProps,
SuggestionProps,
} from "@tiptap/suggestion";
import clsx from "clsx";
import { forwardRef, useCallback, useImperativeHandle, useState } from "react";

export type EmojiListRef = {
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
};

export const EmojiList = forwardRef<EmojiListRef, SuggestionProps>(
({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);

const selectItem = useCallback(
(index: number) => {
const item = items[index];

if (item) {
command({ name: item.name });
}
},
[items, command]
);

useImperativeHandle(ref, () => {
const upHandler = () => {
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
};

const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % items.length);
};

const enterHandler = () => {
selectItem(selectedIndex);
};

return {
onKeyDown: (x: SuggestionKeyDownProps) => {
if (x.event.key === "ArrowUp") {
upHandler();
return true;
}

if (x.event.key === "ArrowDown") {
downHandler();
return true;
}

if (x.event.key === "Enter") {
enterHandler();
return true;
}

return false;
},
};
}, [items, selectedIndex, selectItem]);

if (items.length === 0) {
return null;
}

return (
<div
role="listbox"
className="relative max-h-[300px] min-w-40 overflow-y-auto rounded-lg bg-surface-white p-1 text-base shadow-lg"
>
{items.map((item, index) => (
<button
role="option"
type="button"
key={index}
className={clsx(
"flex w-full items-center whitespace-nowrap rounded-md px-2 py-1.5 text-sm text-ink-gray-9 gap-1",
index === selectedIndex && "bg-surface-gray-2"
)}
onClick={() => selectItem(index)}
onMouseOver={() => setSelectedIndex(index)}
>
<span>{item.emoji}</span>
<span>{item.display || item.title || item.name}</span>
</button>
))}
</div>
);
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* External dependencies.
*/
import type { EmojiItem } from "@tiptap/extension-emoji";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
import { computePosition } from "@floating-ui/react";
import { ReactRenderer } from "@tiptap/react";

/**
* Internal dependencies.
*/
import { EmojiList, type EmojiListRef } from "./emojiList";

type EmojiStorage = {
emojis: EmojiItem[];
};

const EmojiSuggestions: Omit<SuggestionOptions, "editor"> = {
items: ({ editor, query }) => {
const storage = editor.storage.emoji as EmojiStorage;

return storage.emojis
.filter(({ shortcodes, tags }) => {
const lowerQuery = query.toLowerCase();

return (
shortcodes.some((shortcode) => shortcode.startsWith(lowerQuery)) ||
tags.some((tag) => tag.startsWith(lowerQuery))
);
})
.slice(0, 5);
},

allowSpaces: false,

render: () => {
let component: ReactRenderer<EmojiListRef, SuggestionProps> | null = null;

const repositionComponent = (clientRect: DOMRect | null) => {
if (!clientRect || !component) {
return;
}

const virtualElement = {
getBoundingClientRect() {
return clientRect;
},
};

computePosition(virtualElement, component.element, {
placement: "bottom-start",
}).then((pos) => {
if (!component) {
return;
}

Object.assign(component.element.style, {
left: `${pos.x}px`,
top: `${pos.y}px`,
position: pos.strategy === "fixed" ? "fixed" : "absolute",
});
});
};

return {
onStart: (props) => {
component = new ReactRenderer(EmojiList, {
props,
editor: props.editor,
});

document.body.appendChild(component.element);
if (props.clientRect) {
repositionComponent(props.clientRect());
}
},

onUpdate(props) {
if (!component) {
return;
}

component.updateProps(props);
repositionComponent(props.clientRect?.() ?? null);
},

onKeyDown(props) {
if (!component) {
return false;
}

if (!component.ref) {
return false;
}

if (props.event.key === "Escape") {
if (document.body.contains(component.element)) {
document.body.removeChild(component.element);
}
component.destroy();
component = null;
return true;
}

return component.ref.onKeyDown(props) ?? false;
},

onExit() {
if (component) {
if (document.body.contains(component.element)) {
document.body.removeChild(component.element);
}
component.destroy();
component = null;
}
},
};
},
};

export default EmojiSuggestions;
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ const Menu = ({ className }: MenuProps) => {

if (command.component) {
return (
<command.component>
<command.component key={index}>
{({ isActive, onClick }) => (
<button
role="button"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,45 @@ export const EditorFontColor: Story = {
expect(newText).toHaveStyle("background-color: #ffe7e7");
},
};

export const EditorEmoji: Story = {
args: {
content: CONTENT,
editorClass: "prose-sm min-h-[4rem] border rounded-b-lg border-t-0 p-2",
fixedMenu: true,
},
render: function BasicRender(args) {
return (
<div className="m-2 w-[550px]">
<TextEditor {...args} />
</div>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

const text = canvas.getByText((content, element) => {
return (
content.includes("This is a paragraph") && element?.tagName === "P"
);
});

await userEvent.click(text);

await userEvent.type(text, "{Control>}{ArrowRight}");

// Type ":smiley" to trigger emoji suggestions
await userEvent.type(text, " :smiley");

// Wait for the emoji suggestion list to appear
const emojiSuggestion = await screen.findByText("😃");
expect(emojiSuggestion).toBeInTheDocument();

// Select the emoji from the suggestion list
await userEvent.click(emojiSuggestion);

// Verify the emoji is added to the editor content
const updatedContent = canvas.getByText("😃");
expect(updatedContent).toBeInTheDocument();
},
};
19 changes: 13 additions & 6 deletions packages/frappe-ui-react/src/components/textEditor/textEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import HorizontalRule from "@tiptap/extension-horizontal-rule";
import Strike from "@tiptap/extension-strike";
import Placeholder from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
import Emoji from "@tiptap/extension-emoji";
import clsx from "clsx";

/**
Expand All @@ -22,6 +23,7 @@ import { normalizeClasses } from "../../utils";
import type { TextEditorProps } from "./types";
import FixedMenu from "./menu/fixedMenu";
import { ExtendedCodeBlock } from "./extension/codeBlock";
import EmojiSuggestions from "./emoji/suggestions";

const TextEditor = ({
content,
Expand Down Expand Up @@ -56,11 +58,9 @@ const TextEditor = ({
extensions: [
StarterKit.configure({
codeBlock: false,
horizontalRule: {
HTMLAttributes: {
class: "not-prose border-outline-gray-1 m-0",
},
},
strike: false,
blockquote: false,
horizontalRule: false,
...starterkitOptions,
}),
Placeholder.configure({
Expand All @@ -85,6 +85,9 @@ const TextEditor = ({
},
}),
ExtendedCodeBlock,
Emoji.configure({
suggestion: EmojiSuggestions,
}),
...extensions,
],
onUpdate: ({ editor }) => {
Expand Down Expand Up @@ -118,7 +121,11 @@ const TextEditor = ({
<EditorContext.Provider value={{ editor }}>
{Top && <Top />}
{fixedMenu && <FixedMenu />}
{Editor ? <Editor editor={editor} /> : <EditorContent editor={editor} />}
{Editor ? (
<Editor editor={editor} />
) : (
<EditorContent editor={editor} role="textbox" />
)}
{Bottom && <Bottom />}
</EditorContext.Provider>
);
Expand Down
Loading