[WIKI-479] refactor: pi chat editor #3480

This commit is contained in:
Aaryan Khandelwal
2025-06-24 14:20:34 +05:30
committed by GitHub
parent c584baede8
commit 4e9a8b25eb
6 changed files with 259 additions and 174 deletions

View File

@@ -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<IItem>[] | undefined;
}
type PiChatEditorProps = {
setEditorCommand?: (command: any) => void;
mentionSuggestions?: (query: string) => Promise<any>;
@@ -44,10 +31,6 @@ type PiChatEditorProps = {
editorClass?: string;
};
interface CustomMentionOptions extends MentionOptions {
mentionHighlights: () => Promise<string[]>;
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<CustomMentionOptions>({
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 (
<div

View File

@@ -1,6 +1,8 @@
import { Extension } from "@tiptap/core";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
export const EnterKeyExtension = (onEnterKeyPress?: () => 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(),

View File

@@ -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<any>;
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(),
});
},
}),
];
};

View File

@@ -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<string[]>;
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<any>;
};
export const PiChatEditorMentionExtension = (props: Props) => {
const { mentionSuggestions } = props;
return Mention.extend<CustomMentionOptions>({
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();
},
};
},
},
});
};

View File

@@ -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
</Disclosure>
</>
);
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) => {
</div>
);
});
export default MentionList;

View File

@@ -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<string[]>();