mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 22:09:12 +02:00
[WIKI-479] refactor: pi chat editor #3480
This commit is contained in:
committed by
GitHub
parent
c584baede8
commit
4e9a8b25eb
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
@@ -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(),
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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[]>();
|
||||
|
||||
Reference in New Issue
Block a user