forked from github/plane
refactoring to LiteTextEditor and RichTextEditor
This commit is contained in:
parent
a754300116
commit
da86f1ad03
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@plane/editor",
|
"name": "@plane/editor-core",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "Rich Text Editor that powers Plane",
|
"description": "Rich Text Editor that powers Plane",
|
||||||
"main": "./dist/index.mjs",
|
"main": "./dist/index.mjs",
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
import { DeleteImage } from "@/types/delete-image";
|
|
||||||
import { UploadImage } from "@/types/upload-image";
|
|
||||||
import { TiptapExtensions } from "@/ui/extensions";
|
|
||||||
import { TiptapEditorProps } from "@/ui/props";
|
|
||||||
import { useEditor as useTiptapEditor } from "@tiptap/react";
|
|
||||||
|
|
||||||
interface ITiptapEditor {
|
|
||||||
value: string;
|
|
||||||
uploadFile: UploadImage;
|
|
||||||
deleteFile: DeleteImage;
|
|
||||||
onChange?: (json: any, html: string) => void;
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
|
||||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
|
||||||
editable?: boolean;
|
|
||||||
debouncedUpdatesEnabled?: boolean;
|
|
||||||
debouncedUpdates: ({ onChange, editor }: { onChange?: (json: any, html: string) => void; editor: any }) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useEditor = ({ uploadFile, debouncedUpdates, setShouldShowAlert, deleteFile, setIsSubmitting, value, onChange, debouncedUpdatesEnabled, editable }: ITiptapEditor) => useTiptapEditor({
|
|
||||||
editable: editable ?? true,
|
|
||||||
editorProps: TiptapEditorProps(uploadFile, setIsSubmitting),
|
|
||||||
extensions: TiptapExtensions(uploadFile, deleteFile, setIsSubmitting),
|
|
||||||
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
|
|
||||||
onUpdate: async ({ editor }) => {
|
|
||||||
// for instant feedback loop
|
|
||||||
setIsSubmitting?.("submitting");
|
|
||||||
setShouldShowAlert?.(true);
|
|
||||||
if (debouncedUpdatesEnabled) {
|
|
||||||
debouncedUpdates({ onChange, editor });
|
|
||||||
} else {
|
|
||||||
onChange?.(editor.getJSON(), editor.getHTML());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,13 +1,10 @@
|
|||||||
import "@/styles/tailwind.css";
|
import "@/styles/tailwind.css";
|
||||||
import "@/styles/editor.css";
|
import "@/styles/editor.css";
|
||||||
// export { ImageResizer } from "./ui/extensions/image/image-resize";
|
export { startImageUpload } from "@/ui/plugins/upload-image";
|
||||||
// export { TiptapEditorProps } from "./ui/props";
|
|
||||||
// export { TableMenu } from "./ui/menus/table-menu";
|
|
||||||
// export { TiptapExtensions } from "./ui/extensions";
|
|
||||||
// export { cn } from "./lib/utils";
|
|
||||||
// export { FixedMenu } from "./ui/menus/fixed-menu";
|
|
||||||
// export { EditorBubbleMenu } from "./ui/menus/bubble-menu";
|
|
||||||
|
|
||||||
export { TiptapEditor, TiptapEditorWithRef } from "@/ui";
|
// export { TiptapEditor, TiptapEditorWithRef } from "@/ui";
|
||||||
|
export { useEditor } from "@/ui/hooks/useEditor";
|
||||||
export { useEditor } from "@/useEditor";
|
export { cn } from "@/lib/utils";
|
||||||
|
export { getEditorClassNames } from "@/lib/utils";
|
||||||
|
export { EditorContainer } from "@/ui/editor-container";
|
||||||
|
export { EditorContentWrapper } from "@/ui/editor-content";
|
||||||
|
@ -1,4 +0,0 @@
|
|||||||
export interface EditorHandle {
|
|
||||||
clearEditor: () => void;
|
|
||||||
setEditorValue: (content: string) => void;
|
|
||||||
}
|
|
@ -12,4 +12,18 @@ export const findTableAncestor = (
|
|||||||
node = node.parentNode;
|
node = node.parentNode;
|
||||||
}
|
}
|
||||||
return node as HTMLTableElement;
|
return node as HTMLTableElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface EditorClassNames {
|
||||||
|
noBorder?: boolean;
|
||||||
|
borderOnFocus?: boolean;
|
||||||
|
customClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) => cn(
|
||||||
|
'relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md',
|
||||||
|
noBorder ? '' : 'border border-custom-border-200',
|
||||||
|
borderOnFocus ? 'focus:border border-custom-border-300' : 'focus:border-0',
|
||||||
|
customClassName
|
||||||
|
);
|
||||||
|
|
||||||
|
20
packages/editor/core/src/ui/editor-container.tsx
Normal file
20
packages/editor/core/src/ui/editor-container.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface EditorContainerProps {
|
||||||
|
editor: Editor | null;
|
||||||
|
editorClassNames: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditorContainer = ({ editor, editorClassNames, children }: EditorContainerProps) => (
|
||||||
|
<div
|
||||||
|
id="tiptap-container"
|
||||||
|
onClick={() => {
|
||||||
|
editor?.chain().focus().run();
|
||||||
|
}}
|
||||||
|
className={`tiptap-editor-container cursor-text ${editorClassNames}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
19
packages/editor/core/src/ui/editor-content.tsx
Normal file
19
packages/editor/core/src/ui/editor-content.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Editor, EditorContent } from "@tiptap/react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { ImageResizer } from "@/ui/extensions/image/image-resize";
|
||||||
|
import { TableMenu } from "./menus/table-menu";
|
||||||
|
|
||||||
|
interface EditorContentProps {
|
||||||
|
editor: Editor | null;
|
||||||
|
editorContentCustomClassNames: string | undefined;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => (
|
||||||
|
<div className={`${editorContentCustomClassNames}`}>
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
<TableMenu editor={editor} />
|
||||||
|
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
@ -3,7 +3,7 @@ import TrackImageDeletionPlugin from "@/ui/plugins/delete-image";
|
|||||||
import UploadImagesPlugin from "@/ui/plugins/upload-image";
|
import UploadImagesPlugin from "@/ui/plugins/upload-image";
|
||||||
import { DeleteImage } from "@/types/delete-image";
|
import { DeleteImage } from "@/types/delete-image";
|
||||||
|
|
||||||
const UpdatedImage = (deleteImage: DeleteImage) => Image.extend({
|
const ImageExtension = (deleteImage: DeleteImage) => Image.extend({
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)];
|
return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)];
|
||||||
},
|
},
|
||||||
@ -20,4 +20,4 @@ const UpdatedImage = (deleteImage: DeleteImage) => Image.extend({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default UpdatedImage;
|
export default ImageExtension;
|
@ -1,88 +0,0 @@
|
|||||||
import StarterKit from "@tiptap/starter-kit";
|
|
||||||
import TiptapLink from "@tiptap/extension-link";
|
|
||||||
import TiptapUnderline from "@tiptap/extension-underline";
|
|
||||||
import TextStyle from "@tiptap/extension-text-style";
|
|
||||||
import { Color } from "@tiptap/extension-color";
|
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
|
||||||
import { Markdown } from "tiptap-markdown";
|
|
||||||
import Gapcursor from "@tiptap/extension-gapcursor";
|
|
||||||
|
|
||||||
import UpdatedImage from "@/ui/extensions/image/updated-image";
|
|
||||||
|
|
||||||
import { DeleteImage } from "@/types/delete-image";
|
|
||||||
|
|
||||||
import isValidHttpUrl from "@/ui/menus/bubble-menu/utils"
|
|
||||||
|
|
||||||
export const TiptapExtensions = (
|
|
||||||
deleteFile: DeleteImage,
|
|
||||||
) => [
|
|
||||||
StarterKit.configure({
|
|
||||||
bulletList: {
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "list-disc list-outside leading-3 -mt-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderedList: {
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "list-decimal list-outside leading-3 -mt-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
listItem: {
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "leading-normal -mb-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
blockquote: {
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "border-l-4 border-custom-border-300",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
code: {
|
|
||||||
HTMLAttributes: {
|
|
||||||
class:
|
|
||||||
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
|
|
||||||
spellcheck: "false",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
codeBlock: false,
|
|
||||||
horizontalRule: false,
|
|
||||||
dropcursor: {
|
|
||||||
color: "rgba(var(--color-text-100))",
|
|
||||||
width: 2,
|
|
||||||
},
|
|
||||||
gapcursor: false,
|
|
||||||
}),
|
|
||||||
Gapcursor,
|
|
||||||
TiptapLink.configure({
|
|
||||||
protocols: ["http", "https"],
|
|
||||||
validate: (url) => isValidHttpUrl(url),
|
|
||||||
HTMLAttributes: {
|
|
||||||
class:
|
|
||||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
UpdatedImage(deleteFile).configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "rounded-lg border border-custom-border-300",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
TiptapUnderline,
|
|
||||||
TextStyle,
|
|
||||||
Color,
|
|
||||||
TaskList.configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "not-prose pl-2",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
TaskItem.configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "flex items-start my-4",
|
|
||||||
},
|
|
||||||
nested: true,
|
|
||||||
}),
|
|
||||||
Markdown.configure({
|
|
||||||
html: true,
|
|
||||||
transformCopiedText: true,
|
|
||||||
}),
|
|
||||||
];
|
|
@ -1,40 +1,26 @@
|
|||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
|
||||||
import TiptapLink from "@tiptap/extension-link";
|
import TiptapLink from "@tiptap/extension-link";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
|
||||||
import TiptapUnderline from "@tiptap/extension-underline";
|
import TiptapUnderline from "@tiptap/extension-underline";
|
||||||
import TextStyle from "@tiptap/extension-text-style";
|
import TextStyle from "@tiptap/extension-text-style";
|
||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import { Markdown } from "tiptap-markdown";
|
import { Markdown } from "tiptap-markdown";
|
||||||
import Highlight from "@tiptap/extension-highlight";
|
|
||||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
|
||||||
import { InputRule } from "@tiptap/core";
|
|
||||||
import Gapcursor from "@tiptap/extension-gapcursor";
|
import Gapcursor from "@tiptap/extension-gapcursor";
|
||||||
import { Table } from "@/ui/extensions/table/table";
|
|
||||||
import { TableHeader } from "@/ui/extensions/table/table-header";
|
|
||||||
import { TableRow } from "@tiptap/extension-table-row";
|
|
||||||
import { CustomTableCell } from "@/ui/extensions/table/table-cell";
|
|
||||||
|
|
||||||
import UpdatedImage from "@/ui/extensions/image/updated-image";
|
import { CustomTableCell } from "./table/table-cell";
|
||||||
import SlashCommand from "@/ui/extensions/slash-command";
|
import { Table } from "./table/table";
|
||||||
|
import { TableHeader } from "./table/table-header";
|
||||||
|
import { TableRow } from "@tiptap/extension-table-row";
|
||||||
|
|
||||||
|
import ImageExtension from "@/ui/extensions/image";
|
||||||
|
|
||||||
import { DeleteImage } from "@/types/delete-image";
|
import { DeleteImage } from "@/types/delete-image";
|
||||||
import { UploadImage } from "@/types/upload-image";
|
|
||||||
|
|
||||||
import isValidHttpUrl from "@/ui/menus/bubble-menu/utils"
|
import isValidHttpUrl from "@/ui/menus/bubble-menu/utils"
|
||||||
|
|
||||||
import ts from "highlight.js/lib/languages/typescript";
|
|
||||||
import { lowlight } from "lowlight/lib/core";
|
|
||||||
import "highlight.js/styles/github-dark.css";
|
|
||||||
|
|
||||||
lowlight.registerLanguage("ts", ts);
|
|
||||||
|
|
||||||
export const TiptapExtensions = (
|
export const TiptapExtensions = (
|
||||||
uploadFile: UploadImage,
|
|
||||||
deleteFile: DeleteImage,
|
deleteFile: DeleteImage,
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
|
||||||
) => [
|
) => [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
bulletList: {
|
bulletList: {
|
||||||
@ -72,32 +58,6 @@ export const TiptapExtensions = (
|
|||||||
},
|
},
|
||||||
gapcursor: false,
|
gapcursor: false,
|
||||||
}),
|
}),
|
||||||
CodeBlockLowlight.configure({
|
|
||||||
lowlight,
|
|
||||||
}),
|
|
||||||
HorizontalRule.extend({
|
|
||||||
addInputRules() {
|
|
||||||
return [
|
|
||||||
new InputRule({
|
|
||||||
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
|
||||||
handler: ({ state, range, commands }) => {
|
|
||||||
commands.splitBlock();
|
|
||||||
|
|
||||||
const attributes = {};
|
|
||||||
const { tr } = state;
|
|
||||||
const start = range.from;
|
|
||||||
const end = range.to;
|
|
||||||
// @ts-ignore
|
|
||||||
tr.replaceWith(start - 1, end, this.type.create(attributes));
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
}).configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "mb-6 border-t border-custom-border-300",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Gapcursor,
|
Gapcursor,
|
||||||
TiptapLink.configure({
|
TiptapLink.configure({
|
||||||
protocols: ["http", "https"],
|
protocols: ["http", "https"],
|
||||||
@ -107,31 +67,14 @@ export const TiptapExtensions = (
|
|||||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
UpdatedImage(deleteFile).configure({
|
ImageExtension(deleteFile).configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "rounded-lg border border-custom-border-300",
|
class: "rounded-lg border border-custom-border-300",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: ({ node }) => {
|
|
||||||
if (node.type.name === "heading") {
|
|
||||||
return `Heading ${node.attrs.level}`;
|
|
||||||
}
|
|
||||||
if (node.type.name === "image" || node.type.name === "table") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Press '/' for commands...";
|
|
||||||
},
|
|
||||||
includeChildren: true,
|
|
||||||
}),
|
|
||||||
SlashCommand(uploadFile, setIsSubmitting),
|
|
||||||
TiptapUnderline,
|
TiptapUnderline,
|
||||||
TextStyle,
|
TextStyle,
|
||||||
Color,
|
Color,
|
||||||
Highlight.configure({
|
|
||||||
multicolor: true,
|
|
||||||
}),
|
|
||||||
TaskList.configure({
|
TaskList.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "not-prose pl-2",
|
class: "not-prose pl-2",
|
||||||
|
70
packages/editor/core/src/ui/hooks/useEditor.tsx
Normal file
70
packages/editor/core/src/ui/hooks/useEditor.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { useEditor as useCustomEditor, Editor, Extension } from "@tiptap/react";
|
||||||
|
import { useImperativeHandle, useRef, MutableRefObject, forwardRef } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { UploadImage } from '@/types/upload-image';
|
||||||
|
import { DeleteImage } from '@/types/delete-image';
|
||||||
|
import { TiptapEditorProps } from "../props";
|
||||||
|
import { TiptapExtensions } from "../extensions";
|
||||||
|
import { EditorProps } from '@tiptap/pm/view';
|
||||||
|
|
||||||
|
const DEBOUNCE_DELAY = 1500;
|
||||||
|
|
||||||
|
interface CustomEditorProps {
|
||||||
|
editable?: boolean;
|
||||||
|
uploadFile: UploadImage;
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||||
|
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||||
|
value: string;
|
||||||
|
deleteFile: DeleteImage;
|
||||||
|
debouncedUpdatesEnabled?: boolean;
|
||||||
|
onChange?: (json: any, html: string) => void;
|
||||||
|
extensions?: Extension[];
|
||||||
|
editorProps?: EditorProps;
|
||||||
|
forwardedRef?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditor = ({ uploadFile, editable, deleteFile, editorProps = {}, value, extensions = [], onChange, setIsSubmitting, debouncedUpdatesEnabled, forwardedRef, setShouldShowAlert, }: CustomEditorProps) => {
|
||||||
|
const editor = useCustomEditor({
|
||||||
|
editable: editable ?? true,
|
||||||
|
editorProps: {
|
||||||
|
...TiptapEditorProps(uploadFile, setIsSubmitting),
|
||||||
|
...editorProps,
|
||||||
|
},
|
||||||
|
extensions: [...TiptapExtensions(deleteFile), ...extensions],
|
||||||
|
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
|
||||||
|
onUpdate: async ({ editor }) => {
|
||||||
|
// for instant feedback loop
|
||||||
|
setIsSubmitting?.("submitting");
|
||||||
|
setShouldShowAlert?.(true);
|
||||||
|
if (debouncedUpdatesEnabled) {
|
||||||
|
debouncedUpdates({ onChange: onChange, editor });
|
||||||
|
} else {
|
||||||
|
onChange?.(editor.getJSON(), editor.getHTML());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||||
|
editorRef.current = editor;
|
||||||
|
|
||||||
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
|
clearEditor: () => {
|
||||||
|
editorRef.current?.commands.clearContent();
|
||||||
|
},
|
||||||
|
setEditorValue: (content: string) => {
|
||||||
|
editorRef.current?.commands.setContent(content);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(editor.getJSON(), editor.getHTML());
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_DELAY);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
@ -1,150 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import * as React from 'react';
|
|
||||||
import { useImperativeHandle, useRef, forwardRef } from "react";
|
|
||||||
import { useEditor, EditorContent, Editor, Extension } from "@tiptap/react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
import { TableMenu } from '@/ui/menus/table-menu';
|
|
||||||
import { TiptapExtensions } from '@/ui/extensions';
|
|
||||||
import { EditorBubbleMenu } from '@/ui/menus/bubble-menu';
|
|
||||||
import { ImageResizer } from '@/ui/extensions/image/image-resize';
|
|
||||||
import { TiptapEditorProps } from '@/ui/props';
|
|
||||||
import { UploadImage } from '@/types/upload-image';
|
|
||||||
import { DeleteImage } from '@/types/delete-image';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { FixedMenu } from './menus/fixed-menu';
|
|
||||||
import { EditorProps } from '@tiptap/pm/view';
|
|
||||||
|
|
||||||
interface ITiptapEditor {
|
|
||||||
value: string;
|
|
||||||
uploadFile: UploadImage;
|
|
||||||
deleteFile: DeleteImage;
|
|
||||||
noBorder?: boolean;
|
|
||||||
borderOnFocus?: boolean;
|
|
||||||
customClassName?: string;
|
|
||||||
editorContentCustomClassNames?: string;
|
|
||||||
onChange?: (json: any, html: string) => void;
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
|
||||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
|
||||||
editable?: boolean;
|
|
||||||
forwardedRef?: any;
|
|
||||||
debouncedUpdatesEnabled?: boolean;
|
|
||||||
accessValue: string;
|
|
||||||
onAccessChange: (accessKey: string) => void;
|
|
||||||
commentAccess: {
|
|
||||||
icon: string;
|
|
||||||
key: string;
|
|
||||||
label: "Private" | "Public";
|
|
||||||
}[];
|
|
||||||
extensions?: Extension[];
|
|
||||||
editorProps?: EditorProps;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TiptapProps extends ITiptapEditor {
|
|
||||||
forwardedRef?: React.Ref<EditorHandle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditorHandle {
|
|
||||||
clearEditor: () => void;
|
|
||||||
setEditorValue: (content: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEBOUNCE_DELAY = 1500;
|
|
||||||
|
|
||||||
const TiptapEditor = ({
|
|
||||||
onChange,
|
|
||||||
debouncedUpdatesEnabled,
|
|
||||||
editable,
|
|
||||||
setIsSubmitting,
|
|
||||||
setShouldShowAlert,
|
|
||||||
editorContentCustomClassNames,
|
|
||||||
value,
|
|
||||||
uploadFile,
|
|
||||||
extensions = [],
|
|
||||||
editorProps = {},
|
|
||||||
deleteFile,
|
|
||||||
noBorder,
|
|
||||||
borderOnFocus,
|
|
||||||
customClassName,
|
|
||||||
forwardedRef,
|
|
||||||
accessValue,
|
|
||||||
onAccessChange,
|
|
||||||
commentAccess,
|
|
||||||
}: TiptapProps) => {
|
|
||||||
const editor = useEditor({
|
|
||||||
editable: editable ?? true,
|
|
||||||
editorProps: {
|
|
||||||
...TiptapEditorProps(uploadFile, setIsSubmitting),
|
|
||||||
...editorProps,
|
|
||||||
},
|
|
||||||
extensions: [...TiptapExtensions(uploadFile, deleteFile, setIsSubmitting), ...extensions],
|
|
||||||
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
|
|
||||||
onUpdate: async ({ editor }) => {
|
|
||||||
// for instant feedback loop
|
|
||||||
setIsSubmitting?.("submitting");
|
|
||||||
setShouldShowAlert?.(true);
|
|
||||||
if (debouncedUpdatesEnabled) {
|
|
||||||
debouncedUpdates({ onChange, editor });
|
|
||||||
} else {
|
|
||||||
onChange?.(editor.getJSON(), editor.getHTML());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
|
|
||||||
editorRef.current = editor;
|
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
|
||||||
clearEditor: () => {
|
|
||||||
editorRef.current?.commands.clearContent();
|
|
||||||
},
|
|
||||||
setEditorValue: (content: string) => {
|
|
||||||
editorRef.current?.commands.setContent(content);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
|
|
||||||
if (onChange) {
|
|
||||||
onChange(editor.getJSON(), editor.getHTML());
|
|
||||||
}
|
|
||||||
}, DEBOUNCE_DELAY);
|
|
||||||
|
|
||||||
const editorClassNames = cn(
|
|
||||||
'relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md',
|
|
||||||
noBorder ? '' : 'border border-custom-border-200',
|
|
||||||
borderOnFocus ? 'focus:border border-custom-border-300' : 'focus:border-0',
|
|
||||||
customClassName
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!editor) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="tiptap-container"
|
|
||||||
onClick={() => {
|
|
||||||
editor?.chain().focus().run();
|
|
||||||
}}
|
|
||||||
className={`tiptap-editor-container cursor-text ${editorClassNames}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className={`${editorContentCustomClassNames}`}>
|
|
||||||
<EditorContent editor={editor} />
|
|
||||||
<TableMenu editor={editor} />
|
|
||||||
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
|
||||||
</div>
|
|
||||||
{editor && editable !== false &&
|
|
||||||
(<div className="w-full mt-4">
|
|
||||||
<FixedMenu editor={editor} commentAccess={commentAccess} accessValue={accessValue} onAccessChange={onAccessChange} />
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TiptapEditorWithRef = forwardRef<EditorHandle, ITiptapEditor>((props, ref) => (
|
|
||||||
<TiptapEditor {...props} forwardedRef={ref} />
|
|
||||||
));
|
|
||||||
|
|
||||||
TiptapEditorWithRef.displayName = "TiptapEditorWithRef";
|
|
||||||
|
|
||||||
export { TiptapEditor, TiptapEditorWithRef };
|
|
@ -1,17 +1,13 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useImperativeHandle, useRef, forwardRef } from "react";
|
import { Extension } from "@tiptap/react";
|
||||||
import { useEditor, EditorContent, Editor } from "@tiptap/react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
import { TableMenu } from '@/ui/menus/table-menu';
|
|
||||||
import { TiptapExtensions } from '@/ui/extensions';
|
|
||||||
import { EditorBubbleMenu } from '@/ui/menus/bubble-menu';
|
|
||||||
import { ImageResizer } from '@/ui/extensions/image/image-resize';
|
|
||||||
import { TiptapEditorProps } from '@/ui/props';
|
|
||||||
import { UploadImage } from '@/types/upload-image';
|
import { UploadImage } from '@/types/upload-image';
|
||||||
import { DeleteImage } from '@/types/delete-image';
|
import { DeleteImage } from '@/types/delete-image';
|
||||||
import { cn } from '@/lib/utils';
|
import { getEditorClassNames } from '@/lib/utils';
|
||||||
import { FixedMenu } from './menus/fixed-menu';
|
import { EditorProps } from '@tiptap/pm/view';
|
||||||
|
import { useEditor } from './hooks/useEditor';
|
||||||
|
import { EditorContainer } from '@/ui/editor-container';
|
||||||
|
import { EditorContentWrapper } from '@/ui/editor-content';
|
||||||
|
|
||||||
interface ITiptapEditor {
|
interface ITiptapEditor {
|
||||||
value: string;
|
value: string;
|
||||||
@ -34,6 +30,8 @@ interface ITiptapEditor {
|
|||||||
key: string;
|
key: string;
|
||||||
label: "Private" | "Public";
|
label: "Private" | "Public";
|
||||||
}[];
|
}[];
|
||||||
|
extensions?: Extension[];
|
||||||
|
editorProps?: EditorProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TiptapProps extends ITiptapEditor {
|
interface TiptapProps extends ITiptapEditor {
|
||||||
@ -45,8 +43,6 @@ interface EditorHandle {
|
|||||||
setEditorValue: (content: string) => void;
|
setEditorValue: (content: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEBOUNCE_DELAY = 1500;
|
|
||||||
|
|
||||||
const TiptapEditor = ({
|
const TiptapEditor = ({
|
||||||
onChange,
|
onChange,
|
||||||
debouncedUpdatesEnabled,
|
debouncedUpdatesEnabled,
|
||||||
@ -61,79 +57,33 @@ const TiptapEditor = ({
|
|||||||
borderOnFocus,
|
borderOnFocus,
|
||||||
customClassName,
|
customClassName,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
accessValue,
|
|
||||||
onAccessChange,
|
|
||||||
commentAccess,
|
|
||||||
}: TiptapProps) => {
|
}: TiptapProps) => {
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
editable: editable ?? true,
|
onChange,
|
||||||
editorProps: TiptapEditorProps(uploadFile, setIsSubmitting),
|
debouncedUpdatesEnabled,
|
||||||
extensions: TiptapExtensions(uploadFile, deleteFile, setIsSubmitting),
|
editable,
|
||||||
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
|
setIsSubmitting,
|
||||||
onUpdate: async ({ editor }) => {
|
setShouldShowAlert,
|
||||||
// for instant feedback loop
|
value,
|
||||||
setIsSubmitting?.("submitting");
|
uploadFile,
|
||||||
setShouldShowAlert?.(true);
|
deleteFile,
|
||||||
if (debouncedUpdatesEnabled) {
|
forwardedRef,
|
||||||
debouncedUpdates({ onChange, editor });
|
|
||||||
} else {
|
|
||||||
onChange?.(editor.getJSON(), editor.getHTML());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorRef: React.MutableRefObject<Editor | null> = useRef(null);
|
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||||
editorRef.current = editor;
|
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
|
||||||
clearEditor: () => {
|
|
||||||
editorRef.current?.commands.clearContent();
|
|
||||||
},
|
|
||||||
setEditorValue: (content: string) => {
|
|
||||||
editorRef.current?.commands.setContent(content);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
|
|
||||||
if (onChange) {
|
|
||||||
onChange(editor.getJSON(), editor.getHTML());
|
|
||||||
}
|
|
||||||
}, DEBOUNCE_DELAY);
|
|
||||||
|
|
||||||
const editorClassNames = cn(
|
|
||||||
'relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md',
|
|
||||||
noBorder ? '' : 'border border-custom-border-200',
|
|
||||||
borderOnFocus ? 'focus:border border-custom-border-300' : 'focus:border-0',
|
|
||||||
customClassName
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||||
id="tiptap-container"
|
|
||||||
onClick={() => {
|
|
||||||
editor?.chain().focus().run();
|
|
||||||
}}
|
|
||||||
className={`tiptap-editor-container cursor-text ${editorClassNames}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className={`${editorContentCustomClassNames}`}>
|
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||||
<EditorContent editor={editor} />
|
|
||||||
<TableMenu editor={editor} />
|
|
||||||
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
|
||||||
</div>
|
|
||||||
{editor && editable !== false &&
|
|
||||||
(<div className="w-full mt-4">
|
|
||||||
<FixedMenu editor={editor} commentAccess={commentAccess} accessValue={accessValue} onAccessChange={onAccessChange} />
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</EditorContainer >
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TiptapEditorWithRef = forwardRef<EditorHandle, ITiptapEditor>((props, ref) => (
|
const TiptapEditorWithRef = React.forwardRef<EditorHandle, ITiptapEditor>((props, ref) => (
|
||||||
<TiptapEditor {...props} forwardedRef={ref} />
|
<TiptapEditor {...props} forwardedRef={ref} />
|
||||||
));
|
));
|
||||||
|
|
||||||
|
@ -1,35 +0,0 @@
|
|||||||
import {
|
|
||||||
useEditor as useEditorCore,
|
|
||||||
} from "@tiptap/react";
|
|
||||||
import { findTableAncestor } from "@/lib/utils";
|
|
||||||
|
|
||||||
export const useEditor = (props: any) => useEditorCore({
|
|
||||||
editorProps: {
|
|
||||||
attributes: {
|
|
||||||
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
|
|
||||||
},
|
|
||||||
handleDOMEvents: {
|
|
||||||
keydown: (_view, event) => {
|
|
||||||
// prevent default event listeners from firing when slash command is active
|
|
||||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
|
|
||||||
const slashCommand = document.querySelector("#slash-command");
|
|
||||||
if (slashCommand) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
handlePaste: () => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const selection: any = window?.getSelection();
|
|
||||||
if (selection.rangeCount !== 0) {
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
if (findTableAncestor(range.startContainer)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...props,
|
|
||||||
});
|
|
@ -24,28 +24,6 @@
|
|||||||
"react": "^18.2.0"
|
"react": "^18.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blueprintjs/popover2": "^2.0.10",
|
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
|
||||||
"@tiptap/core": "^2.1.7",
|
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.0.4",
|
|
||||||
"@tiptap/extension-color": "^2.1.11",
|
|
||||||
"@tiptap/extension-highlight": "^2.1.7",
|
|
||||||
"@tiptap/extension-horizontal-rule": "^2.1.7",
|
|
||||||
"@tiptap/extension-image": "^2.1.7",
|
|
||||||
"@tiptap/extension-link": "^2.1.7",
|
|
||||||
"@tiptap/extension-placeholder": "2.0.3",
|
|
||||||
"@tiptap/extension-table": "^2.1.6",
|
|
||||||
"@tiptap/extension-table-cell": "^2.1.6",
|
|
||||||
"@tiptap/extension-table-header": "^2.1.6",
|
|
||||||
"@tiptap/extension-table-row": "^2.1.6",
|
|
||||||
"@tiptap/extension-task-item": "^2.1.7",
|
|
||||||
"@tiptap/extension-task-list": "^2.1.7",
|
|
||||||
"@tiptap/extension-text-style": "^2.1.11",
|
|
||||||
"@tiptap/extension-underline": "^2.1.7",
|
|
||||||
"@tiptap/pm": "^2.1.7",
|
|
||||||
"@tiptap/react": "^2.1.7",
|
|
||||||
"@tiptap/starter-kit": "^2.1.10",
|
|
||||||
"@tiptap/suggestion": "^2.1.7",
|
|
||||||
"@types/node": "18.15.3",
|
"@types/node": "18.15.3",
|
||||||
"@types/react": "18.0.28",
|
"@types/react": "18.0.28",
|
||||||
"@types/react-dom": "18.0.11",
|
"@types/react-dom": "18.0.11",
|
||||||
@ -64,7 +42,8 @@
|
|||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"tiptap-markdown": "^0.8.2",
|
"tiptap-markdown": "^0.8.2",
|
||||||
"use-debounce": "^9.0.4"
|
"use-debounce": "^9.0.4",
|
||||||
|
"@plane/editor-core": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.5",
|
"@types/react": "^18.2.5",
|
||||||
|
1
packages/editor/lite-text-editor/src/index.ts
Normal file
1
packages/editor/lite-text-editor/src/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { LiteTextEditor, LiteTextEditorWithRef } from "@/ui";
|
96
packages/editor/lite-text-editor/src/ui/index.tsx
Normal file
96
packages/editor/lite-text-editor/src/ui/index.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"use client"
|
||||||
|
import * as React from 'react';
|
||||||
|
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useEditor } from '@plane/editor-core';
|
||||||
|
import { FixedMenu } from './menus/fixed-menu';
|
||||||
|
|
||||||
|
export type UploadImage = (file: File) => Promise<string>;
|
||||||
|
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||||
|
|
||||||
|
interface ITiptapEditor {
|
||||||
|
value: string;
|
||||||
|
uploadFile: UploadImage;
|
||||||
|
deleteFile: DeleteImage;
|
||||||
|
noBorder?: boolean;
|
||||||
|
borderOnFocus?: boolean;
|
||||||
|
customClassName?: string;
|
||||||
|
editorContentCustomClassNames?: string;
|
||||||
|
onChange?: (json: any, html: string) => void;
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||||
|
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||||
|
editable?: boolean;
|
||||||
|
forwardedRef?: any;
|
||||||
|
debouncedUpdatesEnabled?: boolean;
|
||||||
|
commentAccessSpecifier?: {
|
||||||
|
accessValue: string,
|
||||||
|
onAccessChange: (accessKey: string) => void,
|
||||||
|
showAccessSpecifier: boolean,
|
||||||
|
commentAccess: {
|
||||||
|
icon: string;
|
||||||
|
key: string;
|
||||||
|
label: "Private" | "Public";
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TiptapProps extends ITiptapEditor {
|
||||||
|
forwardedRef?: React.Ref<EditorHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorHandle {
|
||||||
|
clearEditor: () => void;
|
||||||
|
setEditorValue: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LiteTextEditor = ({
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
editable,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
editorContentCustomClassNames,
|
||||||
|
value,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
noBorder,
|
||||||
|
borderOnFocus,
|
||||||
|
customClassName,
|
||||||
|
forwardedRef,
|
||||||
|
commentAccessSpecifier,
|
||||||
|
}: TiptapProps) => {
|
||||||
|
const editor = useEditor({
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
editable,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
value,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
forwardedRef,
|
||||||
|
});
|
||||||
|
|
||||||
|
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||||
|
{(editable !== false) &&
|
||||||
|
(<div className="w-full mt-4">
|
||||||
|
<FixedMenu editor={editor} commentAccessSpecifier={commentAccessSpecifier} />
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</EditorContainer >
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LiteTextEditorWithRef = React.forwardRef<EditorHandle, ITiptapEditor>((props, ref) => (
|
||||||
|
<LiteTextEditor {...props} forwardedRef={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef";
|
||||||
|
|
||||||
|
export { LiteTextEditor, LiteTextEditorWithRef };
|
@ -0,0 +1,13 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
iconName: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
|
||||||
|
<span className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}>
|
||||||
|
{iconName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,115 @@
|
|||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@plane/editor-core";
|
||||||
|
import { Tooltip } from "./tooltip";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
|
||||||
|
export interface BubbleMenuItem {
|
||||||
|
name: string;
|
||||||
|
isActive: () => boolean;
|
||||||
|
command: () => void;
|
||||||
|
icon: typeof BoldIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorBubbleMenuProps = {
|
||||||
|
editor: Editor;
|
||||||
|
commentAccessSpecifier?: {
|
||||||
|
accessValue: string,
|
||||||
|
onAccessChange: (accessKey: string) => void,
|
||||||
|
showAccessSpecifier: boolean,
|
||||||
|
commentAccess: {
|
||||||
|
icon: string;
|
||||||
|
key: string;
|
||||||
|
label: "Private" | "Public";
|
||||||
|
}[] | undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||||
|
const items: BubbleMenuItem[] = [
|
||||||
|
{
|
||||||
|
name: "bold",
|
||||||
|
isActive: () => props.editor?.isActive("bold"),
|
||||||
|
command: () => props.editor?.chain().focus().toggleBold().run(),
|
||||||
|
icon: BoldIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "italic",
|
||||||
|
isActive: () => props.editor?.isActive("italic"),
|
||||||
|
command: () => props.editor?.chain().focus().toggleItalic().run(),
|
||||||
|
icon: ItalicIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "underline",
|
||||||
|
isActive: () => props.editor?.isActive("underline"),
|
||||||
|
command: () => props.editor?.chain().focus().toggleUnderline().run(),
|
||||||
|
icon: UnderlineIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "strike",
|
||||||
|
isActive: () => props.editor?.isActive("strike"),
|
||||||
|
command: () => props.editor?.chain().focus().toggleStrike().run(),
|
||||||
|
icon: StrikethroughIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "code",
|
||||||
|
isActive: () => props.editor?.isActive("code"),
|
||||||
|
command: () => props.editor?.chain().focus().toggleCode().run(),
|
||||||
|
icon: CodeIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleAccessChange = (accessKey: string) => {
|
||||||
|
props.commentAccessSpecifier?.onAccessChange(accessKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
{props.commentAccessSpecifier && (<div className="flex border border-custom-border-300 divide-x divide-custom-border-300 rounded overflow-hidden">
|
||||||
|
{props?.commentAccessSpecifier.commentAccess?.map((access) => (
|
||||||
|
<Tooltip key={access.key} tooltipContent={access.label}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAccessChange(access.key)}
|
||||||
|
className={`grid place-items-center p-1 hover:bg-custom-background-80 ${props.commentAccessSpecifier?.accessValue === access.key ? "bg-custom-background-80" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
iconName={access.icon}
|
||||||
|
className={`w-4 h-4 -mt-1 ${props.accessValue === access.key
|
||||||
|
? "!text-custom-text-100"
|
||||||
|
: "!text-custom-text-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>)}
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={item.command}
|
||||||
|
className={cn(
|
||||||
|
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||||
|
{
|
||||||
|
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("h-4 w-4", {
|
||||||
|
"text-custom-text-100": item.isActive(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,77 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
// next-themes
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
// tooltip2
|
||||||
|
import { Tooltip2 } from "@blueprintjs/popover2";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tooltipHeading?: string;
|
||||||
|
tooltipContent: string | React.ReactNode;
|
||||||
|
position?:
|
||||||
|
| "top"
|
||||||
|
| "right"
|
||||||
|
| "bottom"
|
||||||
|
| "left"
|
||||||
|
| "auto"
|
||||||
|
| "auto-end"
|
||||||
|
| "auto-start"
|
||||||
|
| "bottom-left"
|
||||||
|
| "bottom-right"
|
||||||
|
| "left-bottom"
|
||||||
|
| "left-top"
|
||||||
|
| "right-bottom"
|
||||||
|
| "right-top"
|
||||||
|
| "top-left"
|
||||||
|
| "top-right";
|
||||||
|
children: JSX.Element;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
openDelay?: number;
|
||||||
|
closeDelay?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tooltip: React.FC<Props> = ({
|
||||||
|
tooltipHeading,
|
||||||
|
tooltipContent,
|
||||||
|
position = "top",
|
||||||
|
children,
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
openDelay = 200,
|
||||||
|
closeDelay,
|
||||||
|
}) => {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip2
|
||||||
|
disabled={disabled}
|
||||||
|
hoverOpenDelay={openDelay}
|
||||||
|
hoverCloseDelay={closeDelay}
|
||||||
|
content={
|
||||||
|
<div
|
||||||
|
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md ${
|
||||||
|
theme === "custom"
|
||||||
|
? "bg-custom-background-100 text-custom-text-200"
|
||||||
|
: "bg-black text-gray-400"
|
||||||
|
} break-words overflow-hidden ${className}`}
|
||||||
|
>
|
||||||
|
{tooltipHeading && (
|
||||||
|
<h5
|
||||||
|
className={`font-medium ${
|
||||||
|
theme === "custom" ? "text-custom-text-100" : "text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tooltipHeading}
|
||||||
|
</h5>
|
||||||
|
)}
|
||||||
|
{tooltipContent}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
position={position}
|
||||||
|
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
|
||||||
|
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -24,49 +24,18 @@
|
|||||||
"react": "^18.2.0"
|
"react": "^18.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blueprintjs/popover2": "^2.0.10",
|
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
|
||||||
"@tiptap/core": "^2.1.7",
|
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.0.4",
|
|
||||||
"@tiptap/extension-color": "^2.1.11",
|
|
||||||
"@tiptap/extension-highlight": "^2.1.7",
|
|
||||||
"@tiptap/extension-horizontal-rule": "^2.1.7",
|
|
||||||
"@tiptap/extension-image": "^2.1.7",
|
|
||||||
"@tiptap/extension-link": "^2.1.7",
|
|
||||||
"@tiptap/extension-placeholder": "2.0.3",
|
|
||||||
"@tiptap/extension-table": "^2.1.6",
|
|
||||||
"@tiptap/extension-table-cell": "^2.1.6",
|
|
||||||
"@tiptap/extension-table-header": "^2.1.6",
|
|
||||||
"@tiptap/extension-table-row": "^2.1.6",
|
|
||||||
"@tiptap/extension-task-item": "^2.1.7",
|
|
||||||
"@tiptap/extension-task-list": "^2.1.7",
|
|
||||||
"@tiptap/extension-text-style": "^2.1.11",
|
|
||||||
"@tiptap/extension-underline": "^2.1.7",
|
|
||||||
"@tiptap/pm": "^2.1.7",
|
|
||||||
"@tiptap/react": "^2.1.7",
|
|
||||||
"@tiptap/starter-kit": "^2.1.10",
|
|
||||||
"@tiptap/suggestion": "^2.1.7",
|
|
||||||
"@types/node": "18.15.3",
|
|
||||||
"@types/react": "18.0.28",
|
|
||||||
"@types/react-dom": "18.0.11",
|
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"eslint": "8.36.0",
|
|
||||||
"eslint-config-next": "13.2.4",
|
|
||||||
"eventsource-parser": "^0.1.0",
|
|
||||||
"lowlight": "^2.9.0",
|
"lowlight": "^2.9.0",
|
||||||
"lucide-react": "^0.244.0",
|
"lucide-react": "^0.244.0",
|
||||||
"next": "12.3.2",
|
"next": "12.3.2",
|
||||||
"next-themes": "^0.2.1",
|
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-markdown": "^8.0.7",
|
"@plane/editor-core": "*"
|
||||||
"tailwind-merge": "^1.14.0",
|
|
||||||
"tippy.js": "^6.3.7",
|
|
||||||
"tiptap-markdown": "^0.8.2",
|
|
||||||
"use-debounce": "^9.0.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "18.15.3",
|
||||||
|
"@types/react-dom": "18.0.11",
|
||||||
"@types/react": "^18.2.5",
|
"@types/react": "^18.2.5",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^7.32.0",
|
||||||
"postcss": "^8.4.29",
|
"postcss": "^8.4.29",
|
||||||
|
@ -1,66 +0,0 @@
|
|||||||
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
|
||||||
import { TableRow } from "@tiptap/extension-table-row";
|
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
|
||||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
|
||||||
import { InputRule } from "@tiptap/core";
|
|
||||||
|
|
||||||
import ts from "highlight.js/lib/languages/typescript";
|
|
||||||
import { lowlight } from "lowlight/lib/core";
|
|
||||||
import "highlight.js/styles/github-dark.css";
|
|
||||||
import { Table } from "./table/table";
|
|
||||||
import { TableHeader } from "./table/table-header";
|
|
||||||
import { CustomTableCell } from "./table/table-cell";
|
|
||||||
import SlashCommand from "./slash-command";
|
|
||||||
import { UploadImage } from "./types/upload-image";
|
|
||||||
|
|
||||||
lowlight.registerLanguage("ts", ts);
|
|
||||||
|
|
||||||
export const TiptapExtensions = (
|
|
||||||
uploadFile: UploadImage,
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
|
||||||
) => [
|
|
||||||
CodeBlockLowlight.configure({
|
|
||||||
lowlight,
|
|
||||||
}),
|
|
||||||
HorizontalRule.extend({
|
|
||||||
addInputRules() {
|
|
||||||
return [
|
|
||||||
new InputRule({
|
|
||||||
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
|
||||||
handler: ({ state, range, commands }) => {
|
|
||||||
commands.splitBlock();
|
|
||||||
|
|
||||||
const attributes = {};
|
|
||||||
const { tr } = state;
|
|
||||||
const start = range.from;
|
|
||||||
const end = range.to;
|
|
||||||
// @ts-ignore
|
|
||||||
tr.replaceWith(start - 1, end, this.type.create(attributes));
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
}).configure({
|
|
||||||
HTMLAttributes: {
|
|
||||||
class: "mb-6 border-t border-custom-border-300",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
Placeholder.configure({
|
|
||||||
placeholder: ({ node }) => {
|
|
||||||
if (node.type.name === "heading") {
|
|
||||||
return `Heading ${node.attrs.level}`;
|
|
||||||
}
|
|
||||||
if (node.type.name === "image" || node.type.name === "table") {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "Press '/' for commands...";
|
|
||||||
},
|
|
||||||
includeChildren: true,
|
|
||||||
}),
|
|
||||||
SlashCommand(uploadFile, setIsSubmitting),
|
|
||||||
Table,
|
|
||||||
TableHeader,
|
|
||||||
CustomTableCell,
|
|
||||||
TableRow,
|
|
||||||
];
|
|
1
packages/editor/rich-text-editor/src/index.ts
Normal file
1
packages/editor/rich-text-editor/src/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { RichTextEditor, RichTextEditorWithRef } from "@/ui";
|
@ -1,363 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
|
||||||
import { Editor, Range, Extension } from "@tiptap/core";
|
|
||||||
import Suggestion from "@tiptap/suggestion";
|
|
||||||
import { ReactRenderer } from "@tiptap/react";
|
|
||||||
import tippy from "tippy.js";
|
|
||||||
import {
|
|
||||||
Heading1,
|
|
||||||
Heading2,
|
|
||||||
Heading3,
|
|
||||||
List,
|
|
||||||
ListOrdered,
|
|
||||||
Text,
|
|
||||||
TextQuote,
|
|
||||||
Code,
|
|
||||||
MinusSquare,
|
|
||||||
CheckSquare,
|
|
||||||
ImageIcon,
|
|
||||||
Table,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { startImageUpload } from "@/ui/plugins/upload-image";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { UploadImage } from "@/types/upload-image";
|
|
||||||
|
|
||||||
interface CommandItemProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CommandProps {
|
|
||||||
editor: Editor;
|
|
||||||
range: Range;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Command = Extension.create({
|
|
||||||
name: "slash-command",
|
|
||||||
addOptions() {
|
|
||||||
return {
|
|
||||||
suggestion: {
|
|
||||||
char: "/",
|
|
||||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
|
||||||
props.command({ editor, range });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
Suggestion({
|
|
||||||
editor: this.editor,
|
|
||||||
allow({ editor }) {
|
|
||||||
return !editor.isActive("table");
|
|
||||||
},
|
|
||||||
...this.options.suggestion,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const getSuggestionItems =
|
|
||||||
(
|
|
||||||
uploadFile: UploadImage,
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
|
||||||
) =>
|
|
||||||
({ query }: { query: string }) =>
|
|
||||||
[
|
|
||||||
{
|
|
||||||
title: "Text",
|
|
||||||
description: "Just start typing with plain text.",
|
|
||||||
searchTerms: ["p", "paragraph"],
|
|
||||||
icon: <Text size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Heading 1",
|
|
||||||
description: "Big section heading.",
|
|
||||||
searchTerms: ["title", "big", "large"],
|
|
||||||
icon: <Heading1 size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Heading 2",
|
|
||||||
description: "Medium section heading.",
|
|
||||||
searchTerms: ["subtitle", "medium"],
|
|
||||||
icon: <Heading2 size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Heading 3",
|
|
||||||
description: "Small section heading.",
|
|
||||||
searchTerms: ["subtitle", "small"],
|
|
||||||
icon: <Heading3 size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "To-do List",
|
|
||||||
description: "Track tasks with a to-do list.",
|
|
||||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
|
||||||
icon: <CheckSquare size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Bullet List",
|
|
||||||
description: "Create a simple bullet list.",
|
|
||||||
searchTerms: ["unordered", "point"],
|
|
||||||
icon: <List size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
// @ts-ignore
|
|
||||||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Divider",
|
|
||||||
description: "Visually divide blocks",
|
|
||||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
|
||||||
icon: <MinusSquare size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Table",
|
|
||||||
description: "Create a Table",
|
|
||||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
|
||||||
icon: <Table size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor
|
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
|
||||||
.run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Numbered List",
|
|
||||||
description: "Create a list with numbering.",
|
|
||||||
searchTerms: ["ordered"],
|
|
||||||
icon: <ListOrdered size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
// @ts-ignore
|
|
||||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Quote",
|
|
||||||
description: "Capture a quote.",
|
|
||||||
searchTerms: ["blockquote"],
|
|
||||||
icon: <TextQuote size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
// @ts-ignore
|
|
||||||
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Code",
|
|
||||||
description: "Capture a code snippet.",
|
|
||||||
searchTerms: ["codeblock"],
|
|
||||||
icon: <Code size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) =>
|
|
||||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Image",
|
|
||||||
description: "Upload an image from your computer.",
|
|
||||||
searchTerms: ["photo", "picture", "media"],
|
|
||||||
icon: <ImageIcon size={18} />,
|
|
||||||
command: ({ editor, range }: CommandProps) => {
|
|
||||||
editor.chain().focus().deleteRange(range).run();
|
|
||||||
// upload image
|
|
||||||
const input = document.createElement("input");
|
|
||||||
input.type = "file";
|
|
||||||
input.accept = "image/*";
|
|
||||||
input.onchange = async () => {
|
|
||||||
if (input.files?.length) {
|
|
||||||
const file = input.files[0];
|
|
||||||
const pos = editor.view.state.selection.from;
|
|
||||||
startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.click();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
].filter((item) => {
|
|
||||||
if (typeof query === "string" && query.length > 0) {
|
|
||||||
const search = query.toLowerCase();
|
|
||||||
return (
|
|
||||||
item.title.toLowerCase().includes(search) ||
|
|
||||||
item.description.toLowerCase().includes(search) ||
|
|
||||||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
|
||||||
const containerHeight = container.offsetHeight;
|
|
||||||
const itemHeight = item ? item.offsetHeight : 0;
|
|
||||||
|
|
||||||
const top = item.offsetTop;
|
|
||||||
const bottom = top + itemHeight;
|
|
||||||
|
|
||||||
if (top < container.scrollTop) {
|
|
||||||
container.scrollTop -= container.scrollTop - top + 5;
|
|
||||||
} else if (bottom > containerHeight + container.scrollTop) {
|
|
||||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const CommandList = ({
|
|
||||||
items,
|
|
||||||
command,
|
|
||||||
}: {
|
|
||||||
items: CommandItemProps[];
|
|
||||||
command: any;
|
|
||||||
editor: any;
|
|
||||||
range: any;
|
|
||||||
}) => {
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
||||||
|
|
||||||
const selectItem = useCallback(
|
|
||||||
(index: number) => {
|
|
||||||
const item = items[index];
|
|
||||||
if (item) {
|
|
||||||
command(item);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[command, items]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if (navigationKeys.includes(e.key)) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (e.key === "ArrowUp") {
|
|
||||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
selectItem(selectedIndex);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("keydown", onKeyDown);
|
|
||||||
};
|
|
||||||
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedIndex(0);
|
|
||||||
}, [items]);
|
|
||||||
|
|
||||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const container = commandListContainer?.current;
|
|
||||||
|
|
||||||
const item = container?.children[selectedIndex] as HTMLElement;
|
|
||||||
|
|
||||||
if (item && container) updateScrollView(container, item);
|
|
||||||
}, [selectedIndex]);
|
|
||||||
|
|
||||||
return items.length > 0 ? (
|
|
||||||
<div
|
|
||||||
id="slash-command"
|
|
||||||
ref={commandListContainer}
|
|
||||||
className="z-50 fixed h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-custom-border-300 bg-custom-background-100 px-1 py-2 shadow-md transition-all"
|
|
||||||
>
|
|
||||||
{items.map((item: CommandItemProps, index: number) => (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100`,
|
|
||||||
{ "bg-custom-primary-100/5 text-custom-text-100": index === selectedIndex }
|
|
||||||
)}
|
|
||||||
key={index}
|
|
||||||
onClick={() => selectItem(index)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{item.title}</p>
|
|
||||||
<p className="text-xs text-custom-text-300">{item.description}</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderItems = () => {
|
|
||||||
let component: ReactRenderer | null = null;
|
|
||||||
let popup: any | null = null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
|
||||||
component = new ReactRenderer(CommandList, {
|
|
||||||
props,
|
|
||||||
editor: props.editor,
|
|
||||||
});
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
popup = tippy("body", {
|
|
||||||
getReferenceClientRect: props.clientRect,
|
|
||||||
appendTo: () => document.querySelector("#tiptap-container"),
|
|
||||||
content: component.element,
|
|
||||||
showOnCreate: true,
|
|
||||||
interactive: true,
|
|
||||||
trigger: "manual",
|
|
||||||
placement: "bottom-start",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
|
||||||
component?.updateProps(props);
|
|
||||||
|
|
||||||
popup &&
|
|
||||||
popup[0].setProps({
|
|
||||||
getReferenceClientRect: props.clientRect,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
|
||||||
if (props.event.key === "Escape") {
|
|
||||||
popup?.[0].hide();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// @ts-ignore
|
|
||||||
return component?.ref?.onKeyDown(props);
|
|
||||||
},
|
|
||||||
onExit: () => {
|
|
||||||
popup?.[0].destroy();
|
|
||||||
component?.destroy();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SlashCommand = (
|
|
||||||
uploadFile: UploadImage,
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
|
||||||
) =>
|
|
||||||
Command.configure({
|
|
||||||
suggestion: {
|
|
||||||
items: getSuggestionItems(uploadFile, setIsSubmitting),
|
|
||||||
render: renderItems,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SlashCommand;
|
|
@ -1,32 +0,0 @@
|
|||||||
import { TableCell } from "@tiptap/extension-table-cell";
|
|
||||||
|
|
||||||
export const CustomTableCell = TableCell.extend({
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(),
|
|
||||||
isHeader: {
|
|
||||||
default: false,
|
|
||||||
parseHTML: (element) => {
|
|
||||||
isHeader: element.tagName === "TD";
|
|
||||||
},
|
|
||||||
renderHTML: (attributes) => {
|
|
||||||
tag: attributes.isHeader ? "th" : "td";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
|
||||||
if (HTMLAttributes.isHeader) {
|
|
||||||
return [
|
|
||||||
"th",
|
|
||||||
{
|
|
||||||
...HTMLAttributes,
|
|
||||||
class: `relative ${HTMLAttributes.class}`,
|
|
||||||
},
|
|
||||||
["span", { class: "absolute top-0 right-0" }],
|
|
||||||
0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return ["td", HTMLAttributes, 0];
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,7 +0,0 @@
|
|||||||
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
|
|
||||||
|
|
||||||
const TableHeader = BaseTableHeader.extend({
|
|
||||||
content: "paragraph",
|
|
||||||
});
|
|
||||||
|
|
||||||
export { TableHeader };
|
|
@ -1,9 +0,0 @@
|
|||||||
import { Table as BaseTable } from "@tiptap/extension-table";
|
|
||||||
|
|
||||||
const Table = BaseTable.configure({
|
|
||||||
resizable: true,
|
|
||||||
cellMinWidth: 100,
|
|
||||||
allowTableNodeSelection: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
export { Table };
|
|
@ -1 +0,0 @@
|
|||||||
export type UploadImage = (file: File) => Promise<string>;
|
|
20
packages/editor/rich-text-editor/src/ui/extensions/index.tsx
Normal file
20
packages/editor/rich-text-editor/src/ui/extensions/index.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||||
|
import { lowlight } from "lowlight/lib/core";
|
||||||
|
import { InputRule } from "@tiptap/core";
|
||||||
|
|
||||||
|
import ts from "highlight.js/lib/languages/typescript";
|
||||||
|
|
||||||
|
import "highlight.js/styles/github-dark.css";
|
||||||
|
import SlashCommand from "./slash-command";
|
||||||
|
import { UploadImage } from "..";
|
||||||
|
|
||||||
|
lowlight.registerLanguage("ts", ts);
|
||||||
|
|
||||||
|
export const RichTextEditorExtensions = (
|
||||||
|
uploadFile: UploadImage,
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||||
|
) => [
|
||||||
|
SlashCommand(uploadFile, setIsSubmitting),
|
||||||
|
];
|
@ -17,9 +17,8 @@ import {
|
|||||||
ImageIcon,
|
ImageIcon,
|
||||||
Table,
|
Table,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { startImageUpload } from "@/ui/plugins/upload-image";
|
import { UploadImage } from "..";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, startImageUpload } from "@plane/editor-core";
|
||||||
import { UploadImage } from "@/types/upload-image";
|
|
||||||
|
|
||||||
interface CommandItemProps {
|
interface CommandItemProps {
|
||||||
title: string;
|
title: string;
|
83
packages/editor/rich-text-editor/src/ui/index.tsx
Normal file
83
packages/editor/rich-text-editor/src/ui/index.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"use client"
|
||||||
|
import * as React from 'react';
|
||||||
|
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useEditor } from '@plane/editor-core';
|
||||||
|
import { EditorBubbleMenu } from './menus/bubble-menu';
|
||||||
|
import { RichTextEditorExtensions } from './extensions';
|
||||||
|
|
||||||
|
export type UploadImage = (file: File) => Promise<string>;
|
||||||
|
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||||
|
|
||||||
|
interface ITiptapEditor {
|
||||||
|
value: string;
|
||||||
|
uploadFile: UploadImage;
|
||||||
|
deleteFile: DeleteImage;
|
||||||
|
noBorder?: boolean;
|
||||||
|
borderOnFocus?: boolean;
|
||||||
|
customClassName?: string;
|
||||||
|
editorContentCustomClassNames?: string;
|
||||||
|
onChange?: (json: any, html: string) => void;
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||||
|
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||||
|
editable?: boolean;
|
||||||
|
forwardedRef?: any;
|
||||||
|
debouncedUpdatesEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TiptapProps extends ITiptapEditor {
|
||||||
|
forwardedRef?: React.Ref<EditorHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorHandle {
|
||||||
|
clearEditor: () => void;
|
||||||
|
setEditorValue: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RichTextEditor = ({
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
editable,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
editorContentCustomClassNames,
|
||||||
|
value,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
noBorder,
|
||||||
|
borderOnFocus,
|
||||||
|
customClassName,
|
||||||
|
forwardedRef,
|
||||||
|
}: TiptapProps) => {
|
||||||
|
const editor = useEditor({
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
editable,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
value,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
forwardedRef,
|
||||||
|
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting)
|
||||||
|
});
|
||||||
|
|
||||||
|
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||||
|
{editor && <EditorBubbleMenu editor={editor} />}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||||
|
</div>
|
||||||
|
</EditorContainer >
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RichTextEditorWithRef = React.forwardRef<EditorHandle, ITiptapEditor>((props, ref) => (
|
||||||
|
<RichTextEditor {...props} forwardedRef={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
|
||||||
|
|
||||||
|
export { RichTextEditor, RichTextEditorWithRef};
|
@ -0,0 +1,121 @@
|
|||||||
|
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
|
||||||
|
import { FC, useState } from "react";
|
||||||
|
import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { NodeSelector } from "./node-selector";
|
||||||
|
import { LinkSelector } from "./link-selector";
|
||||||
|
import { cn } from "@plane/editor-core";
|
||||||
|
|
||||||
|
export interface BubbleMenuItem {
|
||||||
|
name: string;
|
||||||
|
isActive: () => boolean;
|
||||||
|
command: () => void;
|
||||||
|
icon: typeof BoldIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||||
|
|
||||||
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||||
|
const items: BubbleMenuItem[] = [
|
||||||
|
{
|
||||||
|
name: "bold",
|
||||||
|
isActive: () => props.editor?.isActive("bold"),
|
||||||
|
command: () => props.editor?.chain().focus().toggleBold().run(),
|
||||||
|
icon: BoldIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "italic",
|
||||||
|
isActive: () => props.editor?.isActive("italic"),
|
||||||
|
command: () => props.editor?.chain().focus().toggleItalic().run(),
|
||||||
|
icon: ItalicIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "underline",
|
||||||
|
isActive: () => props.editor?.isActive("underline"),
|
||||||
|
command: () => props.editor?.chain().focus().toggleUnderline().run(),
|
||||||
|
icon: UnderlineIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "strike",
|
||||||
|
isActive: () => props.editor?.isActive("strike"),
|
||||||
|
command: () => props.editor?.chain().focus().toggleStrike().run(),
|
||||||
|
icon: StrikethroughIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "code",
|
||||||
|
isActive: () => props.editor?.isActive("code"),
|
||||||
|
command: () => props.editor?.chain().focus().toggleCode().run(),
|
||||||
|
icon: CodeIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||||
|
...props,
|
||||||
|
shouldShow: ({ editor }) => {
|
||||||
|
if (!editor.isEditable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (editor.isActive("image")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return editor.view.state.selection.content().size > 0;
|
||||||
|
},
|
||||||
|
tippyOptions: {
|
||||||
|
moveTransition: "transform 0.15s ease-out",
|
||||||
|
onHidden: () => {
|
||||||
|
setIsNodeSelectorOpen(false);
|
||||||
|
setIsLinkSelectorOpen(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||||
|
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BubbleMenu
|
||||||
|
{...bubbleMenuProps}
|
||||||
|
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
||||||
|
>
|
||||||
|
{!props.editor.isActive("table") && (
|
||||||
|
<NodeSelector
|
||||||
|
editor={props.editor!}
|
||||||
|
isOpen={isNodeSelectorOpen}
|
||||||
|
setIsOpen={() => {
|
||||||
|
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||||
|
setIsLinkSelectorOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<LinkSelector
|
||||||
|
editor={props.editor!!}
|
||||||
|
isOpen={isLinkSelectorOpen}
|
||||||
|
setIsOpen={() => {
|
||||||
|
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||||
|
setIsNodeSelectorOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={item.command}
|
||||||
|
className={cn(
|
||||||
|
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||||
|
{
|
||||||
|
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn("h-4 w-4", {
|
||||||
|
"text-custom-text-100": item.isActive(),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</BubbleMenu>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,93 @@
|
|||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { Check, Trash } from "lucide-react";
|
||||||
|
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
||||||
|
import isValidHttpUrl from "@/ui/menus/bubble-menu/utils";
|
||||||
|
import { cn } from "@plane/editor-core";
|
||||||
|
|
||||||
|
interface LinkSelectorProps {
|
||||||
|
editor: Editor;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const onLinkSubmit = useCallback(() => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
const url = input?.value;
|
||||||
|
if (url && isValidHttpUrl(url)) {
|
||||||
|
editor.chain().focus().setLink({ href: url }).run();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}, [editor, inputRef, setIsOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current && inputRef.current?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
|
||||||
|
{ "bg-custom-background-100": isOpen }
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-base">↗</p>
|
||||||
|
<p
|
||||||
|
className={cn("underline underline-offset-4", {
|
||||||
|
"text-custom-text-100": editor.isActive("link"),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Link
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
onLinkSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="url"
|
||||||
|
placeholder="Paste a link"
|
||||||
|
className="flex-1 bg-custom-background-100 border-r border-custom-border-300 p-1 text-sm outline-none placeholder:text-custom-text-400"
|
||||||
|
defaultValue={editor.getAttributes("link").href || ""}
|
||||||
|
/>
|
||||||
|
{editor.getAttributes("link").href ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
||||||
|
onClick={() => {
|
||||||
|
editor.chain().focus().unsetLink().run();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onLinkSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,130 @@
|
|||||||
|
import { cn } from "@plane/editor-core";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Heading1,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
TextQuote,
|
||||||
|
ListOrdered,
|
||||||
|
TextIcon,
|
||||||
|
Code,
|
||||||
|
CheckSquare,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Dispatch, FC, SetStateAction } from "react";
|
||||||
|
|
||||||
|
import { BubbleMenuItem } from ".";
|
||||||
|
|
||||||
|
interface NodeSelectorProps {
|
||||||
|
editor: Editor;
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
|
||||||
|
const items: BubbleMenuItem[] = [
|
||||||
|
{
|
||||||
|
name: "Text",
|
||||||
|
icon: TextIcon,
|
||||||
|
command: () => editor.chain().focus().toggleNode("paragraph", "paragraph").run(),
|
||||||
|
isActive: () =>
|
||||||
|
editor.isActive("paragraph") &&
|
||||||
|
!editor.isActive("bulletList") &&
|
||||||
|
!editor.isActive("orderedList"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "H1",
|
||||||
|
icon: Heading1,
|
||||||
|
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||||
|
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "H2",
|
||||||
|
icon: Heading2,
|
||||||
|
command: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||||
|
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "H3",
|
||||||
|
icon: Heading3,
|
||||||
|
command: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||||
|
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "To-do List",
|
||||||
|
icon: CheckSquare,
|
||||||
|
command: () => editor.chain().focus().toggleTaskList().run(),
|
||||||
|
isActive: () => editor.isActive("taskItem"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bullet List",
|
||||||
|
icon: ListOrdered,
|
||||||
|
command: () => editor.chain().focus().toggleBulletList().run(),
|
||||||
|
isActive: () => editor.isActive("bulletList"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Numbered List",
|
||||||
|
icon: ListOrdered,
|
||||||
|
command: () => editor.chain().focus().toggleOrderedList().run(),
|
||||||
|
isActive: () => editor.isActive("orderedList"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Quote",
|
||||||
|
icon: TextQuote,
|
||||||
|
command: () =>
|
||||||
|
editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run(),
|
||||||
|
isActive: () => editor.isActive("blockquote"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Code",
|
||||||
|
icon: Code,
|
||||||
|
command: () => editor.chain().focus().toggleCodeBlock().run(),
|
||||||
|
isActive: () => editor.isActive("codeBlock"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||||
|
name: "Multiple",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
|
||||||
|
>
|
||||||
|
<span>{activeItem?.name}</span>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 p-1 shadow-xl animate-in fade-in slide-in-from-top-1">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
item.command();
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between rounded-sm px-2 py-1 text-sm text-custom-text-200 hover:bg-custom-primary-100/5 hover:text-custom-text-100",
|
||||||
|
{ "bg-custom-primary-100/5 text-custom-text-100": activeItem.name === item.name }
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="rounded-sm border border-custom-border-300 p-1">
|
||||||
|
<item.icon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
{activeItem.name === item.name && <Check className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,11 @@
|
|||||||
|
export default function isValidHttpUrl(string: string): boolean {
|
||||||
|
let url: URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
url = new URL(string);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
|
}
|
@ -11,7 +11,7 @@ import { SecondaryButton } from "components/ui";
|
|||||||
// types
|
// types
|
||||||
import { Comment } from "types/issue";
|
import { Comment } from "types/issue";
|
||||||
// components
|
// components
|
||||||
import { TiptapEditorWithRef } from "@plane/editor";
|
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||||
// service
|
// service
|
||||||
import fileService from "@/services/file.service";
|
import fileService from "@/services/file.service";
|
||||||
|
|
||||||
@ -71,10 +71,9 @@ export const AddComment: React.FC<Props> = observer((props) => {
|
|||||||
name="comment_html"
|
name="comment_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<TiptapEditorWithRef
|
<RichTextEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspace_slug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspace_slug as string}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={
|
value={
|
||||||
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
|
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
|
||||||
|
@ -9,7 +9,7 @@ import { Menu, Transition } from "@headlessui/react";
|
|||||||
// lib
|
// lib
|
||||||
import { useMobxStore } from "lib/mobx/store-provider";
|
import { useMobxStore } from "lib/mobx/store-provider";
|
||||||
// components
|
// components
|
||||||
import { TiptapEditorWithRef } from "@plane/editor";
|
import { RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||||
|
|
||||||
import { CommentReactions } from "components/issues/peek-overview";
|
import { CommentReactions } from "components/issues/peek-overview";
|
||||||
// icons
|
// icons
|
||||||
@ -103,10 +103,9 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
|||||||
control={control}
|
control={control}
|
||||||
name="comment_html"
|
name="comment_html"
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange, value } }) => (
|
||||||
<TiptapEditorWithRef
|
<RichTextEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={value}
|
value={value}
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
@ -136,10 +135,9 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div className={`${isEditing ? "hidden" : ""}`}>
|
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||||
<TiptapEditorWithRef
|
<RichTextEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={showEditorRef}
|
ref={showEditorRef}
|
||||||
value={comment.comment_html}
|
value={comment.comment_html}
|
||||||
editable={false}
|
editable={false}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { IssueReactions } from "components/issues/peek-overview";
|
import { IssueReactions } from "components/issues/peek-overview";
|
||||||
import { TiptapEditor } from "@plane/editor";
|
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types/issue";
|
import { IIssue } from "types/issue";
|
||||||
@ -21,10 +21,9 @@ export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => {
|
|||||||
</h6>
|
</h6>
|
||||||
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
|
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
|
||||||
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
|
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
|
||||||
<TiptapEditor
|
<RichTextEditor
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspace_slug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspace_slug as string}
|
|
||||||
value={
|
value={
|
||||||
!issueDetails.description_html ||
|
!issueDetails.description_html ||
|
||||||
issueDetails.description_html === "" ||
|
issueDetails.description_html === "" ||
|
||||||
|
@ -35,7 +35,7 @@
|
|||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
"@plane/editor": "*"
|
"@plane/rich-text-editor": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/js-cookie": "^3.0.3",
|
"@types/js-cookie": "^3.0.3",
|
||||||
|
@ -33,13 +33,29 @@ class FileService extends APIService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {
|
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {
|
||||||
return this.mediaUpload(`/api/workspaces/${workspaceSlug}/file-assets/`, file)
|
return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, {
|
||||||
|
headers: {
|
||||||
|
...this.getHeaders(),
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
})
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getUploadFileFunction(workspaceSlug: string): (file: File) => Promise<string> {
|
||||||
|
return async (file: File) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("asset", file);
|
||||||
|
formData.append("attributes", JSON.stringify({}));
|
||||||
|
|
||||||
|
const data = await this.uploadFile(workspaceSlug, formData);
|
||||||
|
return data.asset;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async deleteImage(assetUrlWithWorkspaceId: string): Promise<any> {
|
async deleteImage(assetUrlWithWorkspaceId: string): Promise<any> {
|
||||||
return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`)
|
return this.delete(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/`)
|
||||||
.then((response) => response?.status)
|
.then((response) => response?.status)
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
"cache": false,
|
"cache": false,
|
||||||
"persistent": true,
|
"persistent": true,
|
||||||
"dependsOn": [
|
"dependsOn": [
|
||||||
"@plane/editor#build"
|
"@plane/editor-core#build"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
|
@ -11,7 +11,7 @@ import useUserAuth from "hooks/use-user-auth";
|
|||||||
// ui
|
// ui
|
||||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
// components
|
// components
|
||||||
import { TiptapEditor, TiptapEditorWithRef } from "@plane/editor";
|
import { RichTextEditor, RichTextEditorWithRef } from "@plane/rich-text-editor";
|
||||||
// types
|
// types
|
||||||
import { IIssue, IPageBlock } from "types";
|
import { IIssue, IPageBlock } from "types";
|
||||||
// services
|
// services
|
||||||
@ -143,10 +143,9 @@ export const GptAssistantModal: React.FC<Props> = ({
|
|||||||
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
|
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
Content:
|
Content:
|
||||||
<TiptapEditorWithRef
|
<RichTextEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
value={htmlContent ?? `<p>${content}</p>`}
|
value={htmlContent ?? `<p>${content}</p>`}
|
||||||
customClassName="-m-3"
|
customClassName="-m-3"
|
||||||
noBorder
|
noBorder
|
||||||
@ -159,10 +158,9 @@ export const GptAssistantModal: React.FC<Props> = ({
|
|||||||
{response !== "" && (
|
{response !== "" && (
|
||||||
<div className="page-block-section text-sm">
|
<div className="page-block-section text-sm">
|
||||||
Response:
|
Response:
|
||||||
<TiptapEditor
|
<RichTextEditor
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
value={`<p>${response}</p>`}
|
value={`<p>${response}</p>`}
|
||||||
customClassName="-mx-3 -my-3"
|
customClassName="-mx-3 -my-3"
|
||||||
noBorder
|
noBorder
|
||||||
|
@ -3,9 +3,9 @@ import { useRouter } from "next/router";
|
|||||||
// react-hook-form
|
// react-hook-form
|
||||||
import { useForm, Controller } from "react-hook-form";
|
import { useForm, Controller } from "react-hook-form";
|
||||||
// components
|
// components
|
||||||
import { TiptapEditorWithRef } from "@plane/editor";
|
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
||||||
// ui
|
// ui
|
||||||
import { Icon, SecondaryButton, Tooltip } from "components/ui";
|
import { SecondaryButton } from "components/ui";
|
||||||
// types
|
// types
|
||||||
import type { IIssueComment } from "types";
|
import type { IIssueComment } from "types";
|
||||||
// services
|
// services
|
||||||
@ -74,23 +74,20 @@ export const AddComment: React.FC<Props> = ({
|
|||||||
<Controller
|
<Controller
|
||||||
name="access"
|
name="access"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { onChange, value } }) => (
|
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
|
||||||
<Controller
|
<Controller
|
||||||
name="comment_html"
|
name="comment_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
|
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
|
||||||
<TiptapEditorWithRef
|
<LiteTextEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
|
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
|
||||||
customClassName="p-3 min-h-[100px] shadow-sm"
|
customClassName="p-3 min-h-[100px] shadow-sm"
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
||||||
accessValue={value}
|
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
||||||
onAccessChange={onChange}
|
|
||||||
commentAccess={commentAccess}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -9,7 +9,7 @@ import useUser from "hooks/use-user";
|
|||||||
// ui
|
// ui
|
||||||
import { CustomMenu, Icon } from "components/ui";
|
import { CustomMenu, Icon } from "components/ui";
|
||||||
import { CommentReaction } from "components/issues";
|
import { CommentReaction } from "components/issues";
|
||||||
import { TiptapEditorWithRef } from "@plane/editor";
|
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { timeAgo } from "helpers/date-time.helper";
|
import { timeAgo } from "helpers/date-time.helper";
|
||||||
// types
|
// types
|
||||||
@ -112,10 +112,9 @@ export const CommentCard: React.FC<Props> = ({
|
|||||||
onSubmit={handleSubmit(onEnter)}
|
onSubmit={handleSubmit(onEnter)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<TiptapEditorWithRef
|
<LiteTextEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={watch("comment_html")}
|
value={watch("comment_html")}
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
@ -152,10 +151,9 @@ export const CommentCard: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<TiptapEditorWithRef
|
<LiteTextEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={showEditorRef}
|
ref={showEditorRef}
|
||||||
value={comment.comment_html}
|
value={comment.comment_html}
|
||||||
editable={false}
|
editable={false}
|
||||||
|
@ -9,7 +9,7 @@ import { useDebouncedCallback } from "use-debounce";
|
|||||||
import { TextArea } from "components/ui";
|
import { TextArea } from "components/ui";
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
import { TiptapEditor } from "@plane/editor";
|
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||||
import fileService from "services/file.service";
|
import fileService from "services/file.service";
|
||||||
|
|
||||||
export interface IssueDescriptionFormValues {
|
export interface IssueDescriptionFormValues {
|
||||||
@ -134,7 +134,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
if (!value) return <></>;
|
if (!value) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TiptapEditor
|
<RichTextEditor
|
||||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
value={value}
|
value={value}
|
||||||
|
@ -25,7 +25,7 @@ import { CreateLabelModal } from "components/labels";
|
|||||||
// ui
|
// ui
|
||||||
import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
|
import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
|
||||||
// components
|
// components
|
||||||
import { TiptapEditorWithRef } from "@plane/editor";
|
import { TiptapEditorWithRef } from "@plane/rich-text-editor";
|
||||||
// icons
|
// icons
|
||||||
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
// types
|
// types
|
||||||
@ -387,9 +387,8 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TiptapEditorWithRef
|
<TiptapEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
value={
|
value={
|
||||||
|
@ -25,7 +25,7 @@ import { CreateLabelModal } from "components/labels";
|
|||||||
// ui
|
// ui
|
||||||
import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
|
import { CustomMenu, Input, PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
|
||||||
// components
|
// components
|
||||||
import { TiptapEditorWithRef } from "@plane/editor";
|
import { TiptapEditorWithRef } from "@plane/rich-text-editor";
|
||||||
|
|
||||||
// icons
|
// icons
|
||||||
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
@ -385,9 +385,8 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TiptapEditorWithRef
|
<TiptapEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
value={
|
value={
|
||||||
|
@ -11,7 +11,7 @@ import aiService from "services/ai.service";
|
|||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// components
|
// components
|
||||||
import { GptAssistantModal } from "components/core";
|
import { GptAssistantModal } from "components/core";
|
||||||
import { TiptapEditorWithRef } from "@plane/editor";
|
import { TiptapEditorWithRef } from "@plane/rich-text-editor";
|
||||||
import { PrimaryButton, SecondaryButton, TextArea } from "components/ui";
|
import { PrimaryButton, SecondaryButton, TextArea } from "components/ui";
|
||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IPageBlock } from "types";
|
import { ICurrentUserResponse, IPageBlock } from "types";
|
||||||
@ -282,9 +282,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
|||||||
if (!data)
|
if (!data)
|
||||||
return (
|
return (
|
||||||
<TiptapEditorWithRef
|
<TiptapEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={"<p></p>"}
|
value={"<p></p>"}
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
@ -304,9 +303,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TiptapEditorWithRef
|
<TiptapEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={
|
value={
|
||||||
value && value !== "" && Object.keys(value).length > 0
|
value && value !== "" && Object.keys(value).length > 0
|
||||||
|
@ -19,7 +19,7 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
|||||||
// components
|
// components
|
||||||
import { GptAssistantModal } from "components/core";
|
import { GptAssistantModal } from "components/core";
|
||||||
import { CreateUpdateBlockInline } from "components/pages";
|
import { CreateUpdateBlockInline } from "components/pages";
|
||||||
import { TiptapEditor } from "@plane/editor";
|
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||||
// ui
|
// ui
|
||||||
import { CustomMenu, TextArea } from "components/ui";
|
import { CustomMenu, TextArea } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
@ -452,10 +452,9 @@ export const SinglePageBlock: React.FC<Props> = ({
|
|||||||
|
|
||||||
{showBlockDetails
|
{showBlockDetails
|
||||||
? block.description_html.length > 7 && (
|
? block.description_html.length > 7 && (
|
||||||
<TiptapEditor
|
<RichTextEditor
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
value={block.description_html}
|
value={block.description_html}
|
||||||
customClassName="text-sm min-h-[150px]"
|
customClassName="text-sm min-h-[150px]"
|
||||||
noBorder
|
noBorder
|
||||||
|
@ -10,7 +10,7 @@ import { useForm, Controller } from "react-hook-form";
|
|||||||
import useProjectDetails from "hooks/use-project-details";
|
import useProjectDetails from "hooks/use-project-details";
|
||||||
|
|
||||||
// components
|
// components
|
||||||
import { TiptapEditorWithRef } from "@plane/editor";
|
import { TiptapEditorWithRef } from "@plane/rich-text-editor";
|
||||||
|
|
||||||
// icons
|
// icons
|
||||||
import { Send } from "lucide-react";
|
import { Send } from "lucide-react";
|
||||||
@ -79,9 +79,8 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit }) => {
|
|||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<TiptapEditorWithRef
|
<TiptapEditorWithRef
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={!value || value === "" ? "<p></p>" : value}
|
value={!value || value === "" ? "<p></p>" : value}
|
||||||
customClassName="p-3 min-h-[100px] shadow-sm"
|
customClassName="p-3 min-h-[100px] shadow-sm"
|
||||||
|
@ -16,7 +16,7 @@ import useReloadConfirmations from "hooks/use-reload-confirmation";
|
|||||||
import { TextArea } from "components/ui";
|
import { TextArea } from "components/ui";
|
||||||
|
|
||||||
// components
|
// components
|
||||||
import { TiptapEditor } from "@plane/editor";
|
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||||
import { Label } from "components/web-view";
|
import { Label } from "components/web-view";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
@ -123,8 +123,8 @@ export const IssueWebViewForm: React.FC<Props> = (props) => {
|
|||||||
if (!value) return <></>;
|
if (!value) return <></>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TiptapEditor
|
<RichTextEditor
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
value={
|
value={
|
||||||
!value ||
|
!value ||
|
||||||
@ -133,7 +133,6 @@ export const IssueWebViewForm: React.FC<Props> = (props) => {
|
|||||||
? "<p></p>"
|
? "<p></p>"
|
||||||
: value
|
: value
|
||||||
}
|
}
|
||||||
workspaceSlug={workspaceSlug!.toString()}
|
|
||||||
debouncedUpdatesEnabled={true}
|
debouncedUpdatesEnabled={true}
|
||||||
setShouldShowAlert={setShowAlert}
|
setShouldShowAlert={setShowAlert}
|
||||||
setIsSubmitting={setIsSubmitting}
|
setIsSubmitting={setIsSubmitting}
|
||||||
|
@ -27,7 +27,8 @@
|
|||||||
"@nivo/pie": "0.80.0",
|
"@nivo/pie": "0.80.0",
|
||||||
"@nivo/scatterplot": "0.80.0",
|
"@nivo/scatterplot": "0.80.0",
|
||||||
"@sentry/nextjs": "^7.36.0",
|
"@sentry/nextjs": "^7.36.0",
|
||||||
"@plane/editor": "*",
|
"@plane/lite-text-editor": "*",
|
||||||
|
"@plane/rich-text-editor": "*",
|
||||||
"@types/lodash.debounce": "^4.0.7",
|
"@types/lodash.debounce": "^4.0.7",
|
||||||
"@types/react-datepicker": "^4.8.0",
|
"@types/react-datepicker": "^4.8.0",
|
||||||
"axios": "^1.1.3",
|
"axios": "^1.1.3",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { TiptapEditor } from "@plane/editor";
|
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||||
import type { NextPage } from "next";
|
import type { NextPage } from "next";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
@ -135,8 +135,8 @@ const Editor: NextPage = () => {
|
|||||||
name="description_html"
|
name="description_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<TiptapEditor
|
<RichTextEditor
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(cookies.MOBILE_slug ?? "")}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
borderOnFocus={false}
|
borderOnFocus={false}
|
||||||
value={
|
value={
|
||||||
@ -148,7 +148,6 @@ const Editor: NextPage = () => {
|
|||||||
}
|
}
|
||||||
editable={editable === "true"}
|
editable={editable === "true"}
|
||||||
noBorder={true}
|
noBorder={true}
|
||||||
workspaceSlug={cookies.MOBILE_slug ?? ""}
|
|
||||||
debouncedUpdatesEnabled={true}
|
debouncedUpdatesEnabled={true}
|
||||||
setShouldShowAlert={setShowAlert}
|
setShouldShowAlert={setShowAlert}
|
||||||
setIsSubmitting={setIsSubmitting}
|
setIsSubmitting={setIsSubmitting}
|
||||||
|
@ -9,7 +9,7 @@ import userService from "services/user.service";
|
|||||||
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
|
import { WorkspaceAuthorizationLayout } from "layouts/auth-layout";
|
||||||
// components
|
// components
|
||||||
import { ActivityIcon, ActivityMessage } from "components/core";
|
import { ActivityIcon, ActivityMessage } from "components/core";
|
||||||
import { TiptapEditor } from "@plane/editor";
|
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||||
// icons
|
// icons
|
||||||
import { ArrowTopRightOnSquareIcon, ChatBubbleLeftEllipsisIcon } from "@heroicons/react/24/outline";
|
import { ArrowTopRightOnSquareIcon, ChatBubbleLeftEllipsisIcon } from "@heroicons/react/24/outline";
|
||||||
// ui
|
// ui
|
||||||
@ -98,10 +98,9 @@ const ProfileActivity = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="issue-comments-section p-0">
|
<div className="issue-comments-section p-0">
|
||||||
<TiptapEditor
|
<RichTextEditor
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
value={
|
value={
|
||||||
activityItem?.new_value !== ""
|
activityItem?.new_value !== ""
|
||||||
? activityItem.new_value
|
? activityItem.new_value
|
||||||
|
@ -14,7 +14,7 @@ import { Controller, useForm } from "react-hook-form";
|
|||||||
import WebViewLayout from "layouts/web-view-layout";
|
import WebViewLayout from "layouts/web-view-layout";
|
||||||
|
|
||||||
// components
|
// components
|
||||||
import { TiptapEditor } from "@plane/editor";
|
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||||
import { PrimaryButton, Spinner } from "components/ui";
|
import { PrimaryButton, Spinner } from "components/ui";
|
||||||
// services
|
// services
|
||||||
import fileService from "@/services/file.service";
|
import fileService from "@/services/file.service";
|
||||||
@ -54,8 +54,8 @@ const Editor: NextPage = () => {
|
|||||||
name="data_html"
|
name="data_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { value, onChange } }) => (
|
||||||
<TiptapEditor
|
<RichTextEditor
|
||||||
uploadFile={fileService.uploadFile}
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
deleteFile={fileService.deleteImage}
|
deleteFile={fileService.deleteImage}
|
||||||
borderOnFocus={false}
|
borderOnFocus={false}
|
||||||
value={
|
value={
|
||||||
@ -67,7 +67,6 @@ const Editor: NextPage = () => {
|
|||||||
}
|
}
|
||||||
editable={isEditable}
|
editable={isEditable}
|
||||||
noBorder={true}
|
noBorder={true}
|
||||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
|
||||||
debouncedUpdatesEnabled={true}
|
debouncedUpdatesEnabled={true}
|
||||||
customClassName="min-h-[150px] shadow-sm"
|
customClassName="min-h-[150px] shadow-sm"
|
||||||
editorContentCustomClassNames="pb-9"
|
editorContentCustomClassNames="pb-9"
|
||||||
|
@ -2808,7 +2808,7 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react@*", "@types/react@18.0.15", "@types/react@18.0.28", "@types/react@18.2.0", "@types/react@^18.0.17":
|
"@types/react@*", "@types/react@18.0.15", "@types/react@18.0.28", "@types/react@18.2.0", "@types/react@^18.0.17", "@types/react@^18.2.5":
|
||||||
version "18.2.0"
|
version "18.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.0.tgz#15cda145354accfc09a18d2f2305f9fc099ada21"
|
resolved "https://registry.yarnpkg.com/@types/react/-/react-18.2.0.tgz#15cda145354accfc09a18d2f2305f9fc099ada21"
|
||||||
integrity sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==
|
integrity sha512-0FLj93y5USLHdnhIhABk83rm8XEGA7kH3cr+YUlvxoUGp1xNt/DINUMvqPxLyOQMzLmZe8i4RTHbvb8MC7NmrA==
|
||||||
|
Loading…
Reference in New Issue
Block a user