forked from github/plane
feat: Editor Core Packaging and Restructuring (#2358)
* initialized tiptap component with common tailwind config * added common tailwind config to web * abstracted upload and delete functions * removed tiptap pro extension * fixed types * removed old tailwind config and fixed plane package imports * exported tiptap editor with and without ref * updated package name to @plane/editor * finally fixed import errors * added turbo dependency for tiptap * reverted back types and fixed tailwind * migrated all components to use the common package * removed old tiptap dependency * improved dev experience to build the tiptap package before starting dev server * resolved lock life and missing deps * fixed dependency issue with react type resolution * chore: updated pulls build CI for using turbo builds * comment editor basic version added * new structure of editor components * refactored editor to not require workspace slug * added seperation of extensions and props * refactoring to LiteTextEditor and RichTextEditor * fixed global css issue with highlight js * refactoring tiptap to core/lite/rich text editor * read only editor support added * replaced all read-only instances * trimming html at start and end of content added * onSubmit on enterkey captured * removed absolute imports from editor/core package * removed absolute imports from lite-text-editor * removed absolute imports from rich-text-editor * fixed dependencies in editor package * fixed tailwind config for editor * Enter key behaviour added for Comments * fixed modal form issue * added comment editor with fixed menu * added support for range commands * modified turbo config for build pipeline of space and web projects * fixed shift enter behavior for lists * removed extra margin from access specifiers * removed tiptap instance from web * fixed bugs returning empty editor boxes * fixed toggle Underline behvaiour * updated bubble menu to use core package's utilities * added editor/core readme and fixed imports * fixed ts issues with link plugin * added usage of common dependance for slash commands * completed core package's documentation * fixed tsconfig by removing path aliases * Completed readme for rich-text-editor * Added rich text editor documentation * changed readme title of core package --------- Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
This commit is contained in:
parent
930a20ea7f
commit
0a8b99a074
@ -36,15 +36,13 @@ jobs:
|
|||||||
- name: Build Plane's Main App
|
- name: Build Plane's Main App
|
||||||
if: steps.changed-files.outputs.web_any_changed == 'true'
|
if: steps.changed-files.outputs.web_any_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
cd web
|
|
||||||
yarn
|
yarn
|
||||||
yarn build
|
yarn build --filter=web
|
||||||
|
|
||||||
- name: Build Plane's Deploy App
|
- name: Build Plane's Deploy App
|
||||||
if: steps.changed-files.outputs.deploy_any_changed == 'true'
|
if: steps.changed-files.outputs.deploy_any_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
cd space
|
|
||||||
yarn
|
yarn
|
||||||
yarn build
|
yarn build --filter=space
|
||||||
|
|
||||||
|
|
||||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -74,3 +74,7 @@ pnpm-lock.yaml
|
|||||||
pnpm-workspace.yaml
|
pnpm-workspace.yaml
|
||||||
|
|
||||||
.npmrc
|
.npmrc
|
||||||
|
|
||||||
|
|
||||||
|
## packages
|
||||||
|
dist
|
||||||
|
@ -6,7 +6,11 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"web",
|
"web",
|
||||||
"space",
|
"space",
|
||||||
"packages/*"
|
"packages/editor/*",
|
||||||
|
"packages/eslint-config-custom",
|
||||||
|
"packages/tailwind-config-custom",
|
||||||
|
"packages/tsconfig",
|
||||||
|
"packages/ui"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
@ -25,5 +29,8 @@
|
|||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"turbo": "latest"
|
"turbo": "latest"
|
||||||
},
|
},
|
||||||
|
"resolutions": {
|
||||||
|
"@types/react": "18.2.0"
|
||||||
|
},
|
||||||
"packageManager": "yarn@1.22.19"
|
"packageManager": "yarn@1.22.19"
|
||||||
}
|
}
|
||||||
|
112
packages/editor/core/Readme.md
Normal file
112
packages/editor/core/Readme.md
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# @plane/editor-core
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `@plane/editor-core` package serves as the foundation for our editor system. It provides the base functionality for our other editor packages, but it will not be used directly in any of the projects but only for extending other editors.
|
||||||
|
|
||||||
|
## Utilities
|
||||||
|
|
||||||
|
We provide a wide range of utilities for extending the core itself.
|
||||||
|
|
||||||
|
1. Merging classes and custom styling
|
||||||
|
2. Adding new extensions
|
||||||
|
3. Adding custom props
|
||||||
|
4. Base menu items, and their commands
|
||||||
|
|
||||||
|
This allows for extensive customization and flexibility in the Editors created using our `editor-core` package.
|
||||||
|
|
||||||
|
### Here's a detailed overview of what's exported
|
||||||
|
|
||||||
|
1. useEditor - A hook that you can use to extend the Plane editor.
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
|
||||||
|
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
|
||||||
|
| `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. |
|
||||||
|
| `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 in case of content not being "saved". |
|
||||||
|
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
|
||||||
|
|
||||||
|
2. useReadOnlyEditor - A hook that can be used to extend a Read Only instance of the core editor.
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `value` | `string` | The initial content of the editor. |
|
||||||
|
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
|
||||||
|
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
|
||||||
|
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
|
||||||
|
|
||||||
|
3. Items and Commands - H1, H2, H3, task list, quote, code block, etc's methods.
|
||||||
|
|
||||||
|
4. UI Wrappers
|
||||||
|
|
||||||
|
- `EditorContainer` - Wrap your Editor Container with this to apply base classes and styles.
|
||||||
|
- `EditorContentWrapper` - Use this to get Editor's Content and base menus.
|
||||||
|
|
||||||
|
5. Extending with Custom Styles
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const customEditorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core features
|
||||||
|
|
||||||
|
- **Content Trimming**: The Editor’s content is now automatically trimmed of empty line breaks from the start and end before submitting it to the backend. This ensures cleaner, more consistent data.
|
||||||
|
- **Value Cleaning**: The Editor’s value is cleaned at the editor core level, eliminating the need for additional validation before sending from our app. This results in cleaner code and less potential for errors.
|
||||||
|
- **Turbo Pipeline**: Added a turbo pipeline for both dev and build tasks for projects depending on the editor package.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"web#develop": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true,
|
||||||
|
"dependsOn": [
|
||||||
|
"@plane/lite-text-editor#build",
|
||||||
|
"@plane/rich-text-editor#build"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"space#develop": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true,
|
||||||
|
"dependsOn": [
|
||||||
|
"@plane/lite-text-editor#build",
|
||||||
|
"@plane/rich-text-editor#build"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"web#build": {
|
||||||
|
"cache": true,
|
||||||
|
"dependsOn": [
|
||||||
|
"@plane/lite-text-editor#build",
|
||||||
|
"@plane/rich-text-editor#build"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"space#build": {
|
||||||
|
"cache": true,
|
||||||
|
"dependsOn": [
|
||||||
|
"@plane/lite-text-editor#build",
|
||||||
|
"@plane/rich-text-editor#build"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Base extensions included
|
||||||
|
|
||||||
|
- BulletList
|
||||||
|
- OrderedList
|
||||||
|
- Blockquote
|
||||||
|
- Code
|
||||||
|
- Gapcursor
|
||||||
|
- Link
|
||||||
|
- Image
|
||||||
|
- Basic Marks
|
||||||
|
- Underline
|
||||||
|
- TextStyle
|
||||||
|
- Color
|
||||||
|
- TaskList
|
||||||
|
- Markdown
|
||||||
|
- Table
|
76
packages/editor/core/package.json
Normal file
76
packages/editor/core/package.json
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "@plane/editor-core",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Core Editor that powers Plane",
|
||||||
|
"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": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
|
"next": "12.3.2",
|
||||||
|
"next-themes": "^0.2.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@blueprintjs/popover2": "^2.0.10",
|
||||||
|
"@tiptap/core": "^2.1.7",
|
||||||
|
"@tiptap/extension-color": "^2.1.11",
|
||||||
|
"@tiptap/extension-image": "^2.1.7",
|
||||||
|
"@tiptap/extension-link": "^2.1.7",
|
||||||
|
"@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",
|
||||||
|
"@types/react": "^18.2.5",
|
||||||
|
"@types/react-dom": "18.0.11",
|
||||||
|
"@types/node": "18.15.3",
|
||||||
|
"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",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
9
packages/editor/core/postcss.config.js
Normal file
9
packages/editor/core/postcss.config.js
Normal 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: {},
|
||||||
|
},
|
||||||
|
};
|
19
packages/editor/core/src/index.ts
Normal file
19
packages/editor/core/src/index.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
// styles
|
||||||
|
// import "./styles/tailwind.css";
|
||||||
|
// import "./styles/editor.css";
|
||||||
|
|
||||||
|
// utils
|
||||||
|
export * from "./lib/utils";
|
||||||
|
export { startImageUpload } from "./ui/plugins/upload-image";
|
||||||
|
|
||||||
|
// components
|
||||||
|
export { EditorContainer } from "./ui/components/editor-container";
|
||||||
|
export { EditorContentWrapper } from "./ui/components/editor-content";
|
||||||
|
|
||||||
|
// hooks
|
||||||
|
export { useEditor } from "./ui/hooks/useEditor";
|
||||||
|
export { useReadOnlyEditor } from "./ui/hooks/useReadOnlyEditor";
|
||||||
|
|
||||||
|
// helper items
|
||||||
|
export * from "./ui/menus/menu-items";
|
||||||
|
export * from "./lib/editor-commands";
|
91
packages/editor/core/src/lib/editor-commands.ts
Normal file
91
packages/editor/core/src/lib/editor-commands.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { Editor, Range } from "@tiptap/core";
|
||||||
|
import { UploadImage } from "../types/upload-image";
|
||||||
|
import { startImageUpload } from "../ui/plugins/upload-image";
|
||||||
|
|
||||||
|
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||||
|
else editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
||||||
|
else editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
||||||
|
else editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleBold = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
|
||||||
|
else editor.chain().focus().toggleBold().run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleItalic = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).toggleItalic().run();
|
||||||
|
else editor.chain().focus().toggleItalic().run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleUnderline = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).toggleUnderline().run();
|
||||||
|
else editor.chain().focus().toggleUnderline().run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleCode = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).toggleCode().run();
|
||||||
|
else editor.chain().focus().toggleCode().run();
|
||||||
|
};
|
||||||
|
export const toggleOrderedList = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||||
|
else editor.chain().focus().toggleOrderedList().run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleBulletList = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||||
|
else editor.chain().focus().toggleBulletList().run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleTaskList = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||||
|
else editor.chain().focus().toggleTaskList().run()
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleStrike = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).toggleStrike().run();
|
||||||
|
else editor.chain().focus().toggleStrike().run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleBlockquote = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run();
|
||||||
|
else editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||||
|
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unsetLinkEditor = (editor: Editor) => {
|
||||||
|
editor.chain().focus().unsetLink().run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setLinkEditor = (editor: Editor, url: string) => {
|
||||||
|
editor.chain().focus().setLink({ href: url }).run();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertImageCommand = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, range?: Range) => {
|
||||||
|
if (range) editor.chain().focus().deleteRange(range).run();
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
45
packages/editor/core/src/lib/utils.ts
Normal file
45
packages/editor/core/src/lib/utils.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findTableAncestor = (
|
||||||
|
node: Node | null
|
||||||
|
): HTMLTableElement | null => {
|
||||||
|
while (node !== null && node.nodeName !== "TABLE") {
|
||||||
|
node = node.parentNode;
|
||||||
|
}
|
||||||
|
return node as HTMLTableElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTrimmedHTML = (html: string) => {
|
||||||
|
html = html.replace(/^(<p><\/p>)+/, '');
|
||||||
|
html = html.replace(/(<p><\/p>)+$/, '');
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isValidHttpUrl = (string: string): boolean => {
|
||||||
|
let url: URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
url = new URL(string);
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.protocol === "http:" || url.protocol === "https:";
|
||||||
|
}
|
231
packages/editor/core/src/styles/editor.css
Normal file
231
packages/editor/core/src/styles/editor.css
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
.ProseMirror p.is-editor-empty:first-child::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: rgb(var(--color-text-400));
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror .is-empty::before {
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
color: rgb(var(--color-text-400));
|
||||||
|
pointer-events: none;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom image styles */
|
||||||
|
|
||||||
|
.ProseMirror img {
|
||||||
|
transition: filter 0.1s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
filter: brightness(90%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ProseMirror-selectednode {
|
||||||
|
outline: 3px solid #5abbf7;
|
||||||
|
filter: brightness(90%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror-gapcursor:after {
|
||||||
|
border-top: 1px solid rgb(var(--color-text-100)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */
|
||||||
|
|
||||||
|
ul[data-type="taskList"] li > label {
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
ul[data-type="taskList"] li > label {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background-color: rgb(var(--color-background-100));
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid rgb(var(--color-text-100));
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgb(var(--color-background-80));
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: rgb(var(--color-background-90));
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.65em;
|
||||||
|
height: 0.65em;
|
||||||
|
transform: scale(0);
|
||||||
|
transition: 120ms transform ease-in-out;
|
||||||
|
box-shadow: inset 1em 1em;
|
||||||
|
transform-origin: center;
|
||||||
|
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:checked::before {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||||
|
color: rgb(var(--color-text-200));
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overwrite tippy-box original max-width */
|
||||||
|
|
||||||
|
.tippy-box {
|
||||||
|
max-width: 400px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
position: relative;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
-moz-tab-size: 4;
|
||||||
|
tab-size: 4;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
-moz-user-select: text;
|
||||||
|
-ms-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
outline: none;
|
||||||
|
cursor: text;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: inherit;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
appearance: textfield;
|
||||||
|
-webkit-appearance: textfield;
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fadeIn {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.3s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fadeOut {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-placeholder {
|
||||||
|
position: relative;
|
||||||
|
width: 35%;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 45%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(var(--color-text-200));
|
||||||
|
border-top-color: rgba(var(--color-text-800));
|
||||||
|
animation: spinning 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinning {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor-container {
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid rgb(var(--color-border-200));
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
min-width: 1em;
|
||||||
|
border: 1px solid rgb(var(--color-border-200));
|
||||||
|
padding: 10px 15px;
|
||||||
|
vertical-align: top;
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: left;
|
||||||
|
background-color: rgb(var(--color-primary-100));
|
||||||
|
}
|
||||||
|
|
||||||
|
td:hover {
|
||||||
|
background-color: rgba(var(--color-primary-300), 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selectedCell:after {
|
||||||
|
z-index: 2;
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(var(--color-primary-300), 0.1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
top: 0;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 2px;
|
||||||
|
background-color: rgb(var(--color-primary-400));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableWrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-cursor {
|
||||||
|
cursor: ew-resize;
|
||||||
|
cursor: col-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table * p {
|
||||||
|
padding: 0px 1px;
|
||||||
|
margin: 6px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table * .is-empty::before {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
3
packages/editor/core/src/styles/tailwind.css
Normal file
3
packages/editor/core/src/styles/tailwind.css
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
1
packages/editor/core/src/types/delete-image.ts
Normal file
1
packages/editor/core/src/types/delete-image.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
1
packages/editor/core/src/types/upload-image.ts
Normal file
1
packages/editor/core/src/types/upload-image.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type UploadImage = (file: File) => Promise<string>;
|
20
packages/editor/core/src/ui/components/editor-container.tsx
Normal file
20
packages/editor/core/src/ui/components/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="editor-container"
|
||||||
|
onClick={() => {
|
||||||
|
editor?.chain().focus().run();
|
||||||
|
}}
|
||||||
|
className={`cursor-text ${editorClassNames}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
20
packages/editor/core/src/ui/components/editor-content.tsx
Normal file
20
packages/editor/core/src/ui/components/editor-content.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Editor, EditorContent } from "@tiptap/react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
import { ImageResizer } from "../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}`}>
|
||||||
|
{/* @ts-ignore */}
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
{editor?.isEditable && <TableMenu editor={editor} />}
|
||||||
|
{(editor?.isActive("image") && editor?.isEditable) && <ImageResizer editor={editor} />}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
23
packages/editor/core/src/ui/extensions/image/index.tsx
Normal file
23
packages/editor/core/src/ui/extensions/image/index.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import Image from "@tiptap/extension-image";
|
||||||
|
import TrackImageDeletionPlugin from "../../plugins/delete-image";
|
||||||
|
import UploadImagesPlugin from "../../plugins/upload-image";
|
||||||
|
import { DeleteImage } from "../../../types/delete-image";
|
||||||
|
|
||||||
|
const ImageExtension = (deleteImage: DeleteImage) => Image.extend({
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)];
|
||||||
|
},
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
width: {
|
||||||
|
default: "35%",
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ImageExtension;
|
@ -0,0 +1,17 @@
|
|||||||
|
import Image from "@tiptap/extension-image";
|
||||||
|
|
||||||
|
const ReadOnlyImageExtension = Image.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
...this.parent?.(),
|
||||||
|
width: {
|
||||||
|
default: "35%",
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ReadOnlyImageExtension;
|
@ -1,35 +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 { lowlight } from "lowlight/lib/core";
|
|
||||||
import SlashCommand from "../slash-command";
|
|
||||||
import { InputRule } from "@tiptap/core";
|
|
||||||
import Gapcursor from "@tiptap/extension-gapcursor";
|
import Gapcursor from "@tiptap/extension-gapcursor";
|
||||||
|
|
||||||
import ts from "highlight.js/lib/languages/typescript";
|
|
||||||
|
|
||||||
import "highlight.js/styles/github-dark.css";
|
|
||||||
import UpdatedImage from "./updated-image";
|
|
||||||
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
|
|
||||||
import { CustomTableCell } from "./table/table-cell";
|
import { CustomTableCell } from "./table/table-cell";
|
||||||
import { Table } from "./table/table";
|
import { Table } from "./table";
|
||||||
import { TableHeader } from "./table/table-header";
|
import { TableHeader } from "./table/table-header";
|
||||||
import { TableRow } from "@tiptap/extension-table-row";
|
import { TableRow } from "@tiptap/extension-table-row";
|
||||||
|
|
||||||
lowlight.registerLanguage("ts", ts);
|
import ImageExtension from "./image";
|
||||||
|
|
||||||
export const TiptapExtensions = (
|
import { DeleteImage } from "../../types/delete-image";
|
||||||
workspaceSlug: string,
|
import { isValidHttpUrl } from "../../lib/utils";
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
|
||||||
|
|
||||||
|
export const CoreEditorExtensions = (
|
||||||
|
deleteFile: DeleteImage,
|
||||||
) => [
|
) => [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
bulletList: {
|
bulletList: {
|
||||||
@ -67,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"],
|
||||||
@ -102,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.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(workspaceSlug, 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",
|
69
packages/editor/core/src/ui/hooks/useEditor.tsx
Normal file
69
packages/editor/core/src/ui/hooks/useEditor.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||||
|
import { useImperativeHandle, useRef, MutableRefObject } from "react";
|
||||||
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import { DeleteImage } from '../../types/delete-image';
|
||||||
|
import { CoreEditorProps } from "../props";
|
||||||
|
import { CoreEditorExtensions } from "../extensions";
|
||||||
|
import { EditorProps } from '@tiptap/pm/view';
|
||||||
|
import { getTrimmedHTML } from "../../lib/utils";
|
||||||
|
import { UploadImage } from "../../types/upload-image";
|
||||||
|
|
||||||
|
const DEBOUNCE_DELAY = 1500;
|
||||||
|
|
||||||
|
interface CustomEditorProps {
|
||||||
|
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?: any;
|
||||||
|
editorProps?: EditorProps;
|
||||||
|
forwardedRef?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEditor = ({ uploadFile, deleteFile, editorProps = {}, value, extensions = [], onChange, setIsSubmitting, debouncedUpdatesEnabled, forwardedRef, setShouldShowAlert, }: CustomEditorProps) => {
|
||||||
|
const editor = useCustomEditor({
|
||||||
|
editorProps: {
|
||||||
|
...CoreEditorProps(uploadFile, setIsSubmitting),
|
||||||
|
...editorProps,
|
||||||
|
},
|
||||||
|
extensions: [...CoreEditorExtensions(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(), getTrimmedHTML(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(), getTrimmedHTML(editor.getHTML()));
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_DELAY);
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
43
packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx
Normal file
43
packages/editor/core/src/ui/hooks/useReadOnlyEditor.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||||
|
import { useImperativeHandle, useRef, MutableRefObject } from "react";
|
||||||
|
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
|
||||||
|
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
|
||||||
|
import { EditorProps } from '@tiptap/pm/view';
|
||||||
|
|
||||||
|
interface CustomReadOnlyEditorProps {
|
||||||
|
value: string;
|
||||||
|
forwardedRef?: any;
|
||||||
|
extensions?: any;
|
||||||
|
editorProps?: EditorProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {} }: CustomReadOnlyEditorProps) => {
|
||||||
|
const editor = useCustomEditor({
|
||||||
|
editable: false,
|
||||||
|
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
|
||||||
|
editorProps: {
|
||||||
|
...CoreReadOnlyEditorProps,
|
||||||
|
...editorProps,
|
||||||
|
},
|
||||||
|
extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor;
|
||||||
|
};
|
92
packages/editor/core/src/ui/index.tsx
Normal file
92
packages/editor/core/src/ui/index.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"use client"
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Extension } from "@tiptap/react";
|
||||||
|
import { UploadImage } from '../types/upload-image';
|
||||||
|
import { DeleteImage } from '../types/delete-image';
|
||||||
|
import { getEditorClassNames } from '../lib/utils';
|
||||||
|
import { EditorProps } from '@tiptap/pm/view';
|
||||||
|
import { useEditor } from './hooks/useEditor';
|
||||||
|
import { EditorContainer } from '../ui/components/editor-container';
|
||||||
|
import { EditorContentWrapper } from '../ui/components/editor-content';
|
||||||
|
|
||||||
|
interface ICoreEditor {
|
||||||
|
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 EditorCoreProps extends ICoreEditor {
|
||||||
|
forwardedRef?: React.Ref<EditorHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorHandle {
|
||||||
|
clearEditor: () => void;
|
||||||
|
setEditorValue: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CoreEditor = ({
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
editable,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
editorContentCustomClassNames,
|
||||||
|
value,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
noBorder,
|
||||||
|
borderOnFocus,
|
||||||
|
customClassName,
|
||||||
|
forwardedRef,
|
||||||
|
}: EditorCoreProps) => {
|
||||||
|
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} />
|
||||||
|
</div>
|
||||||
|
</EditorContainer >
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CoreEditorWithRef = React.forwardRef<EditorHandle, ICoreEditor>((props, ref) => (
|
||||||
|
<CoreEditor {...props} forwardedRef={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
CoreEditorWithRef.displayName = "CoreEditorWithRef";
|
||||||
|
|
||||||
|
export { CoreEditor, CoreEditorWithRef };
|
109
packages/editor/core/src/ui/menus/menu-items/index.tsx
Normal file
109
packages/editor/core/src/ui/menus/menu-items/index.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { BoldIcon, Heading1, CheckSquare, Heading2, Heading3, QuoteIcon, ImageIcon, TableIcon, ListIcon, ListOrderedIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
|
||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { UploadImage } from "../../../types/upload-image";
|
||||||
|
import { insertImageCommand, insertTableCommand, toggleBlockquote, toggleBold, toggleBulletList, toggleCode, toggleHeadingOne, toggleHeadingThree, toggleHeadingTwo, toggleItalic, toggleOrderedList, toggleStrike, toggleTaskList, toggleUnderline, } from "../../../lib/editor-commands";
|
||||||
|
|
||||||
|
export interface EditorMenuItem {
|
||||||
|
name: string;
|
||||||
|
isActive: () => boolean;
|
||||||
|
command: () => void;
|
||||||
|
icon: typeof BoldIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "H1",
|
||||||
|
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||||
|
command: () => toggleHeadingOne(editor),
|
||||||
|
icon: Heading1,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "H2",
|
||||||
|
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||||
|
command: () => toggleHeadingTwo(editor),
|
||||||
|
icon: Heading2,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "H3",
|
||||||
|
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||||
|
command: () => toggleHeadingThree(editor),
|
||||||
|
icon: Heading3,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const BoldItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "bold",
|
||||||
|
isActive: () => editor?.isActive("bold"),
|
||||||
|
command: () => toggleBold(editor),
|
||||||
|
icon: BoldIcon,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "italic",
|
||||||
|
isActive: () => editor?.isActive("italic"),
|
||||||
|
command: () => toggleItalic(editor),
|
||||||
|
icon: ItalicIcon,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "underline",
|
||||||
|
isActive: () => editor?.isActive("underline"),
|
||||||
|
command: () => toggleUnderline(editor),
|
||||||
|
icon: UnderlineIcon,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "strike",
|
||||||
|
isActive: () => editor?.isActive("strike"),
|
||||||
|
command: () => toggleStrike(editor),
|
||||||
|
icon: StrikethroughIcon,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const CodeItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "code",
|
||||||
|
isActive: () => editor?.isActive("code"),
|
||||||
|
command: () => toggleCode(editor),
|
||||||
|
icon: CodeIcon,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "bullet-list",
|
||||||
|
isActive: () => editor?.isActive("bulletList"),
|
||||||
|
command: () => toggleBulletList(editor),
|
||||||
|
icon: ListIcon,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "To-do List",
|
||||||
|
isActive: () => editor.isActive("taskItem"),
|
||||||
|
command: () => toggleTaskList(editor),
|
||||||
|
icon: CheckSquare,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "ordered-list",
|
||||||
|
isActive: () => editor?.isActive("orderedList"),
|
||||||
|
command: () => toggleOrderedList(editor),
|
||||||
|
icon: ListOrderedIcon
|
||||||
|
})
|
||||||
|
|
||||||
|
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "quote",
|
||||||
|
isActive: () => editor?.isActive("quote"),
|
||||||
|
command: () => toggleBlockquote(editor),
|
||||||
|
icon: QuoteIcon
|
||||||
|
})
|
||||||
|
|
||||||
|
export const TableItem = (editor: Editor): EditorMenuItem => ({
|
||||||
|
name: "quote",
|
||||||
|
isActive: () => editor?.isActive("table"),
|
||||||
|
command: () => insertTableCommand(editor),
|
||||||
|
icon: TableIcon
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ImageItem = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorMenuItem => ({
|
||||||
|
name: "image",
|
||||||
|
isActive: () => editor?.isActive("image"),
|
||||||
|
command: () => insertImageCommand(editor, uploadFile, setIsSubmitting),
|
||||||
|
icon: ImageIcon,
|
||||||
|
})
|
@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Rows, Columns, ToggleRight } from "lucide-react";
|
import { Rows, Columns, ToggleRight } from "lucide-react";
|
||||||
import { cn } from "../utils";
|
|
||||||
import { Tooltip } from "components/ui";
|
|
||||||
import InsertLeftTableIcon from "./InsertLeftTableIcon";
|
import InsertLeftTableIcon from "./InsertLeftTableIcon";
|
||||||
import InsertRightTableIcon from "./InsertRightTableIcon";
|
import InsertRightTableIcon from "./InsertRightTableIcon";
|
||||||
import InsertTopTableIcon from "./InsertTopTableIcon";
|
import InsertTopTableIcon from "./InsertTopTableIcon";
|
||||||
import InsertBottomTableIcon from "./InsertBottomTableIcon";
|
import InsertBottomTableIcon from "./InsertBottomTableIcon";
|
||||||
|
import { cn, findTableAncestor } from "../../../lib/utils";
|
||||||
|
import { Tooltip } from "./tooltip";
|
||||||
|
|
||||||
interface TableMenuItem {
|
interface TableMenuItem {
|
||||||
command: () => void;
|
command: () => void;
|
||||||
@ -14,12 +14,7 @@ interface TableMenuItem {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
|
|
||||||
while (node !== null && node.nodeName !== "TABLE") {
|
|
||||||
node = node.parentNode;
|
|
||||||
}
|
|
||||||
return node as HTMLTableElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TableMenu = ({ editor }: { editor: any }) => {
|
export const TableMenu = ({ editor }: { editor: any }) => {
|
||||||
const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 });
|
const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 });
|
77
packages/editor/core/src/ui/menus/table-menu/tooltip.tsx
Normal file
77
packages/editor/core/src/ui/menus/table-menu/tooltip.tsx
Normal file
@ -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 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -1,6 +1,6 @@
|
|||||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||||
import fileService from "services/file.service";
|
import { DeleteImage } from "../../types/delete-image";
|
||||||
|
|
||||||
const deleteKey = new PluginKey("delete-image");
|
const deleteKey = new PluginKey("delete-image");
|
||||||
const IMAGE_NODE_TYPE = "image";
|
const IMAGE_NODE_TYPE = "image";
|
||||||
@ -12,7 +12,7 @@ interface ImageNode extends ProseMirrorNode {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrackImageDeletionPlugin = (): Plugin =>
|
const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
|
||||||
new Plugin({
|
new Plugin({
|
||||||
key: deleteKey,
|
key: deleteKey,
|
||||||
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
||||||
@ -45,7 +45,7 @@ const TrackImageDeletionPlugin = (): Plugin =>
|
|||||||
|
|
||||||
removedImages.forEach(async (node) => {
|
removedImages.forEach(async (node) => {
|
||||||
const src = node.attrs.src;
|
const src = node.attrs.src;
|
||||||
await onNodeDeleted(src);
|
await onNodeDeleted(src, deleteImage);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -55,10 +55,10 @@ const TrackImageDeletionPlugin = (): Plugin =>
|
|||||||
|
|
||||||
export default TrackImageDeletionPlugin;
|
export default TrackImageDeletionPlugin;
|
||||||
|
|
||||||
async function onNodeDeleted(src: string): Promise<void> {
|
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||||
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
|
const resStatus = await deleteImage(assetUrlWithWorkspaceId);
|
||||||
if (resStatus === 204) {
|
if (resStatus === 204) {
|
||||||
console.log("Image deleted successfully");
|
console.log("Image deleted successfully");
|
||||||
}
|
}
|
@ -1,6 +1,6 @@
|
|||||||
|
import { UploadImage } from "../../types/upload-image";
|
||||||
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||||
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
|
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
|
||||||
import fileService from "services/file.service";
|
|
||||||
|
|
||||||
const uploadKey = new PluginKey("upload-image");
|
const uploadKey = new PluginKey("upload-image");
|
||||||
|
|
||||||
@ -57,7 +57,7 @@ export async function startImageUpload(
|
|||||||
file: File,
|
file: File,
|
||||||
view: EditorView,
|
view: EditorView,
|
||||||
pos: number,
|
pos: number,
|
||||||
workspaceSlug: string,
|
uploadFile: UploadImage,
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||||
) {
|
) {
|
||||||
if (!file.type.includes("image/")) {
|
if (!file.type.includes("image/")) {
|
||||||
@ -82,11 +82,8 @@ export async function startImageUpload(
|
|||||||
view.dispatch(tr);
|
view.dispatch(tr);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!workspaceSlug) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsSubmitting?.("submitting");
|
setIsSubmitting?.("submitting");
|
||||||
const src = await UploadImageHandler(file, workspaceSlug);
|
const src = await UploadImageHandler(file, uploadFile);
|
||||||
const { schema } = view.state;
|
const { schema } = view.state;
|
||||||
pos = findPlaceholder(view.state, id);
|
pos = findPlaceholder(view.state, id);
|
||||||
|
|
||||||
@ -100,28 +97,30 @@ export async function startImageUpload(
|
|||||||
view.dispatch(transaction);
|
view.dispatch(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadImageHandler = (file: File, workspaceSlug: string): Promise<string> => {
|
const UploadImageHandler = (file: File,
|
||||||
if (!workspaceSlug) {
|
uploadFile: UploadImage
|
||||||
return Promise.reject("Workspace slug is missing");
|
): Promise<string> => {
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("asset", file);
|
|
||||||
formData.append("attributes", JSON.stringify({}));
|
|
||||||
|
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
const imageUrl = await fileService
|
try {
|
||||||
.uploadFile(workspaceSlug, formData)
|
const imageUrl = await uploadFile(file)
|
||||||
.then((response) => response.asset);
|
|
||||||
|
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.src = imageUrl;
|
image.src = imageUrl;
|
||||||
image.onload = () => {
|
image.onload = () => {
|
||||||
resolve(imageUrl);
|
resolve(imageUrl);
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.log(error.message);
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
if (error instanceof Error) {
|
||||||
|
console.log(error.message);
|
||||||
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
@ -1,9 +1,10 @@
|
|||||||
import { EditorProps } from "@tiptap/pm/view";
|
import { EditorProps } from "@tiptap/pm/view";
|
||||||
|
import { findTableAncestor } from "../lib/utils";
|
||||||
import { startImageUpload } from "./plugins/upload-image";
|
import { startImageUpload } from "./plugins/upload-image";
|
||||||
import { findTableAncestor } from "./table-menu";
|
import { UploadImage } from "../types/upload-image";
|
||||||
|
|
||||||
export function TiptapEditorProps(
|
export function CoreEditorProps(
|
||||||
workspaceSlug: string,
|
uploadFile: UploadImage,
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||||
): EditorProps {
|
): EditorProps {
|
||||||
return {
|
return {
|
||||||
@ -35,7 +36,7 @@ export function TiptapEditorProps(
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const file = event.clipboardData.files[0];
|
const file = event.clipboardData.files[0];
|
||||||
const pos = view.state.selection.from;
|
const pos = view.state.selection.from;
|
||||||
startImageUpload(file, view, pos, workspaceSlug, setIsSubmitting);
|
startImageUpload(file, view, pos, uploadFile, setIsSubmitting);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -57,9 +58,8 @@ export function TiptapEditorProps(
|
|||||||
left: event.clientX,
|
left: event.clientX,
|
||||||
top: event.clientY,
|
top: event.clientY,
|
||||||
});
|
});
|
||||||
// here we deduct 1 from the pos or else the image will create an extra node
|
|
||||||
if (coordinates) {
|
if (coordinates) {
|
||||||
startImageUpload(file, view, coordinates.pos - 1, workspaceSlug, setIsSubmitting);
|
startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
92
packages/editor/core/src/ui/read-only/extensions.tsx
Normal file
92
packages/editor/core/src/ui/read-only/extensions.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
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 { CustomTableCell } from "../extensions/table/table-cell";
|
||||||
|
import { Table } from "../extensions/table";
|
||||||
|
import { TableHeader } from "../extensions/table/table-header";
|
||||||
|
import { TableRow } from "@tiptap/extension-table-row";
|
||||||
|
|
||||||
|
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
|
||||||
|
import { isValidHttpUrl } from "../../lib/utils";
|
||||||
|
|
||||||
|
export const CoreReadOnlyEditorExtensions = [
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ReadOnlyImageExtension.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,
|
||||||
|
}),
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
CustomTableCell,
|
||||||
|
TableRow,
|
||||||
|
];
|
8
packages/editor/core/src/ui/read-only/props.tsx
Normal file
8
packages/editor/core/src/ui/read-only/props.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { EditorProps } from "@tiptap/pm/view";
|
||||||
|
|
||||||
|
export const CoreReadOnlyEditorProps: EditorProps =
|
||||||
|
{
|
||||||
|
attributes: {
|
||||||
|
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
|
||||||
|
},
|
||||||
|
};
|
6
packages/editor/core/tailwind.config.js
Normal file
6
packages/editor/core/tailwind.config.js
Normal 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,
|
||||||
|
};
|
12
packages/editor/core/tsconfig.json
Normal file
12
packages/editor/core/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "tsconfig/react.json",
|
||||||
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"index.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
11
packages/editor/core/tsup.config.ts
Normal file
11
packages/editor/core/tsup.config.ts
Normal 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,
|
||||||
|
}));
|
97
packages/editor/lite-text-editor/Readme.md
Normal file
97
packages/editor/lite-text-editor/Readme.md
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
# @plane/lite-text-editor
|
||||||
|
|
||||||
|
## 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 Editor’s 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"
|
||||||
|
/>
|
||||||
|
```
|
63
packages/editor/lite-text-editor/package.json
Normal file
63
packages/editor/lite-text-editor/package.json
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "@plane/lite-text-editor",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Package that powers Plane's Comment Editor",
|
||||||
|
"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": {
|
||||||
|
"@plane/editor-core": "*",
|
||||||
|
"@tiptap/extension-list-item": "^2.1.11",
|
||||||
|
"@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",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
9
packages/editor/lite-text-editor/postcss.config.js
Normal file
9
packages/editor/lite-text-editor/postcss.config.js
Normal 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: {},
|
||||||
|
},
|
||||||
|
};
|
2
packages/editor/lite-text-editor/src/index.ts
Normal file
2
packages/editor/lite-text-editor/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
|
||||||
|
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
|
@ -0,0 +1,9 @@
|
|||||||
|
import ListItem from '@tiptap/extension-list-item'
|
||||||
|
|
||||||
|
export const CustomListItem = ListItem.extend({
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
'Shift-Enter': () => this.editor.chain().focus().splitListItem('listItem').run(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
@ -0,0 +1,16 @@
|
|||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
|
||||||
|
export const EnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({
|
||||||
|
name: 'enterKey',
|
||||||
|
|
||||||
|
addKeyboardShortcuts() {
|
||||||
|
return {
|
||||||
|
'Enter': () => {
|
||||||
|
if (onEnterKeyPress) {
|
||||||
|
onEnterKeyPress();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
@ -0,0 +1,7 @@
|
|||||||
|
import { CustomListItem } from "./custom-list-extension";
|
||||||
|
import { EnterKeyExtension } from "./enter-key-extension";
|
||||||
|
|
||||||
|
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
|
||||||
|
CustomListItem,
|
||||||
|
EnterKeyExtension(onEnterKeyPress),
|
||||||
|
];
|
95
packages/editor/lite-text-editor/src/ui/index.tsx
Normal file
95
packages/editor/lite-text-editor/src/ui/index.tsx
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
"use client"
|
||||||
|
import * as React from 'react';
|
||||||
|
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useEditor } from '@plane/editor-core';
|
||||||
|
import { FixedMenu } from './menus/fixed-menu';
|
||||||
|
import { LiteTextEditorExtensions } from './extensions';
|
||||||
|
|
||||||
|
export type UploadImage = (file: File) => Promise<string>;
|
||||||
|
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||||
|
|
||||||
|
interface ILiteTextEditor {
|
||||||
|
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;
|
||||||
|
forwardedRef?: any;
|
||||||
|
debouncedUpdatesEnabled?: boolean;
|
||||||
|
commentAccessSpecifier?: {
|
||||||
|
accessValue: string,
|
||||||
|
onAccessChange: (accessKey: string) => void,
|
||||||
|
showAccessSpecifier: boolean,
|
||||||
|
commentAccess: {
|
||||||
|
icon: string;
|
||||||
|
key: string;
|
||||||
|
label: "Private" | "Public";
|
||||||
|
}[]
|
||||||
|
};
|
||||||
|
onEnterKeyPress?: (e?: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LiteTextEditorProps extends ILiteTextEditor {
|
||||||
|
forwardedRef?: React.Ref<EditorHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorHandle {
|
||||||
|
clearEditor: () => void;
|
||||||
|
setEditorValue: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LiteTextEditor = ({
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
editorContentCustomClassNames,
|
||||||
|
value,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
noBorder,
|
||||||
|
borderOnFocus,
|
||||||
|
customClassName,
|
||||||
|
forwardedRef,
|
||||||
|
commentAccessSpecifier,
|
||||||
|
onEnterKeyPress
|
||||||
|
}: LiteTextEditorProps) => {
|
||||||
|
const editor = useEditor({
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
value,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
forwardedRef,
|
||||||
|
extensions: LiteTextEditorExtensions(onEnterKeyPress),
|
||||||
|
});
|
||||||
|
|
||||||
|
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} />
|
||||||
|
<div className="w-full mt-4">
|
||||||
|
<FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} commentAccessSpecifier={commentAccessSpecifier} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EditorContainer >
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LiteTextEditorWithRef = React.forwardRef<EditorHandle, ILiteTextEditor>((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,170 @@
|
|||||||
|
import { Editor } from "@tiptap/react";
|
||||||
|
import { BoldIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { BoldItem, BulletListItem, cn, CodeItem, ImageItem, ItalicItem, NumberedListItem, QuoteItem, StrikeThroughItem, TableItem, UnderLineItem } from "@plane/editor-core";
|
||||||
|
import { Icon } from "./icon";
|
||||||
|
import { Tooltip } from "../../tooltip";
|
||||||
|
import { UploadImage } from "../..";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
uploadFile: UploadImage;
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||||
|
const basicMarkItems: BubbleMenuItem[] = [
|
||||||
|
BoldItem(props.editor),
|
||||||
|
ItalicItem(props.editor),
|
||||||
|
UnderLineItem(props.editor),
|
||||||
|
StrikeThroughItem(props.editor),
|
||||||
|
];
|
||||||
|
|
||||||
|
const listItems: BubbleMenuItem[] = [
|
||||||
|
BulletListItem(props.editor),
|
||||||
|
NumberedListItem(props.editor),
|
||||||
|
];
|
||||||
|
|
||||||
|
const userActionItems: BubbleMenuItem[] = [
|
||||||
|
QuoteItem(props.editor),
|
||||||
|
CodeItem(props.editor),
|
||||||
|
];
|
||||||
|
|
||||||
|
const complexItems: BubbleMenuItem[] = [
|
||||||
|
TableItem(props.editor),
|
||||||
|
ImageItem(props.editor, props.uploadFile, props.setIsSubmitting),
|
||||||
|
];
|
||||||
|
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{props.commentAccessSpecifier && (<div className="flex border border-custom-border-300 mt-0 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-basicMarkItems-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 ${props.commentAccessSpecifier?.accessValue === access.key
|
||||||
|
? "!text-custom-text-100"
|
||||||
|
: "!text-custom-text-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</div>)}
|
||||||
|
<div className="flex">
|
||||||
|
{basicMarkItems.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 className="flex">
|
||||||
|
{listItems.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 className="flex">
|
||||||
|
{userActionItems.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 className="flex">
|
||||||
|
{complexItems.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>
|
||||||
|
);
|
||||||
|
};
|
54
packages/editor/lite-text-editor/src/ui/read-only/index.tsx
Normal file
54
packages/editor/lite-text-editor/src/ui/read-only/index.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"use client"
|
||||||
|
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from '@plane/editor-core';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface ICoreReadOnlyEditor {
|
||||||
|
value: string;
|
||||||
|
editorContentCustomClassNames?: string;
|
||||||
|
noBorder?: boolean;
|
||||||
|
borderOnFocus?: boolean;
|
||||||
|
customClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorCoreProps extends ICoreReadOnlyEditor {
|
||||||
|
forwardedRef?: React.Ref<EditorHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorHandle {
|
||||||
|
clearEditor: () => void;
|
||||||
|
setEditorValue: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LiteReadOnlyEditor = ({
|
||||||
|
editorContentCustomClassNames,
|
||||||
|
noBorder,
|
||||||
|
borderOnFocus,
|
||||||
|
customClassName,
|
||||||
|
value,
|
||||||
|
forwardedRef,
|
||||||
|
}: EditorCoreProps) => {
|
||||||
|
const editor = useReadOnlyEditor({
|
||||||
|
value,
|
||||||
|
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} />
|
||||||
|
</div>
|
||||||
|
</EditorContainer >
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LiteReadOnlyEditorWithRef = React.forwardRef<EditorHandle, ICoreReadOnlyEditor>((props, ref) => (
|
||||||
|
<LiteReadOnlyEditor {...props} forwardedRef={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
LiteReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef";
|
||||||
|
|
||||||
|
export { LiteReadOnlyEditor , LiteReadOnlyEditorWithRef };
|
77
packages/editor/lite-text-editor/src/ui/tooltip.tsx
Normal file
77
packages/editor/lite-text-editor/src/ui/tooltip.tsx
Normal file
@ -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 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
6
packages/editor/lite-text-editor/tailwind.config.js
Normal file
6
packages/editor/lite-text-editor/tailwind.config.js
Normal 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,
|
||||||
|
};
|
12
packages/editor/lite-text-editor/tsconfig.json
Normal file
12
packages/editor/lite-text-editor/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "tsconfig/react.json",
|
||||||
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"index.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
11
packages/editor/lite-text-editor/tsup.config.ts
Normal file
11
packages/editor/lite-text-editor/tsup.config.ts
Normal 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,
|
||||||
|
}));
|
99
packages/editor/rich-text-editor/Readme.md
Normal file
99
packages/editor/rich-text-editor/Readme.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# @plane/rich-text-editor
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `@plane/rich-text-editor` package extends from the `editor-core` package, inheriting its base functionality while adding its own unique features of Slash Commands and many more.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Exported Components**: There are two components exported from the Rich text editor (with and without Ref), you can choose to use the `withRef` instance whenever you want to control the Editor’s state via a side effect of some external action from within the application code.
|
||||||
|
|
||||||
|
`RichTextEditor` & `RichTextEditorWithRef`
|
||||||
|
|
||||||
|
- **Read Only Editor Instances**: We have added a really light weight *Read Only* Editor instance for the Rich editor types (with and without Ref)
|
||||||
|
`RichReadOnlyEditor` &`RichReadOnlyEditorWithRef`
|
||||||
|
|
||||||
|
## RichTextEditor
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
| `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
|
||||||
|
<RichTextEditor
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||||
|
deleteFile={fileService.deleteImage}
|
||||||
|
value={value}
|
||||||
|
debouncedUpdatesEnabled={true}
|
||||||
|
setShouldShowAlert={setShowAlert}
|
||||||
|
setIsSubmitting={setIsSubmitting}
|
||||||
|
customClassName={
|
||||||
|
isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"
|
||||||
|
}
|
||||||
|
noBorder={!isAllowed}
|
||||||
|
onChange={(description: Object, description_html: string) => {
|
||||||
|
setShowAlert(true);
|
||||||
|
setIsSubmitting("submitting");
|
||||||
|
onChange(description_html);
|
||||||
|
// custom stuff you want to do
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Example of how to use the `RichTextEditorWithRef` 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 (<RichTextEditorWithRef
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
|
deleteFile={fileService.deleteImage}
|
||||||
|
ref={editorRef}
|
||||||
|
debouncedUpdatesEnabled={false}
|
||||||
|
value={value}
|
||||||
|
customClassName="min-h-[150px]"
|
||||||
|
onChange={(description: Object, description_html: string) => {
|
||||||
|
onChange(description_html);
|
||||||
|
// custom stuff you want to do
|
||||||
|
} } />)
|
||||||
|
```
|
||||||
|
|
||||||
|
## RichReadOnlyEditor
|
||||||
|
|
||||||
|
| 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
|
||||||
|
<RichReadOnlyEditor
|
||||||
|
value={issueDetails.description_html}
|
||||||
|
customClassName="p-3 min-h-[50px] shadow-sm" />
|
||||||
|
```
|
61
packages/editor/rich-text-editor/package.json
Normal file
61
packages/editor/rich-text-editor/package.json
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "@plane/rich-text-editor",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Rich Text Editor that powers Plane",
|
||||||
|
"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",
|
||||||
|
"@tiptap/core": "^2.1.11"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@plane/editor-core": "*",
|
||||||
|
"@tiptap/extension-code-block-lowlight": "^2.1.11",
|
||||||
|
"@tiptap/extension-horizontal-rule": "^2.1.11",
|
||||||
|
"@tiptap/extension-placeholder": "^2.1.11",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"@tiptap/suggestion": "^2.1.7",
|
||||||
|
"clsx": "^1.2.1",
|
||||||
|
"highlight.js": "^11.8.0",
|
||||||
|
"lowlight": "^3.0.0",
|
||||||
|
"lucide-react": "^0.244.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "18.15.3",
|
||||||
|
"@types/react": "^18.2.5",
|
||||||
|
"@types/react-dom": "18.0.11",
|
||||||
|
"eslint": "^7.32.0",
|
||||||
|
"postcss": "^8.4.29",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"tailwind-config-custom": "*",
|
||||||
|
"tsconfig": "*",
|
||||||
|
"tsup": "^7.2.0",
|
||||||
|
"typescript": "4.9.5"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"editor",
|
||||||
|
"rich-text",
|
||||||
|
"markdown",
|
||||||
|
"nextjs",
|
||||||
|
"react"
|
||||||
|
]
|
||||||
|
}
|
9
packages/editor/rich-text-editor/postcss.config.js
Normal file
9
packages/editor/rich-text-editor/postcss.config.js
Normal 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: {},
|
||||||
|
},
|
||||||
|
};
|
4
packages/editor/rich-text-editor/src/index.ts
Normal file
4
packages/editor/rich-text-editor/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import "./styles/github-dark.css";
|
||||||
|
|
||||||
|
export { RichTextEditor, RichTextEditorWithRef } from "./ui";
|
||||||
|
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only";
|
@ -0,0 +1,2 @@
|
|||||||
|
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}
|
||||||
|
.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
|
59
packages/editor/rich-text-editor/src/ui/extensions/index.tsx
Normal file
59
packages/editor/rich-text-editor/src/ui/extensions/index.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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 ts from "highlight.js/lib/languages/typescript";
|
||||||
|
|
||||||
|
import SlashCommand from "./slash-command";
|
||||||
|
import { UploadImage } from "../";
|
||||||
|
|
||||||
|
const lowlight = createLowlight(common)
|
||||||
|
lowlight.register("ts", ts);
|
||||||
|
|
||||||
|
export const RichTextEditorExtensions = (
|
||||||
|
uploadFile: UploadImage,
|
||||||
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||||
|
) => [
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
];
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
||||||
import { Editor, Range, Extension } from "@tiptap/core";
|
import { Editor, Range, Extension } from "@tiptap/core";
|
||||||
import Suggestion from "@tiptap/suggestion";
|
import Suggestion from "@tiptap/suggestion";
|
||||||
import { ReactRenderer } from "@tiptap/react";
|
import { ReactRenderer } from "@tiptap/react";
|
||||||
@ -17,8 +17,8 @@ import {
|
|||||||
ImageIcon,
|
ImageIcon,
|
||||||
Table,
|
Table,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { startImageUpload } from "../plugins/upload-image";
|
import { UploadImage } from "../";
|
||||||
import { cn } from "../utils";
|
import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core";
|
||||||
|
|
||||||
interface CommandItemProps {
|
interface CommandItemProps {
|
||||||
title: string;
|
title: string;
|
||||||
@ -58,7 +58,7 @@ const Command = Extension.create({
|
|||||||
|
|
||||||
const getSuggestionItems =
|
const getSuggestionItems =
|
||||||
(
|
(
|
||||||
workspaceSlug: string,
|
uploadFile: UploadImage,
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||||
) =>
|
) =>
|
||||||
({ query }: { query: string }) =>
|
({ query }: { query: string }) =>
|
||||||
@ -78,7 +78,7 @@ const getSuggestionItems =
|
|||||||
searchTerms: ["title", "big", "large"],
|
searchTerms: ["title", "big", "large"],
|
||||||
icon: <Heading1 size={18} />,
|
icon: <Heading1 size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
toggleHeadingOne(editor, range);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -87,7 +87,7 @@ const getSuggestionItems =
|
|||||||
searchTerms: ["subtitle", "medium"],
|
searchTerms: ["subtitle", "medium"],
|
||||||
icon: <Heading2 size={18} />,
|
icon: <Heading2 size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
toggleHeadingTwo(editor, range);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -96,7 +96,7 @@ const getSuggestionItems =
|
|||||||
searchTerms: ["subtitle", "small"],
|
searchTerms: ["subtitle", "small"],
|
||||||
icon: <Heading3 size={18} />,
|
icon: <Heading3 size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
toggleHeadingThree(editor, range);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -105,7 +105,7 @@ const getSuggestionItems =
|
|||||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||||
icon: <CheckSquare size={18} />,
|
icon: <CheckSquare size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
toggleTaskList(editor, range)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -114,7 +114,7 @@ const getSuggestionItems =
|
|||||||
searchTerms: ["unordered", "point"],
|
searchTerms: ["unordered", "point"],
|
||||||
icon: <List size={18} />,
|
icon: <List size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
toggleBulletList(editor, range);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -132,12 +132,7 @@ const getSuggestionItems =
|
|||||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||||
icon: <Table size={18} />,
|
icon: <Table size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor
|
insertTableCommand(editor, range);
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
|
||||||
.run();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -146,7 +141,7 @@ const getSuggestionItems =
|
|||||||
searchTerms: ["ordered"],
|
searchTerms: ["ordered"],
|
||||||
icon: <ListOrdered size={18} />,
|
icon: <ListOrdered size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
toggleOrderedList(editor, range)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -155,13 +150,7 @@ const getSuggestionItems =
|
|||||||
searchTerms: ["blockquote"],
|
searchTerms: ["blockquote"],
|
||||||
icon: <TextQuote size={18} />,
|
icon: <TextQuote size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) =>
|
command: ({ editor, range }: CommandProps) =>
|
||||||
editor
|
toggleBlockquote(editor, range)
|
||||||
.chain()
|
|
||||||
.focus()
|
|
||||||
.deleteRange(range)
|
|
||||||
.toggleNode("paragraph", "paragraph")
|
|
||||||
.toggleBlockquote()
|
|
||||||
.run(),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Code",
|
title: "Code",
|
||||||
@ -177,19 +166,7 @@ const getSuggestionItems =
|
|||||||
searchTerms: ["photo", "picture", "media"],
|
searchTerms: ["photo", "picture", "media"],
|
||||||
icon: <ImageIcon size={18} />,
|
icon: <ImageIcon size={18} />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
command: ({ editor, range }: CommandProps) => {
|
||||||
editor.chain().focus().deleteRange(range).run();
|
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
||||||
// 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, workspaceSlug, setIsSubmitting);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
input.click();
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
].filter((item) => {
|
].filter((item) => {
|
||||||
@ -312,13 +289,14 @@ const renderItems = () => {
|
|||||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||||
component = new ReactRenderer(CommandList, {
|
component = new ReactRenderer(CommandList, {
|
||||||
props,
|
props,
|
||||||
|
// @ts-ignore
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
popup = tippy("body", {
|
popup = tippy("body", {
|
||||||
getReferenceClientRect: props.clientRect,
|
getReferenceClientRect: props.clientRect,
|
||||||
appendTo: () => document.querySelector("#tiptap-container"),
|
appendTo: () => document.querySelector("#editor-container"),
|
||||||
content: component.element,
|
content: component.element,
|
||||||
showOnCreate: true,
|
showOnCreate: true,
|
||||||
interactive: true,
|
interactive: true,
|
||||||
@ -352,12 +330,12 @@ const renderItems = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SlashCommand = (
|
export const SlashCommand = (
|
||||||
workspaceSlug: string,
|
uploadFile: UploadImage,
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||||
) =>
|
) =>
|
||||||
Command.configure({
|
Command.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: getSuggestionItems(workspaceSlug, setIsSubmitting),
|
items: getSuggestionItems(uploadFile, setIsSubmitting),
|
||||||
render: renderItems,
|
render: renderItems,
|
||||||
},
|
},
|
||||||
});
|
});
|
80
packages/editor/rich-text-editor/src/ui/index.tsx
Normal file
80
packages/editor/rich-text-editor/src/ui/index.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"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 IRichTextEditor {
|
||||||
|
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;
|
||||||
|
forwardedRef?: any;
|
||||||
|
debouncedUpdatesEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RichTextEditorProps extends IRichTextEditor {
|
||||||
|
forwardedRef?: React.Ref<EditorHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorHandle {
|
||||||
|
clearEditor: () => void;
|
||||||
|
setEditorValue: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RichTextEditor = ({
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
setIsSubmitting,
|
||||||
|
setShouldShowAlert,
|
||||||
|
editorContentCustomClassNames,
|
||||||
|
value,
|
||||||
|
uploadFile,
|
||||||
|
deleteFile,
|
||||||
|
noBorder,
|
||||||
|
borderOnFocus,
|
||||||
|
customClassName,
|
||||||
|
forwardedRef,
|
||||||
|
}: RichTextEditorProps) => {
|
||||||
|
const editor = useEditor({
|
||||||
|
onChange,
|
||||||
|
debouncedUpdatesEnabled,
|
||||||
|
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, IRichTextEditor>((props, ref) => (
|
||||||
|
<RichTextEditor {...props} forwardedRef={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
|
||||||
|
|
||||||
|
export { RichTextEditor, RichTextEditorWithRef};
|
@ -1,10 +1,10 @@
|
|||||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
|
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
|
||||||
import { FC, useState } from "react";
|
import { FC, useState } from "react";
|
||||||
import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
|
import { BoldIcon } from "lucide-react";
|
||||||
|
|
||||||
import { NodeSelector } from "./node-selector";
|
import { NodeSelector } from "./node-selector";
|
||||||
import { LinkSelector } from "./link-selector";
|
import { LinkSelector } from "./link-selector";
|
||||||
import { cn } from "../utils";
|
import { BoldItem, cn, CodeItem, ItalicItem, StrikeThroughItem, UnderLineItem } from "@plane/editor-core";
|
||||||
|
|
||||||
export interface BubbleMenuItem {
|
export interface BubbleMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@ -17,36 +17,11 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
|||||||
|
|
||||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||||
const items: BubbleMenuItem[] = [
|
const items: BubbleMenuItem[] = [
|
||||||
{
|
BoldItem(props.editor),
|
||||||
name: "bold",
|
ItalicItem(props.editor),
|
||||||
isActive: () => props.editor?.isActive("bold"),
|
UnderLineItem(props.editor),
|
||||||
command: () => props.editor?.chain().focus().toggleBold().run(),
|
StrikeThroughItem(props.editor),
|
||||||
icon: BoldIcon,
|
CodeItem(props.editor),
|
||||||
},
|
|
||||||
{
|
|
||||||
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 = {
|
const bubbleMenuProps: EditorBubbleMenuProps = {
|
@ -1,8 +1,8 @@
|
|||||||
import { Editor } from "@tiptap/core";
|
import { Editor } from "@tiptap/core";
|
||||||
import { Check, Trash } from "lucide-react";
|
import { Check, Trash } from "lucide-react";
|
||||||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
||||||
import { cn } from "../utils";
|
import { cn, isValidHttpUrl, setLinkEditor, unsetLinkEditor, } from "@plane/editor-core";
|
||||||
import isValidHttpUrl from "./utils/link-validator";
|
|
||||||
interface LinkSelectorProps {
|
interface LinkSelectorProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -16,7 +16,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
|
|||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
const url = input?.value;
|
const url = input?.value;
|
||||||
if (url && isValidHttpUrl(url)) {
|
if (url && isValidHttpUrl(url)) {
|
||||||
editor.chain().focus().setLink({ href: url }).run();
|
setLinkEditor(editor, url);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}, [editor, inputRef, setIsOpen]);
|
}, [editor, inputRef, setIsOpen]);
|
||||||
@ -68,7 +68,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
|
|||||||
type="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"
|
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editor.chain().focus().unsetLink().run();
|
unsetLinkEditor(editor);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
@ -1,20 +1,13 @@
|
|||||||
import { Editor } from "@tiptap/core";
|
import { BulletListItem, cn, CodeItem, HeadingOneItem, HeadingThreeItem, HeadingTwoItem, NumberedListItem, QuoteItem, TodoListItem } from "@plane/editor-core";
|
||||||
|
import { Editor } from "@tiptap/react";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Heading1,
|
|
||||||
Heading2,
|
|
||||||
Heading3,
|
|
||||||
TextQuote,
|
|
||||||
ListOrdered,
|
|
||||||
TextIcon,
|
TextIcon,
|
||||||
Code,
|
|
||||||
CheckSquare,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Dispatch, FC, SetStateAction } from "react";
|
import { Dispatch, FC, SetStateAction } from "react";
|
||||||
|
|
||||||
import { BubbleMenuItem } from ".";
|
import { BubbleMenuItem } from ".";
|
||||||
import { cn } from "../utils";
|
|
||||||
|
|
||||||
interface NodeSelectorProps {
|
interface NodeSelectorProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@ -33,55 +26,14 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
|
|||||||
!editor.isActive("bulletList") &&
|
!editor.isActive("bulletList") &&
|
||||||
!editor.isActive("orderedList"),
|
!editor.isActive("orderedList"),
|
||||||
},
|
},
|
||||||
{
|
HeadingOneItem(editor),
|
||||||
name: "H1",
|
HeadingTwoItem(editor),
|
||||||
icon: Heading1,
|
HeadingThreeItem(editor),
|
||||||
command: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
TodoListItem(editor),
|
||||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
BulletListItem(editor),
|
||||||
},
|
NumberedListItem(editor),
|
||||||
{
|
QuoteItem(editor),
|
||||||
name: "H2",
|
CodeItem(editor),
|
||||||
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() ?? {
|
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
54
packages/editor/rich-text-editor/src/ui/read-only/index.tsx
Normal file
54
packages/editor/rich-text-editor/src/ui/read-only/index.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
"use client"
|
||||||
|
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from '@plane/editor-core';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
interface IRichTextReadOnlyEditor {
|
||||||
|
value: string;
|
||||||
|
editorContentCustomClassNames?: string;
|
||||||
|
noBorder?: boolean;
|
||||||
|
borderOnFocus?: boolean;
|
||||||
|
customClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {
|
||||||
|
forwardedRef?: React.Ref<EditorHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorHandle {
|
||||||
|
clearEditor: () => void;
|
||||||
|
setEditorValue: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RichReadOnlyEditor = ({
|
||||||
|
editorContentCustomClassNames,
|
||||||
|
noBorder,
|
||||||
|
borderOnFocus,
|
||||||
|
customClassName,
|
||||||
|
value,
|
||||||
|
forwardedRef,
|
||||||
|
}: RichTextReadOnlyEditorProps) => {
|
||||||
|
const editor = useReadOnlyEditor({
|
||||||
|
value,
|
||||||
|
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} />
|
||||||
|
</div>
|
||||||
|
</EditorContainer >
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const RichReadOnlyEditorWithRef = React.forwardRef<EditorHandle, IRichTextReadOnlyEditor>((props, ref) => (
|
||||||
|
<RichReadOnlyEditor {...props} forwardedRef={ref} />
|
||||||
|
));
|
||||||
|
|
||||||
|
RichReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
|
||||||
|
|
||||||
|
export { RichReadOnlyEditor , RichReadOnlyEditorWithRef };
|
6
packages/editor/rich-text-editor/tailwind.config.js
Normal file
6
packages/editor/rich-text-editor/tailwind.config.js
Normal 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,
|
||||||
|
};
|
12
packages/editor/rich-text-editor/tsconfig.json
Normal file
12
packages/editor/rich-text-editor/tsconfig.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "tsconfig/react.json",
|
||||||
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"index.d.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"dist",
|
||||||
|
"build",
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
11
packages/editor/rich-text-editor/tsup.config.ts
Normal file
11
packages/editor/rich-text-editor/tsup.config.ts
Normal 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,
|
||||||
|
}));
|
@ -4,7 +4,12 @@
|
|||||||
"description": "common tailwind configuration across monorepo",
|
"description": "common tailwind configuration across monorepo",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"autoprefixer": "^10.4.14",
|
||||||
|
"postcss": "^8.4.21",
|
||||||
|
"prettier": "^2.8.8",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||||
|
"tailwindcss": "^3.2.7",
|
||||||
|
"tailwindcss-animate": "^1.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,19 @@
|
|||||||
const convertToRGB = (variableName) => `rgba(var(${variableName}))`;
|
const convertToRGB = (variableName) => `rgba(var(${variableName}))`;
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: "class",
|
darkMode: "class",
|
||||||
content: [
|
content: {
|
||||||
|
relative: true,
|
||||||
|
files: [
|
||||||
"./components/**/*.tsx",
|
"./components/**/*.tsx",
|
||||||
"./constants/**/*.{js,ts,jsx,tsx}",
|
"./constants/**/*.{js,ts,jsx,tsx}",
|
||||||
"./layouts/**/*.tsx",
|
"./layouts/**/*.tsx",
|
||||||
"./pages/**/*.tsx",
|
"./pages/**/*.tsx",
|
||||||
"./ui/**/*.tsx",
|
"./ui/**/*.tsx",
|
||||||
],
|
"../packages/editor/**/*.{js,ts,jsx,tsx}"
|
||||||
|
]
|
||||||
|
},
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
|
@ -16,5 +16,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true
|
"strict": true
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -3,8 +3,8 @@
|
|||||||
"display": "React Library",
|
"display": "React Library",
|
||||||
"extends": "./base.json",
|
"extends": "./base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"jsx": "react",
|
"jsx": "react-jsx",
|
||||||
"lib": ["ES2015"],
|
"lib": ["ES2015", "DOM"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"target": "es6"
|
"target": "es6"
|
||||||
}
|
}
|
@ -11,7 +11,9 @@ import { SecondaryButton } from "components/ui";
|
|||||||
// types
|
// types
|
||||||
import { Comment } from "types/issue";
|
import { Comment } from "types/issue";
|
||||||
// components
|
// components
|
||||||
import { TipTapEditor } from "components/tiptap";
|
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
||||||
|
// service
|
||||||
|
import fileService from "services/file.service";
|
||||||
|
|
||||||
const defaultValues: Partial<Comment> = {
|
const defaultValues: Partial<Comment> = {
|
||||||
comment_html: "",
|
comment_html: "",
|
||||||
@ -69,8 +71,14 @@ 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 } }) => (
|
||||||
<TipTapEditor
|
<LiteTextEditorWithRef
|
||||||
workspaceSlug={workspace_slug as string}
|
onEnterKeyPress={(e) => {
|
||||||
|
userStore.requiredLogin(() => {
|
||||||
|
handleSubmit(onSubmit)(e);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspace_slug as string)}
|
||||||
|
deleteFile={fileService.deleteImage}
|
||||||
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,8 @@ 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 { TipTapEditor } from "components/tiptap";
|
import { LiteReadOnlyEditorWithRef, LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
||||||
|
|
||||||
import { CommentReactions } from "components/issues/peek-overview";
|
import { CommentReactions } from "components/issues/peek-overview";
|
||||||
// icons
|
// icons
|
||||||
import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon } from "@heroicons/react/24/outline";
|
import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon } from "@heroicons/react/24/outline";
|
||||||
@ -17,6 +18,8 @@ import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon
|
|||||||
import { timeAgo } from "helpers/date-time.helper";
|
import { timeAgo } from "helpers/date-time.helper";
|
||||||
// types
|
// types
|
||||||
import { Comment } from "types/issue";
|
import { Comment } from "types/issue";
|
||||||
|
import fileService from "services/file.service";
|
||||||
|
// services
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
@ -100,8 +103,10 @@ 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 } }) => (
|
||||||
<TipTapEditor
|
<LiteTextEditorWithRef
|
||||||
workspaceSlug={workspaceSlug as string}
|
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||||
|
deleteFile={fileService.deleteImage}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={value}
|
value={value}
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
@ -131,11 +136,9 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div className={`${isEditing ? "hidden" : ""}`}>
|
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||||
<TipTapEditor
|
<LiteReadOnlyEditorWithRef
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={showEditorRef}
|
ref={showEditorRef}
|
||||||
value={comment.comment_html}
|
value={comment.comment_html}
|
||||||
editable={false}
|
|
||||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||||
/>
|
/>
|
||||||
<CommentReactions commentId={comment.id} projectId={comment.project} />
|
<CommentReactions commentId={comment.id} projectId={comment.project} />
|
||||||
@ -171,8 +174,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
|
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${active ? "bg-custom-background-80" : ""
|
||||||
active ? "bg-custom-background-80" : ""
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
@ -186,8 +188,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
|
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${active ? "bg-custom-background-80" : ""
|
||||||
active ? "bg-custom-background-80" : ""
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import { IssueReactions } from "components/issues/peek-overview";
|
import { IssueReactions } from "components/issues/peek-overview";
|
||||||
import { TipTapEditor } from "components/tiptap";
|
import { RichReadOnlyEditor } from "@plane/rich-text-editor";
|
||||||
import { useRouter } from "next/router";
|
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types/issue";
|
import { IIssue } from "types/issue";
|
||||||
|
|
||||||
@ -8,33 +7,22 @@ type Props = {
|
|||||||
issueDetails: IIssue;
|
issueDetails: IIssue;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => {
|
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => (
|
||||||
const router = useRouter();
|
|
||||||
const { workspace_slug } = router.query;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h6 className="font-medium text-custom-text-200">
|
<h6 className="font-medium text-custom-text-200">
|
||||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
||||||
</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
|
<RichReadOnlyEditor
|
||||||
workspaceSlug={workspace_slug as string}
|
value={!issueDetails.description_html ||
|
||||||
value={
|
|
||||||
!issueDetails.description_html ||
|
|
||||||
issueDetails.description_html === "" ||
|
issueDetails.description_html === "" ||
|
||||||
(typeof issueDetails.description_html === "object" &&
|
(typeof issueDetails.description_html === "object" &&
|
||||||
Object.keys(issueDetails.description_html).length === 0)
|
Object.keys(issueDetails.description_html).length === 0)
|
||||||
? "<p></p>"
|
? "<p></p>"
|
||||||
: issueDetails.description_html
|
: issueDetails.description_html}
|
||||||
}
|
customClassName="p-3 min-h-[50px] shadow-sm" />
|
||||||
customClassName="p-3 min-h-[50px] shadow-sm"
|
|
||||||
debouncedUpdatesEnabled={false}
|
|
||||||
editable={false}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<IssueReactions />
|
<IssueReactions />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
@ -1,121 +0,0 @@
|
|||||||
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 "../utils";
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,92 +0,0 @@
|
|||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import { Check, Trash } from "lucide-react";
|
|
||||||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
|
||||||
import { cn } from "../utils";
|
|
||||||
import isValidHttpUrl from "./utils/link-validator";
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,130 +0,0 @@
|
|||||||
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 ".";
|
|
||||||
import { cn } from "../utils";
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,11 +0,0 @@
|
|||||||
export default function isValidHttpUrl(string: string): boolean {
|
|
||||||
let url;
|
|
||||||
|
|
||||||
try {
|
|
||||||
url = new URL(string);
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.protocol === "http:" || url.protocol === "https:";
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
import Image from "@tiptap/extension-image";
|
|
||||||
import TrackImageDeletionPlugin from "../plugins/delete-image";
|
|
||||||
import UploadImagesPlugin from "../plugins/upload-image";
|
|
||||||
|
|
||||||
const UpdatedImage = Image.extend({
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [UploadImagesPlugin(), TrackImageDeletionPlugin()];
|
|
||||||
},
|
|
||||||
addAttributes() {
|
|
||||||
return {
|
|
||||||
...this.parent?.(),
|
|
||||||
width: {
|
|
||||||
default: "35%",
|
|
||||||
},
|
|
||||||
height: {
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default UpdatedImage;
|
|
@ -1,110 +0,0 @@
|
|||||||
import { useImperativeHandle, useRef, forwardRef, useEffect } from "react";
|
|
||||||
import { useEditor, EditorContent, Editor } from "@tiptap/react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
// components
|
|
||||||
import { EditorBubbleMenu } from "./bubble-menu";
|
|
||||||
import { TiptapExtensions } from "./extensions";
|
|
||||||
import { TiptapEditorProps } from "./props";
|
|
||||||
import { ImageResizer } from "./extensions/image-resize";
|
|
||||||
import { TableMenu } from "./table-menu";
|
|
||||||
|
|
||||||
export interface ITipTapRichTextEditor {
|
|
||||||
value: string;
|
|
||||||
noBorder?: boolean;
|
|
||||||
borderOnFocus?: boolean;
|
|
||||||
customClassName?: string;
|
|
||||||
editorContentCustomClassNames?: string;
|
|
||||||
onChange?: (json: any, html: string) => void;
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
|
||||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
|
||||||
workspaceSlug: string;
|
|
||||||
editable?: boolean;
|
|
||||||
forwardedRef?: any;
|
|
||||||
debouncedUpdatesEnabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Tiptap = (props: ITipTapRichTextEditor) => {
|
|
||||||
const {
|
|
||||||
onChange,
|
|
||||||
debouncedUpdatesEnabled,
|
|
||||||
forwardedRef,
|
|
||||||
editable,
|
|
||||||
setIsSubmitting,
|
|
||||||
setShouldShowAlert,
|
|
||||||
editorContentCustomClassNames,
|
|
||||||
value,
|
|
||||||
noBorder,
|
|
||||||
workspaceSlug,
|
|
||||||
borderOnFocus,
|
|
||||||
customClassName,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const editor = useEditor({
|
|
||||||
editable: editable ?? true,
|
|
||||||
editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
|
|
||||||
extensions: TiptapExtensions(workspaceSlug, setIsSubmitting),
|
|
||||||
content: value,
|
|
||||||
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);
|
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
|
||||||
clearEditor: () => {
|
|
||||||
editorRef.current?.commands.clearContent();
|
|
||||||
},
|
|
||||||
setEditorValue: (content: string) => {
|
|
||||||
editorRef.current?.commands.setContent(content);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
|
|
||||||
setTimeout(async () => {
|
|
||||||
if (onChange) {
|
|
||||||
onChange(editor.getJSON(), editor.getHTML());
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
const editorClassNames = `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;
|
|
||||||
editorRef.current = editor;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id="tiptap-container"
|
|
||||||
onClick={() => {
|
|
||||||
editor?.chain().focus().run();
|
|
||||||
}}
|
|
||||||
className={`tiptap-editor-container cursor-text ${editorClassNames}`}
|
|
||||||
>
|
|
||||||
{editor && <EditorBubbleMenu editor={editor} />}
|
|
||||||
<div className={`${editorContentCustomClassNames}`}>
|
|
||||||
<EditorContent editor={editor} />
|
|
||||||
<TableMenu editor={editor} />
|
|
||||||
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TipTapEditor = forwardRef<ITipTapRichTextEditor, ITipTapRichTextEditor>((props, ref) => (
|
|
||||||
<Tiptap {...props} forwardedRef={ref} />
|
|
||||||
));
|
|
||||||
|
|
||||||
TipTapEditor.displayName = "TipTapEditor";
|
|
||||||
|
|
||||||
export { TipTapEditor };
|
|
@ -1,68 +0,0 @@
|
|||||||
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
|
||||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
|
||||||
import fileService from "services/file.service";
|
|
||||||
|
|
||||||
const deleteKey = new PluginKey("delete-image");
|
|
||||||
const IMAGE_NODE_TYPE = "image";
|
|
||||||
|
|
||||||
interface ImageNode extends ProseMirrorNode {
|
|
||||||
attrs: {
|
|
||||||
src: string;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const TrackImageDeletionPlugin = (): Plugin =>
|
|
||||||
new Plugin({
|
|
||||||
key: deleteKey,
|
|
||||||
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
|
|
||||||
const newImageSources = new Set();
|
|
||||||
newState.doc.descendants((node) => {
|
|
||||||
if (node.type.name === IMAGE_NODE_TYPE) {
|
|
||||||
newImageSources.add(node.attrs.src);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
transactions.forEach((transaction) => {
|
|
||||||
if (!transaction.docChanged) return;
|
|
||||||
|
|
||||||
const removedImages: ImageNode[] = [];
|
|
||||||
|
|
||||||
oldState.doc.descendants((oldNode, oldPos) => {
|
|
||||||
if (oldNode.type.name !== IMAGE_NODE_TYPE) return;
|
|
||||||
if (oldPos < 0 || oldPos > newState.doc.content.size) return;
|
|
||||||
if (!newState.doc.resolve(oldPos).parent) return;
|
|
||||||
|
|
||||||
const newNode = newState.doc.nodeAt(oldPos);
|
|
||||||
|
|
||||||
// Check if the node has been deleted or replaced
|
|
||||||
if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) {
|
|
||||||
if (!newImageSources.has(oldNode.attrs.src)) {
|
|
||||||
removedImages.push(oldNode as ImageNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
removedImages.forEach(async (node) => {
|
|
||||||
const src = node.attrs.src;
|
|
||||||
await onNodeDeleted(src);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default TrackImageDeletionPlugin;
|
|
||||||
|
|
||||||
async function onNodeDeleted(src: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
|
||||||
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
|
|
||||||
if (resStatus === 204) {
|
|
||||||
console.log("Image deleted successfully");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting image: ", error);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,127 +0,0 @@
|
|||||||
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
|
|
||||||
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
|
|
||||||
import fileService from "services/file.service";
|
|
||||||
|
|
||||||
const uploadKey = new PluginKey("upload-image");
|
|
||||||
|
|
||||||
const UploadImagesPlugin = () =>
|
|
||||||
new Plugin({
|
|
||||||
key: uploadKey,
|
|
||||||
state: {
|
|
||||||
init() {
|
|
||||||
return DecorationSet.empty;
|
|
||||||
},
|
|
||||||
apply(tr, set) {
|
|
||||||
set = set.map(tr.mapping, tr.doc);
|
|
||||||
// See if the transaction adds or removes any placeholders
|
|
||||||
const action = tr.getMeta(uploadKey);
|
|
||||||
if (action && action.add) {
|
|
||||||
const { id, pos, src } = action.add;
|
|
||||||
|
|
||||||
const placeholder = document.createElement("div");
|
|
||||||
placeholder.setAttribute("class", "img-placeholder");
|
|
||||||
const image = document.createElement("img");
|
|
||||||
image.setAttribute("class", "opacity-10 rounded-lg border border-custom-border-300");
|
|
||||||
image.src = src;
|
|
||||||
placeholder.appendChild(image);
|
|
||||||
const deco = Decoration.widget(pos + 1, placeholder, {
|
|
||||||
id,
|
|
||||||
});
|
|
||||||
set = set.add(tr.doc, [deco]);
|
|
||||||
} else if (action && action.remove) {
|
|
||||||
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
|
|
||||||
}
|
|
||||||
return set;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
decorations(state) {
|
|
||||||
return this.getState(state);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default UploadImagesPlugin;
|
|
||||||
|
|
||||||
function findPlaceholder(state: EditorState, id: {}) {
|
|
||||||
const decos = uploadKey.getState(state);
|
|
||||||
const found = decos.find(
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
(spec: { id: number | undefined }) => spec.id == id
|
|
||||||
);
|
|
||||||
return found.length ? found[0].from : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startImageUpload(
|
|
||||||
file: File,
|
|
||||||
view: EditorView,
|
|
||||||
pos: number,
|
|
||||||
workspaceSlug: string,
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
|
||||||
) {
|
|
||||||
if (!file.type.includes("image/")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = {};
|
|
||||||
|
|
||||||
const tr = view.state.tr;
|
|
||||||
if (!tr.selection.empty) tr.deleteSelection();
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
reader.onload = () => {
|
|
||||||
tr.setMeta(uploadKey, {
|
|
||||||
add: {
|
|
||||||
id,
|
|
||||||
pos,
|
|
||||||
src: reader.result,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
view.dispatch(tr);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!workspaceSlug) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsSubmitting?.("submitting");
|
|
||||||
const src = await UploadImageHandler(file, workspaceSlug);
|
|
||||||
const { schema } = view.state;
|
|
||||||
pos = findPlaceholder(view.state, id);
|
|
||||||
|
|
||||||
if (pos == null) return;
|
|
||||||
const imageSrc = typeof src === "object" ? reader.result : src;
|
|
||||||
|
|
||||||
const node = schema.nodes.image.create({ src: imageSrc });
|
|
||||||
const transaction = view.state.tr
|
|
||||||
.replaceWith(pos, pos, node)
|
|
||||||
.setMeta(uploadKey, { remove: { id } });
|
|
||||||
view.dispatch(transaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
const UploadImageHandler = (file: File, workspaceSlug: string): Promise<string> => {
|
|
||||||
if (!workspaceSlug) {
|
|
||||||
return Promise.reject("Workspace slug is missing");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("asset", file);
|
|
||||||
formData.append("attributes", JSON.stringify({}));
|
|
||||||
|
|
||||||
return new Promise(async (resolve, reject) => {
|
|
||||||
const imageUrl = await fileService
|
|
||||||
.uploadFile(workspaceSlug, formData)
|
|
||||||
.then((response) => response.asset);
|
|
||||||
|
|
||||||
const image = new Image();
|
|
||||||
image.src = imageUrl;
|
|
||||||
image.onload = () => {
|
|
||||||
resolve(imageUrl);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.log(error);
|
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
};
|
|
@ -1,69 +0,0 @@
|
|||||||
import { EditorProps } from "@tiptap/pm/view";
|
|
||||||
import { startImageUpload } from "./plugins/upload-image";
|
|
||||||
import { findTableAncestor } from "./table-menu";
|
|
||||||
|
|
||||||
export function TiptapEditorProps(
|
|
||||||
workspaceSlug: string,
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
|
||||||
): EditorProps {
|
|
||||||
return {
|
|
||||||
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: (view, event) => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const selection: any = window?.getSelection();
|
|
||||||
if (selection.rangeCount !== 0) {
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
if (findTableAncestor(range.startContainer)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files[0]) {
|
|
||||||
event.preventDefault();
|
|
||||||
const file = event.clipboardData.files[0];
|
|
||||||
const pos = view.state.selection.from;
|
|
||||||
startImageUpload(file, view, pos, workspaceSlug, setIsSubmitting);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
handleDrop: (view, event, _slice, moved) => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
const selection: any = window?.getSelection();
|
|
||||||
if (selection.rangeCount !== 0) {
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
if (findTableAncestor(range.startContainer)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) {
|
|
||||||
event.preventDefault();
|
|
||||||
const file = event.dataTransfer.files[0];
|
|
||||||
const coordinates = view.posAtCoords({
|
|
||||||
left: event.clientX,
|
|
||||||
top: event.clientY,
|
|
||||||
});
|
|
||||||
// here we deduct 1 from the pos or else the image will create an extra node
|
|
||||||
if (coordinates) {
|
|
||||||
startImageUpload(file, view, coordinates.pos - 1, workspaceSlug, setIsSubmitting);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,143 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Rows, Columns, ToggleRight } from "lucide-react";
|
|
||||||
import { cn } from "../utils";
|
|
||||||
import { Tooltip } from "components/ui";
|
|
||||||
import InsertLeftTableIcon from "./InsertLeftTableIcon";
|
|
||||||
import InsertRightTableIcon from "./InsertRightTableIcon";
|
|
||||||
import InsertTopTableIcon from "./InsertTopTableIcon";
|
|
||||||
import InsertBottomTableIcon from "./InsertBottomTableIcon";
|
|
||||||
|
|
||||||
interface TableMenuItem {
|
|
||||||
command: () => void;
|
|
||||||
icon: any;
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const findTableAncestor = (node: Node | null): HTMLTableElement | null => {
|
|
||||||
while (node !== null && node.nodeName !== "TABLE") {
|
|
||||||
node = node.parentNode;
|
|
||||||
}
|
|
||||||
return node as HTMLTableElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TableMenu = ({ editor }: { editor: any }) => {
|
|
||||||
const [tableLocation, setTableLocation] = useState({ bottom: 0, left: 0 });
|
|
||||||
const isOpen = editor?.isActive("table");
|
|
||||||
|
|
||||||
const items: TableMenuItem[] = [
|
|
||||||
{
|
|
||||||
command: () => editor.chain().focus().addColumnBefore().run(),
|
|
||||||
icon: InsertLeftTableIcon,
|
|
||||||
key: "insert-column-left",
|
|
||||||
name: "Insert 1 column left",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: () => editor.chain().focus().addColumnAfter().run(),
|
|
||||||
icon: InsertRightTableIcon,
|
|
||||||
key: "insert-column-right",
|
|
||||||
name: "Insert 1 column right",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: () => editor.chain().focus().addRowBefore().run(),
|
|
||||||
icon: InsertTopTableIcon,
|
|
||||||
key: "insert-row-above",
|
|
||||||
name: "Insert 1 row above",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: () => editor.chain().focus().addRowAfter().run(),
|
|
||||||
icon: InsertBottomTableIcon,
|
|
||||||
key: "insert-row-below",
|
|
||||||
name: "Insert 1 row below",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: () => editor.chain().focus().deleteColumn().run(),
|
|
||||||
icon: Columns,
|
|
||||||
key: "delete-column",
|
|
||||||
name: "Delete column",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: () => editor.chain().focus().deleteRow().run(),
|
|
||||||
icon: Rows,
|
|
||||||
key: "delete-row",
|
|
||||||
name: "Delete row",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
command: () => editor.chain().focus().toggleHeaderRow().run(),
|
|
||||||
icon: ToggleRight,
|
|
||||||
key: "toggle-header-row",
|
|
||||||
name: "Toggle header row",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!window) return;
|
|
||||||
|
|
||||||
const handleWindowClick = () => {
|
|
||||||
const selection: any = window?.getSelection();
|
|
||||||
|
|
||||||
if (selection.rangeCount !== 0) {
|
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
const tableNode = findTableAncestor(range.startContainer);
|
|
||||||
|
|
||||||
let parent = tableNode?.parentElement;
|
|
||||||
|
|
||||||
if (tableNode) {
|
|
||||||
const tableRect = tableNode.getBoundingClientRect();
|
|
||||||
const tableCenter = tableRect.left + tableRect.width / 2;
|
|
||||||
const menuWidth = 45;
|
|
||||||
const menuLeft = tableCenter - menuWidth / 2;
|
|
||||||
const tableBottom = tableRect.bottom;
|
|
||||||
|
|
||||||
setTableLocation({ bottom: tableBottom, left: menuLeft });
|
|
||||||
|
|
||||||
while (parent) {
|
|
||||||
if (!parent.classList.contains("disable-scroll"))
|
|
||||||
parent.classList.add("disable-scroll");
|
|
||||||
parent = parent.parentElement;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const scrollDisabledContainers = document.querySelectorAll(".disable-scroll");
|
|
||||||
|
|
||||||
scrollDisabledContainers.forEach((container) => {
|
|
||||||
container.classList.remove("disable-scroll");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("click", handleWindowClick);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("click", handleWindowClick);
|
|
||||||
};
|
|
||||||
}, [tableLocation, editor]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`fixed left-1/2 transform -translate-x-1/2 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-sm p-1 ${
|
|
||||||
isOpen ? "block" : "hidden"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
bottom: `calc(100vh - ${tableLocation.bottom + 45}px)`,
|
|
||||||
left: `${tableLocation.left}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<Tooltip key={index} tooltipContent={item.name}>
|
|
||||||
<button
|
|
||||||
onClick={item.command}
|
|
||||||
className="p-1.5 text-custom-text-200 hover:bg-text-custom-text-100 hover:bg-custom-background-80 active:bg-custom-background-80 rounded"
|
|
||||||
title={item.name}
|
|
||||||
>
|
|
||||||
<item.icon
|
|
||||||
className={cn("h-4 w-4 text-lg", {
|
|
||||||
"text-red-600": item.key.includes("delete"),
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
@ -3,7 +3,8 @@
|
|||||||
"version": "0.13.2",
|
"version": "0.13.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 4000",
|
"dev": "turbo run develop",
|
||||||
|
"develop": "next dev -p 4000",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 4000",
|
"start": "next start -p 4000",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
@ -17,26 +18,6 @@
|
|||||||
"@heroicons/react": "^2.0.12",
|
"@heroicons/react": "^2.0.12",
|
||||||
"@mui/icons-material": "^5.14.1",
|
"@mui/icons-material": "^5.14.1",
|
||||||
"@mui/material": "^5.14.1",
|
"@mui/material": "^5.14.1",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.0.4",
|
|
||||||
"@tiptap/extension-color": "^2.0.4",
|
|
||||||
"@tiptap/extension-gapcursor": "^2.1.7",
|
|
||||||
"@tiptap/extension-highlight": "^2.0.4",
|
|
||||||
"@tiptap/extension-horizontal-rule": "^2.0.4",
|
|
||||||
"@tiptap/extension-image": "^2.0.4",
|
|
||||||
"@tiptap/extension-link": "^2.0.4",
|
|
||||||
"@tiptap/extension-placeholder": "^2.0.4",
|
|
||||||
"@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.0.4",
|
|
||||||
"@tiptap/extension-task-list": "^2.0.4",
|
|
||||||
"@tiptap/extension-text-style": "^2.0.4",
|
|
||||||
"@tiptap/extension-underline": "^2.0.4",
|
|
||||||
"@tiptap/pm": "^2.0.4",
|
|
||||||
"@tiptap/react": "^2.0.4",
|
|
||||||
"@tiptap/starter-kit": "^2.0.4",
|
|
||||||
"@tiptap/suggestion": "^2.0.4",
|
|
||||||
"axios": "^1.3.4",
|
"axios": "^1.3.4",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"js-cookie": "^3.0.1",
|
"js-cookie": "^3.0.1",
|
||||||
@ -51,13 +32,11 @@
|
|||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.38.0",
|
"react-hook-form": "^7.38.0",
|
||||||
"react-moveable": "^0.54.1",
|
|
||||||
"swr": "^2.2.2",
|
"swr": "^2.2.2",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tiptap-markdown": "^0.8.2",
|
|
||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
"use-debounce": "^9.0.4",
|
"uuid": "^9.0.0",
|
||||||
"uuid": "^9.0.0"
|
"@plane/rich-text-editor": "*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/js-cookie": "^3.0.3",
|
"@types/js-cookie": "^3.0.3",
|
||||||
|
@ -25,19 +25,37 @@ interface UnSplashImageUrls {
|
|||||||
small_s3: string;
|
small_s3: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileServices extends APIService {
|
class FileService extends APIService {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(API_BASE_URL);
|
super(API_BASE_URL);
|
||||||
|
this.uploadFile = this.uploadFile.bind(this);
|
||||||
|
this.deleteImage = this.deleteImage.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
@ -76,6 +94,6 @@ class FileServices extends APIService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileServices = new FileServices();
|
const fileService = new FileService();
|
||||||
|
|
||||||
export default fileServices;
|
export default fileService;
|
||||||
|
@ -155,7 +155,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#tiptap-container {
|
#editor-container {
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
|
58
turbo.json
58
turbo.json
@ -28,11 +28,60 @@
|
|||||||
],
|
],
|
||||||
"pipeline": {
|
"pipeline": {
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": [
|
||||||
"outputs": [".next/**", "dist/**"]
|
"^build"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
".next/**",
|
||||||
|
"dist/**"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"web#develop": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true,
|
||||||
|
"dependsOn": [
|
||||||
|
"@plane/lite-text-editor#build",
|
||||||
|
"@plane/rich-text-editor#build"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"space#develop": {
|
||||||
|
"cache": false,
|
||||||
|
"persistent": true,
|
||||||
|
"dependsOn": [
|
||||||
|
"@plane/lite-text-editor#build",
|
||||||
|
"@plane/rich-text-editor#build"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"web#build": {
|
||||||
|
"cache": true,
|
||||||
|
"dependsOn": [
|
||||||
|
"@plane/lite-text-editor#build",
|
||||||
|
"@plane/rich-text-editor#build"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"space#build": {
|
||||||
|
"cache": true,
|
||||||
|
"dependsOn": [
|
||||||
|
"@plane/lite-text-editor#build",
|
||||||
|
"@plane/rich-text-editor#build"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@plane/lite-text-editor#build": {
|
||||||
|
"cache": true,
|
||||||
|
"dependsOn": [
|
||||||
|
"@plane/editor-core#build"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@plane/rich-text-editor#build": {
|
||||||
|
"cache": true,
|
||||||
|
"dependsOn": [
|
||||||
|
"@plane/editor-core#build"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": [
|
||||||
|
"^build"
|
||||||
|
],
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
@ -41,6 +90,9 @@
|
|||||||
"dev": {
|
"dev": {
|
||||||
"cache": false
|
"cache": false
|
||||||
},
|
},
|
||||||
|
"develop": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
"start": {
|
"start": {
|
||||||
"cache": false
|
"cache": false
|
||||||
},
|
},
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, forwardRef, useRef } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
// react-hook-form
|
// react-hook-form
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@ -10,7 +10,8 @@ import useToast from "hooks/use-toast";
|
|||||||
import useUserAuth from "hooks/use-user-auth";
|
import useUserAuth from "hooks/use-user-auth";
|
||||||
// ui
|
// ui
|
||||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
import { TipTapEditor } from "components/tiptap";
|
// components
|
||||||
|
import { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "@plane/rich-text-editor";
|
||||||
// types
|
// types
|
||||||
import { IIssue, IPageBlock } from "types";
|
import { IIssue, IPageBlock } from "types";
|
||||||
|
|
||||||
@ -133,20 +134,17 @@ export const GptAssistantModal: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${
|
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${isOpen ? "block" : "hidden"
|
||||||
isOpen ? "block" : "hidden"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
|
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
Content:
|
Content:
|
||||||
<TipTapEditor
|
<RichReadOnlyEditorWithRef
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
value={htmlContent ?? `<p>${content}</p>`}
|
value={htmlContent ?? `<p>${content}</p>`}
|
||||||
customClassName="-m-3"
|
customClassName="-m-3"
|
||||||
noBorder
|
noBorder
|
||||||
borderOnFocus={false}
|
borderOnFocus={false}
|
||||||
editable={false}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -154,13 +152,11 @@ 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
|
<RichReadOnlyEditor
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
value={`<p>${response}</p>`}
|
value={`<p>${response}</p>`}
|
||||||
customClassName="-mx-3 -my-3"
|
customClassName="-mx-3 -my-3"
|
||||||
noBorder
|
noBorder
|
||||||
borderOnFocus={false}
|
borderOnFocus={false}
|
||||||
editable={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -174,8 +170,7 @@ export const GptAssistantModal: React.FC<Props> = ({
|
|||||||
type="text"
|
type="text"
|
||||||
name="task"
|
name="task"
|
||||||
register={register}
|
register={register}
|
||||||
placeholder={`${
|
placeholder={`${content && content !== ""
|
||||||
content && content !== ""
|
|
||||||
? "Tell AI what action to perform on this content..."
|
? "Tell AI what action to perform on this content..."
|
||||||
: "Ask AI anything..."
|
: "Ask AI anything..."
|
||||||
}`}
|
}`}
|
||||||
|
@ -7,7 +7,7 @@ import { useDropzone } from "react-dropzone";
|
|||||||
// headless ui
|
// headless ui
|
||||||
import { Transition, Dialog } from "@headlessui/react";
|
import { Transition, Dialog } from "@headlessui/react";
|
||||||
// services
|
// services
|
||||||
import fileServices from "services/file.service";
|
import fileService from "services/file.service";
|
||||||
// hooks
|
// hooks
|
||||||
import useWorkspaceDetails from "hooks/use-workspace-details";
|
import useWorkspaceDetails from "hooks/use-workspace-details";
|
||||||
// ui
|
// ui
|
||||||
@ -64,7 +64,7 @@ export const ImageUploadModal: React.FC<Props> = ({
|
|||||||
formData.append("attributes", JSON.stringify({}));
|
formData.append("attributes", JSON.stringify({}));
|
||||||
|
|
||||||
if (userImage) {
|
if (userImage) {
|
||||||
fileServices
|
fileService
|
||||||
.uploadUserFile(formData)
|
.uploadUserFile(formData)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const imageUrl = res.asset;
|
const imageUrl = res.asset;
|
||||||
@ -73,13 +73,13 @@ export const ImageUploadModal: React.FC<Props> = ({
|
|||||||
setIsImageUploading(false);
|
setIsImageUploading(false);
|
||||||
setImage(null);
|
setImage(null);
|
||||||
|
|
||||||
if (value) fileServices.deleteUserFile(value);
|
if (value) fileService.deleteUserFile(value);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
} else
|
} else
|
||||||
fileServices
|
fileService
|
||||||
.uploadFile(workspaceSlug as string, formData)
|
.uploadFile(workspaceSlug as string, formData)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const imageUrl = res.asset;
|
const imageUrl = res.asset;
|
||||||
@ -87,7 +87,7 @@ export const ImageUploadModal: React.FC<Props> = ({
|
|||||||
setIsImageUploading(false);
|
setIsImageUploading(false);
|
||||||
setImage(null);
|
setImage(null);
|
||||||
|
|
||||||
if (value && workspaceDetails) fileServices.deleteFile(workspaceDetails.id, value);
|
if (value && workspaceDetails) fileService.deleteFile(workspaceDetails.id, value);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
@ -3,11 +3,13 @@ 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 { TipTapEditor } from "components/tiptap";
|
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
|
||||||
|
import fileService from "services/file.service";
|
||||||
|
|
||||||
const defaultValues: Partial<IIssueComment> = {
|
const defaultValues: Partial<IIssueComment> = {
|
||||||
access: "INTERNAL",
|
access: "INTERNAL",
|
||||||
@ -20,7 +22,12 @@ type Props = {
|
|||||||
showAccessSpecifier?: boolean;
|
showAccessSpecifier?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const commentAccess = [
|
type commentAccessType = {
|
||||||
|
icon: string;
|
||||||
|
key: string;
|
||||||
|
label: "Private" | "Public";
|
||||||
|
}
|
||||||
|
const commentAccess: commentAccessType[] = [
|
||||||
{
|
{
|
||||||
icon: "lock",
|
icon: "lock",
|
||||||
key: "INTERNAL",
|
key: "INTERNAL",
|
||||||
@ -64,49 +71,26 @@ export const AddComment: React.FC<Props> = ({
|
|||||||
<form onSubmit={handleSubmit(handleAddComment)}>
|
<form onSubmit={handleSubmit(handleAddComment)}>
|
||||||
<div>
|
<div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{showAccessSpecifier && (
|
|
||||||
<div className="absolute bottom-2 left-3 z-[1]">
|
|
||||||
<Controller
|
<Controller
|
||||||
control={control}
|
|
||||||
name="access"
|
name="access"
|
||||||
render={({ field: { onChange, value } }) => (
|
control={control}
|
||||||
<div className="flex border border-custom-border-300 divide-x divide-custom-border-300 rounded overflow-hidden">
|
render={({ field: { onChange: onAccessChange, value: accessValue } }) => (
|
||||||
{commentAccess.map((access) => (
|
|
||||||
<Tooltip key={access.key} tooltipContent={access.label}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(access.key)}
|
|
||||||
className={`grid place-items-center p-1 hover:bg-custom-background-80 ${
|
|
||||||
value === access.key ? "bg-custom-background-80" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
iconName={access.icon}
|
|
||||||
className={`w-4 h-4 -mt-1 ${
|
|
||||||
value === access.key
|
|
||||||
? "!text-custom-text-100"
|
|
||||||
: "!text-custom-text-400"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Controller
|
<Controller
|
||||||
name="comment_html"
|
name="comment_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => (
|
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
|
||||||
<TipTapEditor
|
<LiteTextEditorWithRef
|
||||||
workspaceSlug={workspaceSlug as string}
|
onEnterKeyPress={handleSubmit(handleAddComment)}
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
|
deleteFile={fileService.deleteImage}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={!value || value === "" ? "<p></p>" : value}
|
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) => onChange(comment_html)}
|
onChange={(comment_json: Object, comment_html: string) => onCommentChange(comment_html)}
|
||||||
|
commentAccessSpecifier={{ accessValue, onAccessChange, showAccessSpecifier, commentAccess }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
@ -9,11 +9,13 @@ 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 { TipTapEditor } from "components/tiptap";
|
import { LiteTextEditorWithRef, LiteReadOnlyEditorWithRef } from "@plane/lite-text-editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { timeAgo } from "helpers/date-time.helper";
|
import { timeAgo } from "helpers/date-time.helper";
|
||||||
// types
|
// types
|
||||||
import type { IIssueComment } from "types";
|
import type { IIssueComment } from "types";
|
||||||
|
import fileService from "services/file.service";
|
||||||
|
// services
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
comment: IIssueComment;
|
comment: IIssueComment;
|
||||||
@ -107,11 +109,12 @@ export const CommentCard: React.FC<Props> = ({
|
|||||||
<div className="issue-comments-section p-0">
|
<div className="issue-comments-section p-0">
|
||||||
<form
|
<form
|
||||||
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
|
className={`flex-col gap-2 ${isEditing ? "flex" : "hidden"}`}
|
||||||
onSubmit={handleSubmit(onEnter)}
|
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<TipTapEditor
|
<LiteTextEditorWithRef
|
||||||
workspaceSlug={workspaceSlug as string}
|
onEnterKeyPress={handleSubmit(onEnter)}
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
|
deleteFile={fileService.deleteImage}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={watch("comment_html")}
|
value={watch("comment_html")}
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
@ -148,11 +151,9 @@ export const CommentCard: React.FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<TipTapEditor
|
<LiteReadOnlyEditorWithRef
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={showEditorRef}
|
ref={showEditorRef}
|
||||||
value={comment.comment_html}
|
value={comment.comment_html}
|
||||||
editable={false}
|
|
||||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||||
/>
|
/>
|
||||||
<CommentReaction projectId={comment.project} commentId={comment.id} />
|
<CommentReaction projectId={comment.project} commentId={comment.id} />
|
||||||
|
@ -7,9 +7,10 @@ import useReloadConfirmations from "hooks/use-reload-confirmation";
|
|||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
// components
|
// components
|
||||||
import { TextArea } from "components/ui";
|
import { TextArea } from "components/ui";
|
||||||
import { TipTapEditor } from "components/tiptap";
|
|
||||||
// types
|
// types
|
||||||
import { IIssue } from "types";
|
import { IIssue } from "types";
|
||||||
|
import { RichTextEditor } from "@plane/rich-text-editor";
|
||||||
|
import fileService from "services/file.service";
|
||||||
|
|
||||||
export interface IssueDescriptionFormValues {
|
export interface IssueDescriptionFormValues {
|
||||||
name: string;
|
name: string;
|
||||||
@ -84,10 +85,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
}, [issue, reset]);
|
}, [issue, reset]);
|
||||||
|
|
||||||
const debouncedTitleSave = useDebouncedCallback(async () => {
|
const debouncedTitleSave = useDebouncedCallback(async () => {
|
||||||
setTimeout(async () => {
|
|
||||||
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
|
||||||
}, 500);
|
}, 1500);
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -99,7 +98,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
placeholder="Enter issue name"
|
placeholder="Enter issue name"
|
||||||
register={register}
|
register={register}
|
||||||
onFocus={() => setCharacterLimit(true)}
|
onFocus={() => setCharacterLimit(true)}
|
||||||
onChange={(e) => {
|
onChange={() => {
|
||||||
setCharacterLimit(false);
|
setCharacterLimit(false);
|
||||||
setIsSubmitting("submitting");
|
setIsSubmitting("submitting");
|
||||||
debouncedTitleSave();
|
debouncedTitleSave();
|
||||||
@ -115,8 +114,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
{characterLimit && isAllowed && (
|
{characterLimit && isAllowed && (
|
||||||
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
|
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
|
||||||
<span
|
<span
|
||||||
className={`${
|
className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
|
||||||
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{watch("name").length}
|
{watch("name").length}
|
||||||
@ -130,42 +128,27 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
<Controller
|
<Controller
|
||||||
name="description_html"
|
name="description_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => {
|
render={({ field: { value, onChange } }) => (
|
||||||
if (!value) return <></>;
|
<RichTextEditor
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||||
return (
|
deleteFile={fileService.deleteImage}
|
||||||
<TipTapEditor
|
value={value}
|
||||||
value={
|
|
||||||
!value ||
|
|
||||||
value === "" ||
|
|
||||||
(typeof value === "object" && Object.keys(value).length === 0)
|
|
||||||
? "<p></p>"
|
|
||||||
: value
|
|
||||||
}
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
debouncedUpdatesEnabled={true}
|
debouncedUpdatesEnabled={true}
|
||||||
setShouldShowAlert={setShowAlert}
|
setShouldShowAlert={setShowAlert}
|
||||||
setIsSubmitting={setIsSubmitting}
|
setIsSubmitting={setIsSubmitting}
|
||||||
customClassName={
|
customClassName={isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"}
|
||||||
isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"
|
|
||||||
}
|
|
||||||
noBorder={!isAllowed}
|
noBorder={!isAllowed}
|
||||||
onChange={(description: Object, description_html: string) => {
|
onChange={(description: Object, description_html: string) => {
|
||||||
setShowAlert(true);
|
setShowAlert(true);
|
||||||
setIsSubmitting("submitting");
|
setIsSubmitting("submitting");
|
||||||
onChange(description_html);
|
onChange(description_html);
|
||||||
handleSubmit(handleDescriptionFormSubmit)().finally(() =>
|
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted")
|
||||||
setIsSubmitting("submitted")
|
|
||||||
);
|
);
|
||||||
}}
|
} } />
|
||||||
editable={isAllowed}
|
)}
|
||||||
/>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
|
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||||
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
||||||
|
@ -25,11 +25,14 @@ import { CreateStateModal } from "components/states";
|
|||||||
import { CreateLabelModal } from "components/labels";
|
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";
|
||||||
import { TipTapEditor } from "components/tiptap";
|
// components
|
||||||
|
import { RichTextEditorWithRef } 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
|
||||||
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
|
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
|
||||||
|
import fileService from "services/file.service";
|
||||||
|
// services
|
||||||
|
|
||||||
const defaultValues: Partial<IIssue> = {
|
const defaultValues: Partial<IIssue> = {
|
||||||
project: "",
|
project: "",
|
||||||
@ -411,29 +414,23 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="description_html"
|
name="description_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => {
|
render={({ field: { value, onChange } }) => (
|
||||||
if (!value && !watch("description_html")) return <></>;
|
<RichTextEditorWithRef
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
return (
|
deleteFile={fileService.deleteImage}
|
||||||
<TipTapEditor
|
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
value={
|
value={!value ||
|
||||||
!value ||
|
|
||||||
value === "" ||
|
value === "" ||
|
||||||
(typeof value === "object" && Object.keys(value).length === 0)
|
(typeof value === "object" && Object.keys(value).length === 0)
|
||||||
? watch("description_html")
|
? watch("description_html")
|
||||||
: value
|
: value}
|
||||||
}
|
|
||||||
customClassName="min-h-[150px]"
|
customClassName="min-h-[150px]"
|
||||||
onChange={(description: Object, description_html: string) => {
|
onChange={(description: Object, description_html: string) => {
|
||||||
onChange(description_html);
|
onChange(description_html);
|
||||||
setValue("description", description);
|
setValue("description", description);
|
||||||
}}
|
} } />
|
||||||
/>
|
)}
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<GptAssistantModal
|
<GptAssistantModal
|
||||||
isOpen={gptAssistantModal}
|
isOpen={gptAssistantModal}
|
||||||
|
@ -24,11 +24,15 @@ import { CreateStateModal } from "components/states";
|
|||||||
import { CreateLabelModal } from "components/labels";
|
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";
|
||||||
import { TipTapEditor } from "components/tiptap";
|
// components
|
||||||
|
import { RichTextEditorWithRef } 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
|
||||||
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
|
import type { ICurrentUserResponse, IIssue, ISearchIssueResponse } from "types";
|
||||||
|
import fileService from "services/file.service";
|
||||||
|
// services
|
||||||
|
|
||||||
const defaultValues: Partial<IIssue> = {
|
const defaultValues: Partial<IIssue> = {
|
||||||
project: "",
|
project: "",
|
||||||
@ -350,8 +354,7 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
{issueName && issueName !== "" && (
|
{issueName && issueName !== "" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${
|
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-90 ${iAmFeelingLucky ? "cursor-wait" : ""
|
||||||
iAmFeelingLucky ? "cursor-wait" : ""
|
|
||||||
}`}
|
}`}
|
||||||
onClick={handleAutoGenerateDescription}
|
onClick={handleAutoGenerateDescription}
|
||||||
disabled={iAmFeelingLucky}
|
disabled={iAmFeelingLucky}
|
||||||
@ -377,29 +380,23 @@ export const IssueForm: FC<IssueFormProps> = (props) => {
|
|||||||
<Controller
|
<Controller
|
||||||
name="description_html"
|
name="description_html"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field: { value, onChange } }) => {
|
render={({ field: { value, onChange } }) => (
|
||||||
if (!value && !watch("description_html")) return <></>;
|
<RichTextEditorWithRef
|
||||||
|
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||||
return (
|
deleteFile={fileService.deleteImage}
|
||||||
<TipTapEditor
|
|
||||||
workspaceSlug={workspaceSlug as string}
|
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
debouncedUpdatesEnabled={false}
|
debouncedUpdatesEnabled={false}
|
||||||
value={
|
value={!value ||
|
||||||
!value ||
|
|
||||||
value === "" ||
|
value === "" ||
|
||||||
(typeof value === "object" && Object.keys(value).length === 0)
|
(typeof value === "object" && Object.keys(value).length === 0)
|
||||||
? watch("description_html")
|
? watch("description_html")
|
||||||
: value
|
: value}
|
||||||
}
|
|
||||||
customClassName="min-h-[150px]"
|
customClassName="min-h-[150px]"
|
||||||
onChange={(description: Object, description_html: string) => {
|
onChange={(description: Object, description_html: string) => {
|
||||||
onChange(description_html);
|
onChange(description_html);
|
||||||
setValue("description", description);
|
setValue("description", description);
|
||||||
}}
|
} } />
|
||||||
/>
|
)}
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<GptAssistantModal
|
<GptAssistantModal
|
||||||
isOpen={gptAssistantModal}
|
isOpen={gptAssistantModal}
|
||||||
|
@ -19,6 +19,7 @@ import { SubIssuesRootList } from "./issues-list";
|
|||||||
import { IssueProperty } from "./properties";
|
import { IssueProperty } from "./properties";
|
||||||
// ui
|
// ui
|
||||||
import { Tooltip, CustomMenu } from "components/ui";
|
import { Tooltip, CustomMenu } from "components/ui";
|
||||||
|
|
||||||
// types
|
// types
|
||||||
import { ICurrentUserResponse, IIssue } from "types";
|
import { ICurrentUserResponse, IIssue } from "types";
|
||||||
import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root";
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user