From 03a281a694e5cc6b5a71ba424d0f686bded196f1 Mon Sep 17 00:00:00 2001 From: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:30:50 +0530 Subject: [PATCH 01/11] feat: add emoji suggestions Signed-off-by: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> --- packages/frappe-ui-react/package.json | 6 +- .../components/textEditor/emoji/emojiList.tsx | 83 ++++++++++++ .../textEditor/emoji/suggestions.tsx | 120 ++++++++++++++++++ .../src/components/textEditor/textEditor.tsx | 6 + pnpm-lock.yaml | 55 ++++++++ 5 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 packages/frappe-ui-react/src/components/textEditor/emoji/emojiList.tsx create mode 100644 packages/frappe-ui-react/src/components/textEditor/emoji/suggestions.tsx diff --git a/packages/frappe-ui-react/package.json b/packages/frappe-ui-react/package.json index 87fa40ad..97ab17da 100644 --- a/packages/frappe-ui-react/package.json +++ b/packages/frappe-ui-react/package.json @@ -42,13 +42,14 @@ "@headlessui/react": "^2.2.9", "@popperjs/core": "^2.11.8", "@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-blockquote": "^3.17.1", "@tiptap/extension-horizontal-rule": "^3.17.1", - "@tiptap/extension-strike": "^3.17.1", "@tiptap/extension-list": "^3.17.1", "@tiptap/extension-placeholder": "^3.17.1", + "@tiptap/extension-strike": "^3.17.1", "@tiptap/extension-table": "^3.17.1", "@tiptap/extension-task-list": "^3.17.1", "@tiptap/extension-text-align": "^3.17.1", @@ -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", "clsx": "^2.1.1", "dayjs": "^1.11.19", "dompurify": "^3.3.1", diff --git a/packages/frappe-ui-react/src/components/textEditor/emoji/emojiList.tsx b/packages/frappe-ui-react/src/components/textEditor/emoji/emojiList.tsx new file mode 100644 index 00000000..c86cba02 --- /dev/null +++ b/packages/frappe-ui-react/src/components/textEditor/emoji/emojiList.tsx @@ -0,0 +1,83 @@ +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( + (props, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const selectItem = useCallback( + (index: number) => { + const item = props.items[index]; + + if (item) { + props.command({ name: item.name }); + } + }, + [props] + ); + + useImperativeHandle(ref, () => { + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length + ); + }; + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.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; + }, + }; + }, [props, selectedIndex, selectItem]); + + return ( +
+ {props.items.map((item, index) => ( + + ))} +
+ ); + } +); diff --git a/packages/frappe-ui-react/src/components/textEditor/emoji/suggestions.tsx b/packages/frappe-ui-react/src/components/textEditor/emoji/suggestions.tsx new file mode 100644 index 00000000..df6941f7 --- /dev/null +++ b/packages/frappe-ui-react/src/components/textEditor/emoji/suggestions.tsx @@ -0,0 +1,120 @@ +/** + * External dependencies. + */ +import type { EmojiItem } from "@tiptap/extension-emoji"; +import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion"; +import { computePosition } from "@floating-ui/dom"; +import { ReactRenderer } from "@tiptap/react"; + +/** + * Internal dependencies. + */ +import { EmojiList, type EmojiListRef } from "./emojiList"; + +type EmojiStorage = { + emojis: EmojiItem[]; +}; + +const EmojiSuggestions: Omit = { + 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 | 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" && component) { + if (document.body.contains(component.element)) { + document.body.removeChild(component.element); + } + component.destroy(); + 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; diff --git a/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx b/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx index 4fbada51..89a0397e 100644 --- a/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx @@ -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, { gitHubEmojis } from "@tiptap/extension-emoji"; import clsx from "clsx"; /** @@ -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, @@ -85,6 +87,10 @@ const TextEditor = ({ }, }), ExtendedCodeBlock, + Emoji.configure({ + emojis: gitHubEmojis, + suggestion: EmojiSuggestions, + }), ...extensions, ], onUpdate: ({ editor }) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 93a22666..93c8d65e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: '@tiptap/extension-code-block-lowlight': specifier: ^3.17.1 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/extension-code-block@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(highlight.js@11.11.1)(lowlight@3.3.0) + '@tiptap/extension-emoji': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(emojibase@17.0.0) '@tiptap/extension-highlight': specifier: ^3.17.1 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) @@ -207,6 +210,9 @@ importers: '@tiptap/starter-kit': specifier: ^3.17.1 version: 3.19.0 + '@tiptap/suggestion': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -2064,6 +2070,13 @@ packages: peerDependencies: '@tiptap/extensions': ^3.19.0 + '@tiptap/extension-emoji@3.19.0': + resolution: {integrity: sha512-sMpMUuVKaMqmOdIwFotuRSIw5LgBSECSQJKdC6HUUlETvtcRkUgf6SmrKefixXyir2CQPbA3ioDZTNTI2rs1Wg==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + '@tiptap/suggestion': ^3.19.0 + '@tiptap/extension-floating-menu@3.19.0': resolution: {integrity: sha512-JaoEkVRkt+Slq3tySlIsxnMnCjS0L5n1CA1hctjLy0iah8edetj3XD5mVv5iKqDzE+LIjF4nwLRRVKJPc8hFBg==} peerDependencies: @@ -2197,6 +2210,12 @@ packages: '@tiptap/starter-kit@3.19.0': resolution: {integrity: sha512-dTCkHEz+Y8ADxX7h+xvl6caAj+3nII/wMB1rTQchSuNKqJTOrzyUsCWm094+IoZmLT738wANE0fRIgziNHs/ug==} + '@tiptap/suggestion@3.19.0': + resolution: {integrity: sha512-tUZwMRFqTVPIo566ZmHNRteyZxJy2EE4FA+S3IeIUOOvY6AW0h1imhbpBO7sXV8CeEQvpa+2DWwLvy7L3vmstA==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/pm': ^3.19.0 + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2978,6 +2997,15 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + emojibase-data@15.3.2: + resolution: {integrity: sha512-TpDyTDDTdqWIJixV5sTA6OQ0P0JfIIeK2tFRR3q56G9LK65ylAZ7z3KyBXokpvTTJ+mLUXQXbLNyVkjvnTLE+A==} + peerDependencies: + emojibase: '*' + + emojibase@17.0.0: + resolution: {integrity: sha512-bXdpf4HPY3p41zK5swVKZdC/VynsMZ4LoLxdYDE+GucqkFwzcM1GVc4ODfYAlwoKaf2U2oNNUoOO78N96ovpBA==} + engines: {node: '>=18.12.0'} + enhanced-resolve@5.18.4: resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} engines: {node: '>=10.13.0'} @@ -3462,6 +3490,9 @@ packages: engines: {node: '>=8'} hasBin: true + is-emoji-supported@0.0.5: + resolution: {integrity: sha512-WOlXUhDDHxYqcSmFZis+xWhhqXiK2SU0iYiqmth5Ip0FHLZQAt9rKL5ahnilE8/86WH8tZ3bmNNNC+bTzamqlw==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -7068,6 +7099,17 @@ snapshots: dependencies: '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-emoji@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)(@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0))(emojibase@17.0.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tiptap/suggestion': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + emoji-regex: 10.6.0 + emojibase-data: 15.3.2(emojibase@17.0.0) + is-emoji-supported: 0.0.5 + transitivePeerDependencies: + - emojibase + '@tiptap/extension-floating-menu@3.19.0(@floating-ui/dom@1.7.4)(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': dependencies: '@floating-ui/dom': 1.7.4 @@ -7230,6 +7272,11 @@ snapshots: '@tiptap/extensions': 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) '@tiptap/pm': 3.19.0 + '@tiptap/suggestion@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0)': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/pm': 3.19.0 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -8060,6 +8107,12 @@ snapshots: emoji-regex@9.2.2: {} + emojibase-data@15.3.2(emojibase@17.0.0): + dependencies: + emojibase: 17.0.0 + + emojibase@17.0.0: {} + enhanced-resolve@5.18.4: dependencies: graceful-fs: 4.2.11 @@ -8658,6 +8711,8 @@ snapshots: is-docker@2.2.1: {} + is-emoji-supported@0.0.5: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: From 7dee49ffaed6b33a53ec51cc7542b063ace23183 Mon Sep 17 00:00:00 2001 From: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:43:40 +0530 Subject: [PATCH 02/11] chore: add interaction text Signed-off-by: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> --- .../textEditor.interactions.stories.tsx | 43 +++++++++++++++++++ .../src/components/textEditor/textEditor.tsx | 6 ++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/frappe-ui-react/src/components/textEditor/textEditor.interactions.stories.tsx b/packages/frappe-ui-react/src/components/textEditor/textEditor.interactions.stories.tsx index 417155e6..2122a080 100644 --- a/packages/frappe-ui-react/src/components/textEditor/textEditor.interactions.stories.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/textEditor.interactions.stories.tsx @@ -255,3 +255,46 @@ 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 ( +
+ +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const text = canvas.getByText((content, element) => { + return ( + content.includes("This is a paragraph") && element?.tagName === "P" + ); + }); + console.log(text); + + 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(); + }, +}; diff --git a/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx b/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx index 89a0397e..f76f9216 100644 --- a/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx @@ -124,7 +124,11 @@ const TextEditor = ({ {Top && } {fixedMenu && } - {Editor ? : } + {Editor ? ( + + ) : ( + + )} {Bottom && } ); From 498c21b1823ce6a730aff5eee1b5e5fd94c8a43e Mon Sep 17 00:00:00 2001 From: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:46:05 +0530 Subject: [PATCH 03/11] chore: fix extension warning Signed-off-by: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> --- .../src/components/textEditor/textEditor.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx b/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx index 4fbada51..c07bf08f 100644 --- a/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx @@ -56,11 +56,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({ From 7aed4ee54d83b2be2ec97ff9b4f1cabc1cea587d Mon Sep 17 00:00:00 2001 From: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> Date: Thu, 5 Feb 2026 17:50:00 +0530 Subject: [PATCH 04/11] chore: fix keys warning Signed-off-by: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> --- .../frappe-ui-react/src/components/textEditor/menu/menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frappe-ui-react/src/components/textEditor/menu/menu.tsx b/packages/frappe-ui-react/src/components/textEditor/menu/menu.tsx index 3efd0b77..b333c0e2 100644 --- a/packages/frappe-ui-react/src/components/textEditor/menu/menu.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/menu/menu.tsx @@ -164,7 +164,7 @@ const Menu = ({ className }: MenuProps) => { if (command.component) { return ( - + {({ isActive, onClick }) => (