From 4e9a8b25eb02abb42dd63c5fc37f5539f0dfa795 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Date: Tue, 24 Jun 2025 14:20:34 +0530 Subject: [PATCH] [WIKI-479] refactor: pi chat editor #3480 --- .../editors/pi-chat-editor/editor.tsx | 166 ++---------------- .../pi-chat-editor/enter-key.ts} | 10 +- .../extensions/pi-chat-editor/extensions.ts | 89 ++++++++++ .../pi-chat-editor/mention/extension.ts | 141 +++++++++++++++ .../pi-chat-editor/mention/mentions-list.tsx} | 15 +- .../pi-chat-editor/mention/node-view.tsx} | 12 +- 6 files changed, 259 insertions(+), 174 deletions(-) rename packages/editor/src/ee/{components/editors/pi-chat-editor/extensions/enter-key-extension.tsx => extensions/pi-chat-editor/enter-key.ts} (64%) create mode 100644 packages/editor/src/ee/extensions/pi-chat-editor/extensions.ts create mode 100644 packages/editor/src/ee/extensions/pi-chat-editor/mention/extension.ts rename packages/editor/src/ee/{components/editors/pi-chat-editor/mention-list.tsx => extensions/pi-chat-editor/mention/mentions-list.tsx} (95%) rename packages/editor/src/ee/{components/editors/pi-chat-editor/mention-node-view.tsx => extensions/pi-chat-editor/mention/node-view.tsx} (80%) diff --git a/packages/editor/src/ee/components/editors/pi-chat-editor/editor.tsx b/packages/editor/src/ee/components/editors/pi-chat-editor/editor.tsx index e39d402be9..54bcf9e0c5 100644 --- a/packages/editor/src/ee/components/editors/pi-chat-editor/editor.tsx +++ b/packages/editor/src/ee/components/editors/pi-chat-editor/editor.tsx @@ -1,22 +1,8 @@ -import Document from "@tiptap/extension-document"; -import Mention, { MentionOptions } from "@tiptap/extension-mention"; -import Paragraph from "@tiptap/extension-paragraph"; -import Text from "@tiptap/extension-text"; -import { - Editor, - EditorContent, - Extension, - mergeAttributes, - ReactNodeViewRenderer, - ReactRenderer, - useEditor, -} from "@tiptap/react"; -import MentionList from "./mention-list.js"; -import tippy from "tippy.js"; -import { PiMentionNodeView } from "./mention-node-view.js"; -import { getTrimmedHTML } from "@/helpers/common.js"; -import { EnterKeyExtension } from "./extensions/enter-key-extension.js"; +import { EditorContent, useEditor } from "@tiptap/react"; +// plane imports import { cn } from "@plane/utils"; +// plane editor imports +import { PiChatEditorExtensions } from "@/plane-editor/extensions/pi-chat-editor/extensions"; interface IItem { id: string; @@ -35,6 +21,7 @@ interface IItem { export interface IMentions { [key: string]: Partial[] | undefined; } + type PiChatEditorProps = { setEditorCommand?: (command: any) => void; mentionSuggestions?: (query: string) => Promise; @@ -44,10 +31,6 @@ type PiChatEditorProps = { editorClass?: string; }; -interface CustomMentionOptions extends MentionOptions { - mentionHighlights: () => Promise; - readonly?: boolean; -} export const PiChatEditor = (props: PiChatEditorProps) => { const { setEditorCommand, @@ -59,141 +42,16 @@ export const PiChatEditor = (props: PiChatEditorProps) => { } = props; const editor = useEditor({ editable, - extensions: [ - EnterKeyExtension(handleSubmit), - Document, - Paragraph, - Text, - Mention.extend({ - addStorage(this) { - return { - mentionsOpen: false, - }; - }, - addAttributes() { - return { - id: { - default: null, - }, - label: { - default: null, - }, - target: { - default: null, - }, - self: { - default: false, - }, - redirect_uri: { - default: "/", - }, - entity_identifier: { - default: null, - }, - entity_name: { - default: null, - }, - }; - }, - addNodeView() { - return ReactNodeViewRenderer(PiMentionNodeView); - }, - parseHTML() { - return [ - { - tag: "mention-component", - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ["mention-component", mergeAttributes(HTMLAttributes)]; - }, - }).configure({ - HTMLAttributes: { - class: "mention", - }, - suggestion: { - items: async ({ query }) => { - const response = await mentionSuggestions?.(query); - return response; - }, - render: () => { - let component; - let popup; - - return { - onStart: (props) => { - component = new ReactRenderer(MentionList, { - props, - editor: props.editor, - }); - - if (!props.clientRect) { - return; - } - // @ts-expect-error types are incorrect - popup = tippy("body", { - getReferenceClientRect: props.clientRect, - appendTo: () => document.body, - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); - }, - - onUpdate(props) { - component.updateProps(props); - - if (!props.clientRect) { - return; - } - - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, - - onKeyDown(props) { - if (props.event.key === "Escape") { - popup[0].hide(); - - return true; - } - - return component.ref?.onKeyDown(props); - }, - - onExit() { - popup[0].destroy(); - component.destroy(); - }, - }; - }, - }, - }), - Paragraph.configure({ - HTMLAttributes: { - class: `text-[14px] leading-5 font-normal ${editorClass}`, - }, - }), - Extension.create({ - onUpdate(this) { - // The content has changed. - setEditorCommand?.({ - getHTML: () => getTrimmedHTML(this.editor?.getHTML()), - clear: () => this.editor?.commands.clearContent(), - }); - }, - }), - ], + extensions: PiChatEditorExtensions({ + editorClass, + handleSubmit, + mentionSuggestions, + setEditorCommand, + }), content: content, }); - if (!editor) { - return null; - } + if (!editor) return null; return (
void) => +export const PiChatEditorEnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({ name: "enterKey", @@ -8,9 +10,7 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) => return { Enter: () => { if (!this.editor.storage.mentionsOpen) { - if (onEnterKeyPress) { - onEnterKeyPress(); - } + onEnterKeyPress?.(); return true; } return false; @@ -18,6 +18,8 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) => "Shift-Enter": ({ editor }) => editor.commands.first(({ commands }) => [ () => commands.newlineInCode(), + () => commands.splitListItem(CORE_EXTENSIONS.LIST_ITEM), + () => commands.splitListItem(CORE_EXTENSIONS.TASK_ITEM), () => commands.createParagraphNear(), () => commands.liftEmptyBlock(), () => commands.splitBlock(), diff --git a/packages/editor/src/ee/extensions/pi-chat-editor/extensions.ts b/packages/editor/src/ee/extensions/pi-chat-editor/extensions.ts new file mode 100644 index 0000000000..0372089a5b --- /dev/null +++ b/packages/editor/src/ee/extensions/pi-chat-editor/extensions.ts @@ -0,0 +1,89 @@ +import { Extension, type Extensions } from "@tiptap/core"; +import TaskItem from "@tiptap/extension-task-item"; +import TaskList from "@tiptap/extension-task-list"; +import StarterKit from "@tiptap/starter-kit"; +// extensions +import { CustomCodeBlockExtension } from "@/extensions/code"; +import { CustomCodeInlineExtension } from "@/extensions/code-inline"; +// helpers +import { getTrimmedHTML } from "@/helpers/common"; +// local imports +import { PiChatEditorEnterKeyExtension } from "./enter-key"; +import { PiChatEditorMentionExtension } from "./mention/extension"; + +type Props = { + editorClass: string; + handleSubmit?: () => void; + mentionSuggestions?: (query: string) => Promise; + setEditorCommand?: (command: any) => void; +}; + +export const PiChatEditorExtensions = (props: Props): Extensions => { + const { editorClass, handleSubmit, mentionSuggestions, setEditorCommand } = props; + + return [ + // @ts-expect-error tiptap types are incorrect + StarterKit.configure({ + bold: false, + blockquote: false, + code: false, + codeBlock: false, + dropcursor: false, + gapcursor: false, + hardBreak: false, + heading: false, + horizontalRule: false, + italic: false, + paragraph: { + HTMLAttributes: { + class: `text-[14px] leading-5 font-normal ${editorClass}`, + }, + }, + strike: false, + bulletList: { + HTMLAttributes: { + class: "list-disc pl-7 space-y-[--list-spacing-y]", + }, + }, + orderedList: { + HTMLAttributes: { + class: "list-decimal pl-7 space-y-[--list-spacing-y]", + }, + }, + listItem: { + HTMLAttributes: { + class: "not-prose space-y-2", + }, + }, + }), + PiChatEditorMentionExtension({ + mentionSuggestions, + }), + PiChatEditorEnterKeyExtension(handleSubmit), + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2 space-y-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "relative", + }, + nested: true, + }), + CustomCodeBlockExtension.configure({ + HTMLAttributes: { + class: "", + }, + }), + CustomCodeInlineExtension, + Extension.create({ + onUpdate(this) { + setEditorCommand?.({ + getHTML: () => getTrimmedHTML(this.editor?.getHTML()), + clear: () => this.editor?.commands.clearContent(), + }); + }, + }), + ]; +}; diff --git a/packages/editor/src/ee/extensions/pi-chat-editor/mention/extension.ts b/packages/editor/src/ee/extensions/pi-chat-editor/mention/extension.ts new file mode 100644 index 0000000000..a21c14c3c9 --- /dev/null +++ b/packages/editor/src/ee/extensions/pi-chat-editor/mention/extension.ts @@ -0,0 +1,141 @@ +import Mention, { MentionOptions } from "@tiptap/extension-mention"; +import { mergeAttributes, ReactNodeViewRenderer, ReactRenderer } from "@tiptap/react"; +import tippy from "tippy.js"; +// local imports +import { PiChatEditorMentionsList } from "./mentions-list"; +import { PiChatEditorMentionNodeView } from "./node-view"; + +interface CustomMentionOptions extends MentionOptions { + mentionHighlights: () => Promise; + readonly?: boolean; +} + +export type IMentionSuggestion = { + id: string; + type: string; + entity_name: string; + entity_identifier: string; + avatar: string; + title: string; + subtitle: string; + redirect_uri: string; +}; + +type Props = { + mentionSuggestions?: (query: string) => Promise; +}; + +export const PiChatEditorMentionExtension = (props: Props) => { + const { mentionSuggestions } = props; + + return Mention.extend({ + addStorage(this) { + return { + mentionsOpen: false, + }; + }, + + addAttributes() { + return { + id: { + default: null, + }, + label: { + default: null, + }, + target: { + default: null, + }, + self: { + default: false, + }, + redirect_uri: { + default: "/", + }, + entity_identifier: { + default: null, + }, + entity_name: { + default: null, + }, + }; + }, + addNodeView() { + return ReactNodeViewRenderer(PiChatEditorMentionNodeView); + }, + parseHTML() { + return [ + { + tag: "mention-component", + }, + ]; + }, + renderHTML({ HTMLAttributes }) { + return ["mention-component", mergeAttributes(HTMLAttributes)]; + }, + }).configure({ + HTMLAttributes: { + class: "mention", + }, + suggestion: { + items: async ({ query }) => { + const response = await mentionSuggestions?.(query); + return response; + }, + render: () => { + let component; + let popup; + + return { + onStart: (props) => { + component = new ReactRenderer(PiChatEditorMentionsList, { + props, + editor: props.editor, + }); + + if (!props.clientRect) { + return; + } + // @ts-expect-error types are incorrect + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + + onUpdate(props) { + component.updateProps(props); + + if (!props.clientRect) { + return; + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + + onKeyDown(props) { + if (props.event.key === "Escape") { + popup[0].hide(); + + return true; + } + + return component.ref?.onKeyDown(props); + }, + + onExit() { + popup[0].destroy(); + component.destroy(); + }, + }; + }, + }, + }); +}; diff --git a/packages/editor/src/ee/components/editors/pi-chat-editor/mention-list.tsx b/packages/editor/src/ee/extensions/pi-chat-editor/mention/mentions-list.tsx similarity index 95% rename from packages/editor/src/ee/components/editors/pi-chat-editor/mention-list.tsx rename to packages/editor/src/ee/extensions/pi-chat-editor/mention/mentions-list.tsx index 8e4d258fe2..5415d01411 100644 --- a/packages/editor/src/ee/components/editors/pi-chat-editor/mention-list.tsx +++ b/packages/editor/src/ee/extensions/pi-chat-editor/mention/mentions-list.tsx @@ -1,9 +1,11 @@ -import { IMentionSuggestion } from "@/types"; import { Disclosure, Transition } from "@headlessui/react"; -import { cn } from "@plane/utils"; import { Editor } from "@tiptap/core"; import { forwardRef, useEffect, useImperativeHandle, useState } from "react"; import smoothScrollIntoView from "smooth-scroll-into-view-if-needed"; +// plane imports +import { cn } from "@plane/utils"; +// local imports +import { IMentionSuggestion } from "./extension"; interface IItem { id: string; @@ -84,7 +86,8 @@ const Suggestions = ({ type, data, onClick, key, selectedIndex, isSectionSelecte ); -const MentionList = forwardRef((props: MentionListProps, ref) => { + +export const PiChatEditorMentionsList = forwardRef((props: MentionListProps, ref) => { const [selectedIndex, setSelectedIndex] = useState(0); const [selectedSection, setSelectedSection] = useState(0); const [isEmpty, setIsEmpty] = useState(true); @@ -94,9 +97,9 @@ const MentionList = forwardRef((props: MentionListProps, ref) => { if (selectedItem) { props.command({ id: selectedItem.id, - label: type === "issue" ? `${selectedItem.subTitle}` : selectedItem.title, + label: type === "issue" ? `${selectedItem.subTitle ?? ""}` : (selectedItem.title ?? ""), entity_identifier: selectedItem.id, - entity_name: selectedItem.title, + entity_name: selectedItem.title ?? "", target: `${type}s`, redirect_uri: "", }); @@ -206,5 +209,3 @@ const MentionList = forwardRef((props: MentionListProps, ref) => {
); }); - -export default MentionList; diff --git a/packages/editor/src/ee/components/editors/pi-chat-editor/mention-node-view.tsx b/packages/editor/src/ee/extensions/pi-chat-editor/mention/node-view.tsx similarity index 80% rename from packages/editor/src/ee/components/editors/pi-chat-editor/mention-node-view.tsx rename to packages/editor/src/ee/extensions/pi-chat-editor/mention/node-view.tsx index aef03390b0..2d8294e645 100644 --- a/packages/editor/src/ee/components/editors/pi-chat-editor/mention-node-view.tsx +++ b/packages/editor/src/ee/extensions/pi-chat-editor/mention/node-view.tsx @@ -1,15 +1,9 @@ -// TODO: fix all warnings - -/* eslint-disable react/display-name */ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck -import { useEffect, useState } from "react"; import { NodeViewWrapper } from "@tiptap/react"; -// plane utils +import { useEffect, useState } from "react"; +// plane imports import { cn } from "@plane/utils"; -// eslint-disable-next-line import/no-anonymous-default-export -export const PiMentionNodeView = (props) => { +export const PiChatEditorMentionNodeView = (props) => { // TODO: move it to web app const [highlightsState, setHighlightsState] = useState();