image can't be inserted inside table (#2904)

* image can't be inserted inside table

Now we've diabled image icon from showing up if the cursor is inside a table node or if a table cell is selected

* added drag drop support for document editor

* fixed missing dependencies
This commit is contained in:
M. Palanikannan 2023-11-27 20:37:40 +05:30 committed by sriram veeraghanta
parent 041c3af35a
commit 10cde58363
32 changed files with 555 additions and 553 deletions

View file

@ -23,12 +23,10 @@
},
"peerDependencies": {
"next": "12.3.2",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "18.2.0"
},
"dependencies": {
"@blueprintjs/popover2": "^2.0.10",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-code-block-lowlight": "^2.1.12",
"@tiptap/extension-color": "^2.1.11",
@ -49,29 +47,24 @@
"@tiptap/react": "^2.1.7",
"@tiptap/starter-kit": "^2.1.10",
"@tiptap/suggestion": "^2.0.4",
"@types/node": "18.15.3",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eventsource-parser": "^0.1.0",
"highlight.js": "^11.8.0",
"jsx-dom-cjs": "^8.0.3",
"lowlight": "^3.0.0",
"lucide-react": "^0.244.0",
"prosemirror-async-query": "^0.0.4",
"react-markdown": "^8.0.7",
"react-moveable": "^0.54.2",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.2",
"use-debounce": "^9.0.4"
"tiptap-markdown": "^0.8.2"
},
"devDependencies": {
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"eslint-config-next": "13.2.4",
"@types/node": "18.15.3",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
"tailwind-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",

View file

@ -27,33 +27,17 @@
"react-dom": "18.2.0"
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@plane/ui": "*",
"@plane/editor-core": "*",
"@popperjs/core": "^2.11.8",
"@plane/editor-extensions": "*",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-code-block-lowlight": "^2.1.11",
"@tiptap/extension-horizontal-rule": "^2.1.11",
"@tiptap/extension-list-item": "^2.1.11",
"@tiptap/extension-placeholder": "^2.1.11",
"@tiptap/suggestion": "^2.1.7",
"@types/node": "18.15.3",
"@types/react": "^18.2.5",
"@types/react-dom": "18.0.11",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eventsource-parser": "^0.1.0",
"highlight.js": "^11.8.0",
"lowlight": "^3.0.0",
"lucide-react": "^0.244.0",
"react-markdown": "^8.0.7",
"react-popper": "^2.3.0",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.2",
"use-debounce": "^9.0.4"
"react-popper": "^2.3.0"
},
"devDependencies": {
"eslint": "^7.32.0",

View file

@ -1,59 +1,28 @@
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import Placeholder from "@tiptap/extension-placeholder";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import { common, createLowlight } from 'lowlight'
import { InputRule } from "@tiptap/core";
import { SlashCommand } from "@plane/editor-extensions";
import ts from "highlight.js/lib/languages/typescript";
import SlashCommand from "./slash-command";
import { UploadImage } from "../";
const lowlight = createLowlight(common)
lowlight.register("ts", ts);
import { UploadImage } from "@plane/editor-types";
import { DragAndDrop } from "@plane/editor-extensions";
export const DocumentEditorExtensions = (
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
) => [
HorizontalRule.extend({
addInputRules() {
return [
new InputRule({
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
handler: ({ state, range, commands }) => {
commands.splitBlock();
SlashCommand(uploadFile, setIsSubmitting),
DragAndDrop,
Placeholder.configure({
placeholder: ({ node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
return "";
}
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",
},
}),
SlashCommand(uploadFile, setIsSubmitting),
CodeBlockLowlight.configure({
lowlight,
}),
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,
}),
];
return "Press '/' for commands...";
},
includeChildren: true,
}),
];

View file

@ -1,343 +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 { UploadImage } from "../";
import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core";
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) => {
toggleHeadingOne(editor, range);
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingTwo(editor, range);
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingThree(editor, range);
},
},
{
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) => {
toggleTaskList(editor, range)
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleBulletList(editor, range);
},
},
{
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) => {
insertTableCommand(editor, range);
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleOrderedList(editor, range)
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
toggleBlockquote(editor, range)
},
{
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) => {
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
},
},
].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,
// @ts-ignore
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector("#editor-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;

View file

@ -4,6 +4,7 @@ import { BoldIcon } from "lucide-react";
import {
BoldItem,
BulletListItem,
isCellSelection,
cn,
CodeItem,
ImageItem,
@ -16,6 +17,7 @@ import {
HeadingOneItem,
HeadingTwoItem,
HeadingThreeItem,
findTableAncestor,
} from "@plane/editor-core";
import { UploadImage } from "..";
@ -57,10 +59,36 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
CodeItem(editor),
];
const complexItems: BubbleMenuItem[] = [
TableItem(editor),
ImageItem(editor, uploadFile, setIsSubmitting),
];
function getComplexItems(): BubbleMenuItem[] {
const items: BubbleMenuItem[] = [TableItem(editor)];
if (shouldShowImageItem()) {
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
}
return items;
}
const complexItems: BubbleMenuItem[] = getComplexItems();
function shouldShowImageItem(): boolean {
if (typeof window !== "undefined") {
const selectionRange: any = window?.getSelection();
const { selection } = props.editor.state;
if (selectionRange.rangeCount !== 0) {
const range = selectionRange.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return false;
}
if (isCellSelection(selection)) {
return false;
}
}
return true;
}
return false;
}
return (
<div className="flex items-center divide-x divide-custom-border-200">

View file

@ -0,0 +1,97 @@
# @plane/editor-extensions
## Description
The `@plane/lite-text-editor` package extends from the `editor-core` package, inheriting its base functionality while adding its own unique features of Custom control over Enter key, etc.
## Key Features
- **Exported Components**: There are two components exported from the Lite text editor (with and without Ref), you can choose to use the `withRef` instance whenever you want to control the Editors state via a side effect of some external action from within the application code.
`LiteTextEditor` & `LiteTextEditorWithRef`
- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Lite editor types (with and without Ref)
`LiteReadOnlyEditor` &`LiteReadOnlyEditorWithRef`
## LiteTextEditor
| Prop | Type | Description |
| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
1. Here is an example of how to use the `RichTextEditor` component
```tsx
<LiteTextEditor
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
```
2. Example of how to use the `LiteTextEditorWithRef` component
```tsx
const editorRef = useRef<any>(null);
// can use it to set the editor's value
editorRef.current?.setEditorValue(`${watch("description_html")}`);
// can use it to clear the editor
editorRef?.current?.clearEditor();
return (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
ref={editorRef}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
);
```
## LiteReadOnlyEditor
| Prop | Type | Description |
| ------------------------------- | ------------- | --------------------------------------------------------------------- |
| `value` | `html string` | The initial content of the editor. |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
Here is an example of how to use the `RichReadOnlyEditor` component
```tsx
<LiteReadOnlyEditor
value={comment.comment_html}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
```

View file

@ -0,0 +1,60 @@
{
"name": "@plane/editor-extensions",
"version": "0.1.0",
"description": "Package that powers Plane's Editor with extensions",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"files": [
"dist/**/*"
],
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"module": "./dist/index.mjs"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"next": "12.3.2",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "18.2.0"
},
"dependencies": {
"@tiptap/react": "^2.1.7",
"@tiptap/core": "^2.1.7",
"@tiptap/suggestion": "^2.0.4",
"@plane/editor-types": "*",
"@plane/editor-core": "*",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"lucide-react": "^0.244.0",
"tippy.js": "^6.3.7",
"@tiptap/pm": "^2.1.7"
},
"devDependencies": {
"@types/node": "18.15.3",
"@types/react": "^18.2.35",
"@types/react-dom": "^18.2.14",
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"tailwind-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "4.9.5"
},
"keywords": [
"editor",
"rich-text",
"markdown",
"nextjs",
"react"
]
}

View file

@ -0,0 +1,9 @@
// If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -3,7 +3,30 @@ import { Extension } from "@tiptap/core";
import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state";
// @ts-ignore
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
import { createDragHandleElement } from "../../lib/utils/DragHandleElement";
function createDragHandleElement(): HTMLElement {
let dragHandleElement = document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");
const dragHandleContainer = document.createElement("div");
dragHandleContainer.classList.add("drag-handle-container");
dragHandleElement.appendChild(dragHandleContainer);
const dotsContainer = document.createElement("div");
dotsContainer.classList.add("drag-handle-dots");
for (let i = 0; i < 6; i++) {
const spanElement = document.createElement("span");
spanElement.classList.add("drag-handle-dot");
dotsContainer.appendChild(spanElement);
}
dragHandleContainer.appendChild(dotsContainer);
return dragHandleElement;
}
export interface DragHandleOptions {
dragHandleWidth: number;
@ -220,7 +243,7 @@ function DragHandle(options: DragHandleOptions) {
});
}
const DragAndDrop = Extension.create({
export const DragAndDrop = Extension.create({
name: "dragAndDrop",
addProseMirrorPlugins() {
@ -231,5 +254,3 @@ const DragAndDrop = Extension.create({
];
},
});
export default DragAndDrop;

View file

@ -10,6 +10,7 @@ import { Editor, Range, Extension } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
import tippy from "tippy.js";
import type { UploadImage } from "@plane/editor-types";
import {
Heading1,
Heading2,
@ -24,7 +25,6 @@ import {
ImageIcon,
Table,
} from "lucide-react";
import { UploadImage } from "../";
import {
cn,
insertTableCommand,
@ -156,6 +156,7 @@ const getSuggestionItems =
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
// @ts-expect-error I have to move this to the core
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
@ -191,6 +192,7 @@ const getSuggestionItems =
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) =>
// @ts-expect-error I have to move this to the core
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
@ -381,5 +383,3 @@ export const SlashCommand = (
render: renderItems,
},
});
export default SlashCommand;

View file

@ -0,0 +1,2 @@
export { SlashCommand } from "./extensions/slash-commands";
export { DragAndDrop } from "./extensions/drag-drop";

View file

@ -0,0 +1,6 @@
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
module.exports = {
// prefix ui lib classes to avoid conflicting with the app
...sharedConfig,
};

View file

@ -0,0 +1,5 @@
{
"extends": "tsconfig/react-library.json",
"include": ["src/**/*", "index.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View file

@ -0,0 +1,11 @@
import { defineConfig, Options } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));

View file

@ -29,20 +29,7 @@
},
"dependencies": {
"@plane/editor-core": "*",
"@plane/ui": "*",
"@tiptap/extension-list-item": "^2.1.11",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"eslint": "8.36.0",
"eslint-config-next": "13.2.4",
"eventsource-parser": "^0.1.0",
"lowlight": "^2.9.0",
"lucide-react": "^0.244.0",
"react-markdown": "^8.0.7",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.2",
"use-debounce": "^9.0.4"
"@plane/ui": "*"
},
"devDependencies": {
"@types/node": "18.15.3",
@ -51,6 +38,7 @@
"eslint": "^7.32.0",
"postcss": "^8.4.29",
"tailwind-config-custom": "*",
"eslint-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "4.9.5"

View file

@ -1,12 +1,13 @@
import { Editor } from "@tiptap/react";
import { BoldIcon } from "lucide-react";
import {
BoldItem,
BulletListItem,
cn,
CodeItem,
findTableAncestor,
ImageItem,
isCellSelection,
ItalicItem,
NumberedListItem,
QuoteItem,
@ -16,12 +17,18 @@ import {
} from "@plane/editor-core";
import { Tooltip } from "@plane/ui";
import { UploadImage } from "../../";
import type { SVGProps } from "react";
interface LucideProps extends Partial<SVGProps<SVGSVGElement>> {
size?: string | number
absoluteStrokeWidth?: boolean
}
type LucideIcon = (props: LucideProps) => JSX.Element;
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
icon: typeof BoldIcon;
icon: LucideIcon;
}
type EditorBubbleMenuProps = {
@ -63,10 +70,38 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => {
CodeItem(props.editor),
];
const complexItems: BubbleMenuItem[] = [
TableItem(props.editor),
ImageItem(props.editor, props.uploadFile, props.setIsSubmitting),
];
function getComplexItems(): BubbleMenuItem[] {
const items: BubbleMenuItem[] = [TableItem(props.editor)];
if (shouldShowImageItem()) {
items.push(
ImageItem(props.editor, props.uploadFile, props.setIsSubmitting),
);
}
return items;
}
const complexItems: BubbleMenuItem[] = getComplexItems();
function shouldShowImageItem(): boolean {
if (typeof window !== "undefined") {
const selectionRange: any = window?.getSelection();
const { selection } = props.editor.state;
if (selectionRange.rangeCount !== 0) {
const range = selectionRange.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return false;
}
if (isCellSelection(selection)) {
return false;
}
}
return true;
}
return false;
}
const handleAccessChange = (accessKey: string) => {
props.commentAccessSpecifier?.onAccessChange(accessKey);

View file

@ -22,7 +22,6 @@
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"@tiptap/core": "^2.1.11",
"next": "12.3.2",
"next-themes": "^0.2.1",
"react": "^18.2.0",
@ -30,11 +29,9 @@
},
"dependencies": {
"@plane/editor-core": "*",
"@tiptap/extension-horizontal-rule": "^2.1.11",
"@tiptap/core": "^2.1.11",
"@plane/editor-extensions": "*",
"@tiptap/extension-placeholder": "^2.1.11",
"@tiptap/suggestion": "^2.1.7",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"lucide-react": "^0.244.0"
},
"devDependencies": {

View file

@ -1,23 +0,0 @@
export function createDragHandleElement(): HTMLElement {
let dragHandleElement = document.createElement("div");
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = "";
dragHandleElement.classList.add("drag-handle");
const dragHandleContainer = document.createElement("div");
dragHandleContainer.classList.add("drag-handle-container");
dragHandleElement.appendChild(dragHandleContainer);
const dotsContainer = document.createElement("div");
dotsContainer.classList.add("drag-handle-dots");
for (let i = 0; i < 6; i++) {
const spanElement = document.createElement("span");
spanElement.classList.add("drag-handle-dot");
dotsContainer.appendChild(spanElement);
}
dragHandleContainer.appendChild(dotsContainer);
return dragHandleElement;
}

View file

@ -1,8 +1,7 @@
import { SlashCommand } from "@plane/editor-extensions";
import Placeholder from "@tiptap/extension-placeholder";
import SlashCommand from "./slash-command";
import { DragAndDrop } from "@plane/editor-extensions";
import { UploadImage } from "../";
import DragAndDrop from "./drag-drop";
export const RichTextEditorExtensions = (
uploadFile: UploadImage,

View file

@ -0,0 +1,97 @@
# @plane/editor-extensions
## Description
The `@plane/lite-text-editor` package extends from the `editor-core` package, inheriting its base functionality while adding its own unique features of Custom control over Enter key, etc.
## Key Features
- **Exported Components**: There are two components exported from the Lite text editor (with and without Ref), you can choose to use the `withRef` instance whenever you want to control the Editors state via a side effect of some external action from within the application code.
`LiteTextEditor` & `LiteTextEditorWithRef`
- **Read Only Editor Instances**: We have added a really light weight _Read Only_ Editor instance for the Lite editor types (with and without Ref)
`LiteReadOnlyEditor` &`LiteReadOnlyEditorWithRef`
## LiteTextEditor
| Prop | Type | Description |
| ------------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
| `value` | `html string` | The initial content of the editor. |
| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press |
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
1. Here is an example of how to use the `RichTextEditor` component
```tsx
<LiteTextEditor
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
```
2. Example of how to use the `LiteTextEditorWithRef` component
```tsx
const editorRef = useRef<any>(null);
// can use it to set the editor's value
editorRef.current?.setEditorValue(`${watch("description_html")}`);
// can use it to clear the editor
editorRef?.current?.clearEditor();
return (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
ref={editorRef}
value={value}
debouncedUpdatesEnabled={false}
customClassName="min-h-[50px] p-3 shadow-sm"
onChange={(comment_json: Object, comment_html: string) => {
onChange(comment_html);
}}
/>
);
```
## LiteReadOnlyEditor
| Prop | Type | Description |
| ------------------------------- | ------------- | --------------------------------------------------------------------- |
| `value` | `html string` | The initial content of the editor. |
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
### Usage
Here is an example of how to use the `RichReadOnlyEditor` component
```tsx
<LiteReadOnlyEditor
value={comment.comment_html}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
```

View file

@ -0,0 +1,50 @@
{
"name": "@plane/editor-types",
"version": "0.1.0",
"description": "Package that powers Plane's Editor with extensions",
"private": true,
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"files": [
"dist/**/*"
],
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"module": "./dist/index.mjs"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"next": "12.3.2",
"next-themes": "^0.2.1",
"react": "^18.2.0",
"react-dom": "18.2.0"
},
"dependencies": {
"eslint": "8.36.0",
"eslint-config-next": "13.2.4"
},
"devDependencies": {
"@types/node": "18.15.3",
"@types/react": "^18.2.35",
"@types/react-dom": "^18.2.14",
"eslint": "^7.32.0",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "4.9.5"
},
"keywords": [
"editor",
"rich-text",
"markdown",
"nextjs",
"react"
]
}

View file

@ -0,0 +1,9 @@
// If you want to use other PostCSS plugins, see the following:
// https://tailwindcss.com/docs/using-with-preprocessors
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,6 @@
export type { DeleteImage } from "./types/delete-image";
export type { UploadImage } from "./types/upload-image";
export type {
IMentionHighlight,
IMentionSuggestion,
} from "./types/mention-suggestion";

View file

@ -0,0 +1 @@
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;

View file

@ -0,0 +1,10 @@
export type IMentionSuggestion = {
id: string;
type: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
};
export type IMentionHighlight = string;

View file

@ -0,0 +1 @@
export type UploadImage = (file: File) => Promise<string>;

View file

@ -0,0 +1,6 @@
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
module.exports = {
// prefix ui lib classes to avoid conflicting with the app
...sharedConfig,
};

View file

@ -0,0 +1,5 @@
{
"extends": "tsconfig/react-library.json",
"include": ["src/**/*", "index.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}

View file

@ -0,0 +1,11 @@
import { defineConfig, Options } from "tsup";
export default defineConfig((options: Options) => ({
entry: ["src/index.ts"],
format: ["cjs", "esm"],
dts: true,
clean: false,
external: ["react"],
injectStyle: true,
...options,
}));