From 326c5d0793e1896e100dc23bc567125cb9c51496 Mon Sep 17 00:00:00 2001 From: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:13:10 +0530 Subject: [PATCH 1/7] feat: add link editing functionality Signed-off-by: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> --- packages/frappe-ui-react/package.json | 4 +- .../textEditor/menu/commands/index.ts | 7 ++ .../textEditor/menu/commands/types.ts | 1 + .../textEditor/menu/linkBubbleMenu.tsx | 84 +++++++++++++++++++ .../src/components/textEditor/menu/menu.tsx | 1 + .../src/components/textEditor/textEditor.tsx | 2 + packages/frappe-ui-react/tsconfig.json | 1 + 7 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx diff --git a/packages/frappe-ui-react/package.json b/packages/frappe-ui-react/package.json index 87fa40ad..a65bd7e7 100644 --- a/packages/frappe-ui-react/package.json +++ b/packages/frappe-ui-react/package.json @@ -42,13 +42,13 @@ "@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-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", diff --git a/packages/frappe-ui-react/src/components/textEditor/menu/commands/index.ts b/packages/frappe-ui-react/src/components/textEditor/menu/commands/index.ts index 214e0eeb..0e36a1aa 100644 --- a/packages/frappe-ui-react/src/components/textEditor/menu/commands/index.ts +++ b/packages/frappe-ui-react/src/components/textEditor/menu/commands/index.ts @@ -25,6 +25,7 @@ import { TableIcon, TypeIcon, Undo2Icon, + Link2, } from "lucide-react"; /** @@ -264,6 +265,12 @@ export const COMMANDS: Record = { isDisabled: (editor) => !editor.can().redo(), isActive: () => false, }, + link: { + label: "Link", + icon: Link2, + action: (editor) => editor.chain().focus().setLink({ href: "" }).run(), + isActive: (editor) => editor.isActive("link"), + }, }; export default COMMANDS; diff --git a/packages/frappe-ui-react/src/components/textEditor/menu/commands/types.ts b/packages/frappe-ui-react/src/components/textEditor/menu/commands/types.ts index e2b621a3..c46ceff1 100644 --- a/packages/frappe-ui-react/src/components/textEditor/menu/commands/types.ts +++ b/packages/frappe-ui-react/src/components/textEditor/menu/commands/types.ts @@ -39,6 +39,7 @@ export const COMMANDS_KEYS = [ "redo", "codeblock", "horizontal_rule", + "link", ] as const; export type TYPE_COMMANDS_KEYS = (typeof COMMANDS_KEYS)[number]; diff --git a/packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx b/packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx new file mode 100644 index 00000000..29b0c6aa --- /dev/null +++ b/packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx @@ -0,0 +1,84 @@ +/** + * External dependencies. + */ +import { useCurrentEditor, useEditorState } from "@tiptap/react"; +import { BubbleMenu, type BubbleMenuProps } from "@tiptap/react/menus"; +import { Check, X } from "lucide-react"; + +/** + * Internal dependencies. + */ +import { TextInput } from "../../textInput"; +import { Button } from "../../button"; +import { useRef } from "react"; + +const LinkBubbleMenu = () => { + const { editor } = useCurrentEditor(); + const inputRef = useRef(null); + + const state = useEditorState({ + editor, + selector: ({ editor }) => ({ + currentLink: (editor?.getAttributes("link").href || "") as string, + from: editor?.state.selection.from, + }), + }); + + if (!editor) { + return null; + } + + const shouldShow: BubbleMenuProps["shouldShow"] = ({ editor, from, to }) => { + return editor.isActive("link") && from < to; + }; + + const unsetLink = () => { + editor.chain().focus().unsetLink().run(); + }; + + const setLink = (href: string) => { + editor.chain().setLink({ href }).run(); + }; + + const close = () => { + if (state && state.from) { + editor.commands.setTextSelection(state.from); + } + }; + + return ( + +
+
+ setLink(e.target.value)} + /> +
+
+ <> +
+
+
+ ); +}; + +export default LinkBubbleMenu; 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 b333c0e2..a2d7c1e2 100644 --- a/packages/frappe-ui-react/src/components/textEditor/menu/menu.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/menu/menu.tsx @@ -60,6 +60,7 @@ const DEFAULT_COMMANDS: Array< "undo", "redo", "horizontal_rule", + "link", ]; const Menu = ({ className }: MenuProps) => { diff --git a/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx b/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx index c07bf08f..f189c34e 100644 --- a/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx @@ -22,6 +22,7 @@ import { normalizeClasses } from "../../utils"; import type { TextEditorProps } from "./types"; import FixedMenu from "./menu/fixedMenu"; import { ExtendedCodeBlock } from "./extension/codeBlock"; +import LinkBubbleMenu from "./menu/linkBubbleMenu"; const TextEditor = ({ content, @@ -114,6 +115,7 @@ const TextEditor = ({ return ( + {Top && } {fixedMenu && } {Editor ? : } diff --git a/packages/frappe-ui-react/tsconfig.json b/packages/frappe-ui-react/tsconfig.json index 9676ca63..46d74f90 100644 --- a/packages/frappe-ui-react/tsconfig.json +++ b/packages/frappe-ui-react/tsconfig.json @@ -6,6 +6,7 @@ "outDir": "dist", "declaration": true, "declarationDir": "dist-types", + "moduleResolution": "bundler", "sourceMap": true, "typeRoots": ["./typings", "./node_modules/@types"], "noUncheckedSideEffectImports": true, From a7d7140c579d6d456e182f54dc534f6c75d6968d Mon Sep 17 00:00:00 2001 From: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:46:55 +0530 Subject: [PATCH 2/7] chore: add interaction test Signed-off-by: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> --- .../textEditor/menu/linkBubbleMenu.tsx | 30 +++++++-------- .../textEditor.interactions.stories.tsx | 37 +++++++++++++++++++ .../src/components/textInput/textInput.tsx | 1 - 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx b/packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx index 29b0c6aa..7e5dcc0f 100644 --- a/packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx @@ -60,21 +60,21 @@ const LinkBubbleMenu = () => { />
- <> -
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..b8950dea 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,40 @@ export const EditorFontColor: Story = { expect(newText).toHaveStyle("background-color: #ffe7e7"); }, }; + +export const EditorLink: 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) => { + return content.includes("paragraph"); + }); + + await userEvent.tripleClick(text); + + const linkButton = await screen.findByTitle("Link"); + await userEvent.click(linkButton); + + const linkInput = await screen.findByPlaceholderText(/example.com/i); + await userEvent.type(linkInput, "https://test-link.com"); + + const confirmButton = await screen.findByRole("button", { + name: /confirm link/i, + }); + await userEvent.click(confirmButton); + + const linkElement = canvas.getByRole("link"); + expect(linkElement).toHaveAttribute("href", "https://test-link.com"); + }, +}; diff --git a/packages/frappe-ui-react/src/components/textInput/textInput.tsx b/packages/frappe-ui-react/src/components/textInput/textInput.tsx index bcbe4465..1d0da59e 100644 --- a/packages/frappe-ui-react/src/components/textInput/textInput.tsx +++ b/packages/frappe-ui-react/src/components/textInput/textInput.tsx @@ -149,7 +149,6 @@ const TextInput = forwardRef( value={inputValue} required={rest.required} onChange={handleChange} - data-testid="text-input" className={`appearance-none ${inputClasses}`} {...rest} /> From d8af2e35554b6e1dcfab729c94ee59340a5f5fe0 Mon Sep 17 00:00:00 2001 From: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:52:50 +0530 Subject: [PATCH 3/7] refactor: update link when confirm is pressed Signed-off-by: ayushnirwal <53055971+ayushnirwal@users.noreply.github.com> --- .../components/textEditor/menu/linkBubbleMenu.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx b/packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx index 7e5dcc0f..85399aca 100644 --- a/packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/menu/linkBubbleMenu.tsx @@ -10,7 +10,7 @@ import { Check, X } from "lucide-react"; */ import { TextInput } from "../../textInput"; import { Button } from "../../button"; -import { useRef } from "react"; +import { useRef, useState } from "react"; const LinkBubbleMenu = () => { const { editor } = useCurrentEditor(); @@ -24,6 +24,8 @@ const LinkBubbleMenu = () => { }), }); + const [value, setValue] = useState(state?.currentLink); + if (!editor) { return null; } @@ -55,8 +57,8 @@ const LinkBubbleMenu = () => { type="text" placeholder="https://example.com" variant="subtle" - value={state?.currentLink} - onChange={(e) => setLink(e.target.value)} + value={value} + onChange={(e) => setValue(e.target.value)} />
@@ -64,7 +66,10 @@ const LinkBubbleMenu = () => { aria-label="Confirm Link" icon={() => } variant="subtle" - onClick={close} + onClick={() => { + setLink(value ?? ""); + close(); + }} />