diff --git a/packages/frappe-ui-react/package.json b/packages/frappe-ui-react/package.json index b0eca500..1f19940c 100644 --- a/packages/frappe-ui-react/package.json +++ b/packages/frappe-ui-react/package.json @@ -46,6 +46,7 @@ "@tiptap/extension-code-block-lowlight": "^3.17.1", "@tiptap/extension-highlight": "^3.17.1", "@tiptap/extension-horizontal-rule": "^3.17.1", + "@tiptap/extension-image": "^3.19.0", "@tiptap/extension-list": "^3.17.1", "@tiptap/extension-placeholder": "^3.17.1", "@tiptap/extension-strike": "^3.17.1", diff --git a/packages/frappe-ui-react/src/components/textEditor/extension/codeBlock.css b/packages/frappe-ui-react/src/components/textEditor/extension/codeBlock/codeBlock.css similarity index 100% rename from packages/frappe-ui-react/src/components/textEditor/extension/codeBlock.css rename to packages/frappe-ui-react/src/components/textEditor/extension/codeBlock/codeBlock.css diff --git a/packages/frappe-ui-react/src/components/textEditor/extension/codeBlock.ts b/packages/frappe-ui-react/src/components/textEditor/extension/codeBlock/codeBlock.ts similarity index 100% rename from packages/frappe-ui-react/src/components/textEditor/extension/codeBlock.ts rename to packages/frappe-ui-react/src/components/textEditor/extension/codeBlock/codeBlock.ts index 84e1061d..685b5322 100644 --- a/packages/frappe-ui-react/src/components/textEditor/extension/codeBlock.ts +++ b/packages/frappe-ui-react/src/components/textEditor/extension/codeBlock/codeBlock.ts @@ -2,13 +2,13 @@ * External dependencies. */ import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; +import { ReactNodeViewRenderer } from "@tiptap/react"; import { createLowlight, all } from "lowlight"; /** * Internal dependencies. */ import { getCodeBlockCtx, INDENT, lineStartsBetween } from "./utils"; -import { ReactNodeViewRenderer } from "@tiptap/react"; import CodeBlockComponent from "./codeBlockNode"; const lowlight = createLowlight(all); diff --git a/packages/frappe-ui-react/src/components/textEditor/extension/codeBlockNode.tsx b/packages/frappe-ui-react/src/components/textEditor/extension/codeBlock/codeBlockNode.tsx similarity index 100% rename from packages/frappe-ui-react/src/components/textEditor/extension/codeBlockNode.tsx rename to packages/frappe-ui-react/src/components/textEditor/extension/codeBlock/codeBlockNode.tsx diff --git a/packages/frappe-ui-react/src/components/textEditor/extension/utils.ts b/packages/frappe-ui-react/src/components/textEditor/extension/codeBlock/utils.ts similarity index 100% rename from packages/frappe-ui-react/src/components/textEditor/extension/utils.ts rename to packages/frappe-ui-react/src/components/textEditor/extension/codeBlock/utils.ts 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..e479fac6 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, + Image, } from "lucide-react"; /** @@ -264,6 +265,17 @@ export const COMMANDS: Record = { isDisabled: (editor) => !editor.can().redo(), isActive: () => false, }, + select_image: { + label: "Image", + icon: Image, + action: () => { + const inpEle = document.getElementById("fileInput"); + if (inpEle) { + inpEle.click(); + } + }, + isActive: () => false, + }, }; 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..94b72e87 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", + "select_image", ] as const; export type TYPE_COMMANDS_KEYS = (typeof COMMANDS_KEYS)[number]; 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..973cbb36 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", + "select_image", ]; const Menu = ({ className }: MenuProps) => { diff --git a/packages/frappe-ui-react/src/components/textEditor/textEditor.css b/packages/frappe-ui-react/src/components/textEditor/textEditor.css index 6745066e..f89dfec1 100644 --- a/packages/frappe-ui-react/src/components/textEditor/textEditor.css +++ b/packages/frappe-ui-react/src/components/textEditor/textEditor.css @@ -220,3 +220,37 @@ img.ProseMirror-selectednode { .tag-suggestion-active { background-color: var(--color-surface-gray-2, #f3f3f3); } + + +[data-resize-handle] { + position: absolute; + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.8); + border-radius: 2px; + z-index: 10; + + &:hover { + background: rgba(0, 0, 0, 0.8); + } + + /* Corner handles */ + &[data-resize-handle='top-left'], + &[data-resize-handle='top-right'], + &[data-resize-handle='bottom-left'], + &[data-resize-handle='bottom-right'] { + width: 8px; + height: 8px; + } + + &[data-resize-handle='bottom-right'] { + bottom: -4px; + right: -4px; + cursor: nwse-resize; + } + + } + + [data-resize-state='true'] [data-resize-wrapper] { + outline: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 0.125rem; + } diff --git a/packages/frappe-ui-react/src/components/textEditor/textEditor.stories.tsx b/packages/frappe-ui-react/src/components/textEditor/textEditor.stories.tsx index e5e46013..5592a7db 100644 --- a/packages/frappe-ui-react/src/components/textEditor/textEditor.stories.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/textEditor.stories.tsx @@ -67,13 +67,13 @@ const CONTENT: string = `

This is a paragraph with bold and italic text.

+
import { Button } from '@rtcamp/frappe-ui-react'
 const value = ref(true);
- `; diff --git a/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx b/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx index c07bf08f..f3514f3c 100644 --- a/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx +++ b/packages/frappe-ui-react/src/components/textEditor/textEditor.tsx @@ -12,7 +12,9 @@ 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 Image from "@tiptap/extension-image"; import clsx from "clsx"; +import type { ChangeEventHandler } from "react"; /** * Internal dependencies. @@ -21,7 +23,8 @@ import "./textEditor.css"; import { normalizeClasses } from "../../utils"; import type { TextEditorProps } from "./types"; import FixedMenu from "./menu/fixedMenu"; -import { ExtendedCodeBlock } from "./extension/codeBlock"; +import { ExtendedCodeBlock } from "./extension/codeBlock/codeBlock"; +import { getBase64File } from "./utils/getBase64File"; const TextEditor = ({ content, @@ -39,6 +42,7 @@ const TextEditor = ({ Top, Editor, Bottom, + uploadFunction = getBase64File, }: TextEditorProps) => { const editor = useEditor( { @@ -83,6 +87,16 @@ const TextEditor = ({ }, }), ExtendedCodeBlock, + Image.configure({ + allowBase64: true, + resize: { + enabled: true, + directions: ["bottom-right"], // can be any direction or diagonal combination + minWidth: 50, + minHeight: 50, + alwaysPreserveAspectRatio: true, + }, + }), ...extensions, ], onUpdate: ({ editor }) => { @@ -112,8 +126,34 @@ const TextEditor = ({ ] ); + const handleUpload: ChangeEventHandler = async (e) => { + const file = e.target.files?.[0]; + if (!file || !editor) return; + if (!file.type.startsWith("image/")) { + return; + } + + const res = await uploadFunction(file); + const url = res.file_url; + + if (url) { + editor.chain().focus().setImage({ src: url }).run(); + } + + e.target.value = ""; + }; + return ( + {/* Invisible field to provide upload functionality */} + {Top && } {fixedMenu && } {Editor ? : } diff --git a/packages/frappe-ui-react/src/components/textEditor/types.ts b/packages/frappe-ui-react/src/components/textEditor/types.ts index 433e409c..dd2ef180 100644 --- a/packages/frappe-ui-react/src/components/textEditor/types.ts +++ b/packages/frappe-ui-react/src/components/textEditor/types.ts @@ -3,7 +3,22 @@ */ import { Editor, type Extension } from "@tiptap/react"; import type { StarterKitOptions } from "@tiptap/starter-kit"; -import type { FC } from "react"; + +export interface UploadedFile { + file_name: string; + file_size: number; + file_url: string; + name?: string; + owner?: string; + creation?: string; + modified?: string; + modified_by?: string; + is_private?: 0 | 1; + file_type?: string; + folder?: string; + is_folder?: 0 | 1; + content_hash?: string; +} export interface TextEditorProps { // Props @@ -21,9 +36,11 @@ export interface TextEditorProps { onBlur?: (event: FocusEvent) => void; onTransaction?: (editor: Editor) => void; // Slots - Top?: FC; - Editor?: FC<{ editor: Editor }>; - Bottom?: FC; + Top?: React.FC; + Editor?: React.FC<{ editor: Editor }>; + Bottom?: React.FC; + // handlers + uploadFunction?: (file: File) => Promise; } export interface EditorCommand { diff --git a/packages/frappe-ui-react/src/components/textEditor/utils/getBase64File.ts b/packages/frappe-ui-react/src/components/textEditor/utils/getBase64File.ts new file mode 100644 index 00000000..81952b68 --- /dev/null +++ b/packages/frappe-ui-react/src/components/textEditor/utils/getBase64File.ts @@ -0,0 +1,27 @@ +/** + * External dependencies. + */ +import type { UploadedFile } from "../types"; + +export const getBase64File = async (file: File): Promise => { + const base64Url = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + const result = event.target?.result; + if (typeof result === "string") { + resolve(result); + } else { + reject(new Error("Failed to read file as base64")); + } + }; + reader.onerror = () => { + reject(new Error("Error reading file")); + }; + reader.readAsDataURL(file); + }); + return { + file_name: file.name, + file_size: file.size, + file_url: base64Url, + }; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ec5c72d..eed14c31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,9 @@ importers: '@tiptap/extension-horizontal-rule': specifier: ^3.17.1 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) + '@tiptap/extension-image': + specifier: ^3.19.0 + version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0)) '@tiptap/extension-list': specifier: ^3.17.1 version: 3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))(@tiptap/pm@3.19.0) @@ -2103,6 +2106,11 @@ packages: '@tiptap/core': ^3.19.0 '@tiptap/pm': ^3.19.0 + '@tiptap/extension-image@3.19.0': + resolution: {integrity: sha512-/rGl8nBziBPVJJ/9639eQWFDKcI3RQsDM3s+cqYQMFQfMqc7sQB9h4o4sHCBpmKxk3Y0FV/0NjnjLbBVm8OKdQ==} + peerDependencies: + '@tiptap/core': ^3.19.0 + '@tiptap/extension-italic@3.19.0': resolution: {integrity: sha512-6GffxOnS/tWyCbDkirWNZITiXRta9wrCmrfa4rh+v32wfaOL1RRQNyqo9qN6Wjyl1R42Js+yXTzTTzZsOaLMYA==} peerDependencies: @@ -7108,6 +7116,10 @@ snapshots: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) '@tiptap/pm': 3.19.0 + '@tiptap/extension-image@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': + dependencies: + '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0) + '@tiptap/extension-italic@3.19.0(@tiptap/core@3.19.0(@tiptap/pm@3.19.0))': dependencies: '@tiptap/core': 3.19.0(@tiptap/pm@3.19.0)