Compare commits

...

60 Commits

Author SHA1 Message Date
Palanikannan1437
94ce5ce332 color support added for row and columns and fixed naming conventions 2023-10-13 14:42:04 +05:30
Palanikannan1437
020e5fe922 working tables with row/column selectors 2023-10-12 21:27:20 +05:30
Palanikannan1437
b6f1cb71d8 changed readme title of core package 2023-10-10 18:18:46 +05:30
Palanikannan1437
2ce9f3be98 Added rich text editor documentation 2023-10-10 18:07:24 +05:30
Palanikannan1437
8ba39dc1ea Completed readme for rich-text-editor 2023-10-10 17:48:26 +05:30
Palanikannan1437
67fd3e296a fixed tsconfig by removing path aliases 2023-10-10 17:29:24 +05:30
Palanikannan1437
3ea7ab3151 completed core package's documentation 2023-10-10 17:25:45 +05:30
Palanikannan1437
ede27b7b17 added usage of common dependance for slash commands 2023-10-10 16:17:26 +05:30
Palanikannan1437
d9c3407eed fixed ts issues with link plugin 2023-10-10 14:35:07 +05:30
Palanikannan1437
8552ef24a6 added editor/core readme and fixed imports 2023-10-09 17:56:35 +05:30
Palanikannan1437
6cb9f73006 updated bubble menu to use core package's utilities 2023-10-09 17:19:24 +05:30
Palanikannan1437
37d5e83e92 fixed toggle Underline behvaiour 2023-10-05 10:32:24 +05:30
Palanikannan1437
cfadb5a793 fixed bugs returning empty editor boxes 2023-10-04 23:07:14 +05:30
Palanikannan1437
a6b0ef221f removed tiptap instance from web 2023-10-04 21:51:18 +05:30
Palanikannan1437
0cf5ad684b Merge branch 'develop' into comment-editor 2023-10-04 21:50:09 +05:30
Palanikannan1437
a9d197cda7 removed extra margin from access specifiers 2023-10-04 21:47:42 +05:30
Palanikannan1437
696a2e9251 fixed shift enter behavior for lists 2023-10-04 15:28:55 +05:30
Palanikannan1437
2fd9198fcf modified turbo config for build pipeline of space and web projects 2023-10-04 01:37:49 +05:30
Palanikannan1437
18c9e64f48 added support for range commands 2023-10-04 01:26:40 +05:30
Palanikannan1437
95439fbbef added comment editor with fixed menu 2023-10-04 01:06:16 +05:30
Palanikannan1437
99618e93a7 fixed modal form issue 2023-10-03 19:59:09 +05:30
Palanikannan1437
ff29c1c87e Enter key behaviour added for Comments 2023-10-03 19:51:23 +05:30
Palanikannan1437
fc8284d458 fixed tailwind config for editor 2023-10-03 19:26:48 +05:30
Palanikannan1437
f408eb3055 fixed dependencies in editor package 2023-10-03 10:01:10 +05:30
Palanikannan1437
95e5a30614 removed absolute imports from rich-text-editor 2023-10-03 09:32:16 +05:30
Palanikannan1437
0080880852 removed absolute imports from lite-text-editor 2023-10-03 09:30:11 +05:30
Palanikannan1437
857fa18719 removed absolute imports from editor/core package 2023-10-03 09:27:52 +05:30
Palanikannan1437
b479718ece onSubmit on enterkey captured 2023-10-02 20:10:05 +05:30
Palanikannan1437
2c804c8d6c trimming html at start and end of content added 2023-10-02 17:44:56 +05:30
Palanikannan1437
51e281592f replaced all read-only instances 2023-10-02 16:35:54 +05:30
Palanikannan1437
de07f63089 read only editor support added 2023-10-02 16:24:57 +05:30
Palanikannan1437
3d87a56e3b refactoring tiptap to core/lite/rich text editor 2023-10-02 14:52:38 +05:30
Palanikannan1437
b5228bb0ef fixed global css issue with highlight js 2023-10-02 14:15:16 +05:30
Palanikannan1437
da86f1ad03 refactoring to LiteTextEditor and RichTextEditor 2023-09-30 17:20:59 +05:30
Palanikannan1437
a754300116 Merge branch 'develop' into comment-editor 2023-09-28 20:25:52 +05:30
Palanikannan1437
efcc7f20cc added seperation of extensions and props 2023-09-28 20:25:31 +05:30
Palanikannan1437
4298b0500e refactored editor to not require workspace slug 2023-09-28 16:05:52 +05:30
Palanikannan1437
d639a0126d new structure of editor components 2023-09-27 19:49:15 +05:30
Palanikannan1437
063f4c774a comment editor basic version added 2023-09-25 14:05:17 +05:30
Henit Chobisa
4981c3dbfe chore: updated pulls build CI for using turbo builds 2023-09-22 17:04:56 +00:00
PalanikannanM
bb13f35465 fixed dependency issue with react type resolution 2023-09-22 16:23:27 +00:00
Palanikannan1437
cbb1c1884f resolved lock life and missing deps 2023-09-22 18:07:07 +05:30
Palanikannan1437
9079fa4766 improved dev experience to build the tiptap package before starting dev server 2023-09-22 17:35:08 +05:30
M. Palanikannan
0f9aae6ff9
Merge branch 'develop' into packaging-tiptap 2023-09-22 17:26:31 +05:30
Palanikannan1437
36fd719f7f removed old tiptap dependency 2023-09-22 17:15:19 +05:30
Palanikannan1437
fa01dfc652 migrated all components to use the common package 2023-09-22 16:45:09 +05:30
Palanikannan1437
f0e246ccfd reverted back types and fixed tailwind 2023-09-22 13:54:52 +05:30
Palanikannan1437
448d433876 added turbo dependency for tiptap 2023-09-21 18:04:38 +05:30
Palanikannan1437
5f8a0c3f94 Merge branch 'develop' into packaging-tiptap 2023-09-20 19:52:05 +05:30
Palanikannan1437
8e71ce0c50 finally fixed import errors 2023-09-20 19:03:06 +05:30
Palanikannan1437
28697e3c0e updated package name to @plane/editor 2023-09-20 12:19:35 +05:30
Palanikannan1437
3b9c29cfd4 exported tiptap editor with and without ref 2023-09-20 11:26:16 +05:30
Palanikannan1437
7ef1745e38 removed old tailwind config and fixed plane package imports 2023-09-20 10:10:11 +05:30
Palanikannan1437
46c761429f Merge branch 'develop' into packaging-tiptap 2023-09-19 19:54:06 +05:30
Palanikannan1437
e35848718d fixed types 2023-09-19 19:49:15 +05:30
Palanikannan1437
35ffb850b7 removed tiptap pro extension 2023-09-19 16:37:40 +05:30
Palanikannan1437
8e76809c9b abstracted upload and delete functions 2023-09-19 13:42:54 +05:30
Palanikannan1437
e9e6f439b4 merge develop into packaging-tiptap 2023-09-19 08:39:23 +05:30
Palanikannan1437
b841df6729 added common tailwind config to web 2023-09-08 18:42:05 +05:30
Palanikannan1437
0887b69149 initialized tiptap component with common tailwind config 2023-09-08 18:41:40 +05:30
147 changed files with 5112 additions and 3228 deletions

View File

@ -36,15 +36,13 @@ jobs:
- name: Build Plane's Main App
if: steps.changed-files.outputs.web_any_changed == 'true'
run: |
cd web
yarn
yarn build
yarn build --filter=web
- name: Build Plane's Deploy App
if: steps.changed-files.outputs.deploy_any_changed == 'true'
run: |
cd space
yarn
yarn build
yarn build --filter=space

4
.gitignore vendored
View File

@ -73,3 +73,7 @@ pnpm-lock.yaml
pnpm-workspace.yaml
.npmrc
## packages
dist

View File

@ -6,7 +6,11 @@
"workspaces": [
"web",
"space",
"packages/*"
"packages/editor/*",
"packages/eslint-config-custom",
"packages/tailwind-config-custom",
"packages/tsconfig",
"packages/ui"
],
"scripts": {
"build": "turbo run build",
@ -25,5 +29,8 @@
"tailwindcss": "^3.3.3",
"turbo": "latest"
},
"resolutions": {
"@types/react": "18.2.0"
},
"packageManager": "yarn@1.22.19"
}

View 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 Editors 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 Editors 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

View File

@ -0,0 +1,74 @@
{
"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-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",
"@tiptap/prosemirror-tables": "^1.1.4",
"jsx-dom-cjs": "^8.0.3"
},
"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"
]
}

View File

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

View File

@ -0,0 +1,21 @@
// styles
// import "./styles/tailwind.css";
// import "./styles/editor.css";
export * from "./ui/extensions/table/table";
// 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";

View 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();
};

View 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:";
}

View 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;
}

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

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

View File

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

View 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 editorContainer ${editorClassNames}`}
>
{children}
</div>
);

View File

@ -0,0 +1,17 @@
import { Editor, EditorContent } from "@tiptap/react";
import { ReactNode } from "react";
import { ImageResizer } from "../extensions/image/image-resize";
interface EditorContentProps {
editor: Editor | null;
editorContentCustomClassNames: string | undefined;
children?: ReactNode;
}
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => (
<div className={`contentEditor ${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{editor?.isActive("image") && <ImageResizer editor={editor} />}
{children}
</div>
);

View 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;

View File

@ -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;

View File

@ -0,0 +1,95 @@
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 ImageExtension from "./image";
import { DeleteImage } from "../../types/delete-image";
import { isValidHttpUrl } from "../../lib/utils";
import TableHeader from "./table/table-header/table-header";
import Table from "./table/table";
import TableCell from "./table/table-cell/table-cell";
import TableRow from "./table/table-row/table-row";
export const CoreEditorExtensions = (
deleteFile: DeleteImage,
) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
code: {
HTMLAttributes: {
class:
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
},
codeBlock: false,
horizontalRule: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
},
gapcursor: false,
}),
Gapcursor,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtension(deleteFile).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
},
nested: true,
}),
Markdown.configure({
html: true,
transformCopiedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
];

View File

@ -0,0 +1 @@
export { default as default } from "./table-cell"

View File

@ -0,0 +1,58 @@
import { mergeAttributes, Node } from "@tiptap/core"
export interface TableCellOptions {
HTMLAttributes: Record<string, any>
}
export default Node.create<TableCellOptions>({
name: "tableCell",
addOptions() {
return {
HTMLAttributes: {}
}
},
content: "paragraph+",
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth")
const value = colwidth ? [parseInt(colwidth, 10)] : null
return value
}
},
background: {
default: "none"
}
}
},
tableRole: "cell",
isolating: true,
parseHTML() {
return [{ tag: "td" }]
},
renderHTML({ node, HTMLAttributes }) {
return [
"td",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`
}),
0
]
}
})

View File

@ -0,0 +1 @@
export { default as default } from "./table-header"

View File

@ -0,0 +1,57 @@
import { mergeAttributes, Node } from "@tiptap/core"
export interface TableHeaderOptions {
HTMLAttributes: Record<string, any>
}
export default Node.create<TableHeaderOptions>({
name: "tableHeader",
addOptions() {
return {
HTMLAttributes: {}
}
},
content: "paragraph+",
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute("colwidth")
const value = colwidth ? [parseInt(colwidth, 10)] : null
return value
}
},
background: {
default: "rgb(var(--color-primary-100))"
}
}
},
tableRole: "header_cell",
isolating: true,
parseHTML() {
return [{ tag: "th" }]
},
renderHTML({ node, HTMLAttributes }) {
return [
"th",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
style: `background-color: ${node.attrs.background}`
}),
0
]
}
})

View File

@ -0,0 +1 @@
export { default as default } from "./table-row"

View File

@ -0,0 +1,31 @@
import { mergeAttributes, Node } from "@tiptap/core"
export interface TableRowOptions {
HTMLAttributes: Record<string, any>
}
export default Node.create<TableRowOptions>({
name: "tableRow",
addOptions() {
return {
HTMLAttributes: {}
}
},
content: "(tableCell | tableHeader)*",
tableRole: "row",
parseHTML() {
return [{ tag: "tr" }]
},
renderHTML({ HTMLAttributes }) {
return [
"tr",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
0
]
}
})

View File

@ -0,0 +1,11 @@
const icons = {
insertColumnLeft: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M20 3c.552 0 1 .448 1 1v16c0 .552-.448 1-1 1h-6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2h-4v14h4V5zM6 7c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2H5v1.999L3 11v2l2-.001V15h2v-2.001L9 13v-2l-2-.001V9z"/></svg>`,
insertColumnRight: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M10 3c.552 0 1 .448 1 1v16c0 .552-.448 1-1 1H4c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zM9 5H5v14h4V5zm9 2c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2h-2v1.999L15 11v2l2-.001V15h2v-2.001L21 13v-2l-2-.001V9z"/></svg>`,
insertRowTop: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M20 13c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1H4c-.552 0-1-.448-1-1v-6c0-.552.448-1 1-1h16zm-1 2H5v4h14v-4zM12 1c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2h-2v1.999L9 5v2l2-.001V9h2V6.999L15 7V5l-2-.001V3z"/></svg>`,
insertRowBottom: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M12 13c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2h-2v1.999L9 17v2l2-.001V21h2v-2.001L15 19v-2l-2-.001V15zm7-12c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1H4c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h16zM5 5v4h14V5H5z"/></svg>`,
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: inherit;transform: ;msFilter:;"><path d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`
}
export default icons

View File

@ -0,0 +1 @@
export { default as default } from "./table"

View File

@ -0,0 +1,118 @@
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"
import { findParentNode } from "@tiptap/core"
import { DecorationSet, Decoration } from "@tiptap/pm/view"
const key = new PluginKey("tableControls")
export function tableControls() {
return new Plugin({
key,
state: {
init() {
return new TableControlsState()
},
apply(tr, prev) {
return prev.apply(tr)
}
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
const pluginState = key.getState(view.state)
if (
!(event.target as HTMLElement).closest(
".tableWrapper"
) &&
pluginState.values.hoveredTable
) {
return view.dispatch(
view.state.tr.setMeta(key, {
setHoveredTable: null,
setHoveredCell: null
})
)
}
const pos = view.posAtCoords({
left: event.clientX,
top: event.clientY
})
if (!pos) return
const table = findParentNode(
(node) => node.type.name === "table"
)(TextSelection.create(view.state.doc, pos.pos))
const cell = findParentNode(
(node) =>
node.type.name === "tableCell" ||
node.type.name === "tableHeader"
)(TextSelection.create(view.state.doc, pos.pos))
if (!table || !cell) return
if (pluginState.values.hoveredCell?.pos !== cell.pos) {
return view.dispatch(
view.state.tr.setMeta(key, {
setHoveredTable: table,
setHoveredCell: cell
})
)
}
}
},
decorations: (state) => {
const pluginState = key.getState(state)
if (!pluginState) {
return null
}
const { hoveredTable, hoveredCell } = pluginState.values
if (hoveredTable) {
const decorations = [
Decoration.node(
hoveredTable.pos,
hoveredTable.pos + hoveredTable.node.nodeSize,
{},
{
hoveredTable,
hoveredCell
}
)
]
return DecorationSet.create(state.doc, decorations)
}
return null
}
}
})
}
class TableControlsState {
values
constructor(props = {}) {
this.values = {
hoveredTable: null,
hoveredCell: null,
...props
}
}
apply(tr: any) {
const actions = tr.getMeta(key)
if (actions?.setHoveredTable !== undefined) {
this.values.hoveredTable = actions.setHoveredTable
}
if (actions?.setHoveredCell !== undefined) {
this.values.hoveredCell = actions.setHoveredCell
}
return this
}
}

View File

@ -0,0 +1,500 @@
import { h } from "jsx-dom-cjs"
import { Node as ProseMirrorNode } from "@tiptap/pm/model"
import { Decoration, NodeView } from "@tiptap/pm/view"
import tippy, { Instance, Props } from "tippy.js"
import { Editor } from "@tiptap/core"
import {
CellSelection,
TableMap,
updateColumnsOnResize
} from "@tiptap/prosemirror-tables"
import icons from "./icons"
export function updateColumns(
node: ProseMirrorNode,
colgroup: HTMLElement,
table: HTMLElement,
cellMinWidth: number,
overrideCol?: number,
overrideValue?: any
) {
let totalWidth = 0
let fixedWidth = true
let nextDOM = colgroup.firstChild as HTMLElement
const row = node.firstChild
if (!row) return
for (let i = 0, col = 0; i < row.childCount; i += 1) {
const { colspan, colwidth } = row.child(i).attrs
for (let j = 0; j < colspan; j += 1, col += 1) {
const hasWidth =
overrideCol === col ? overrideValue : colwidth && colwidth[j]
const cssWidth = hasWidth ? `${hasWidth}px` : ""
totalWidth += hasWidth || cellMinWidth
if (!hasWidth) {
fixedWidth = false
}
if (!nextDOM) {
colgroup.appendChild(
document.createElement("col")
).style.width = cssWidth
} else {
if (nextDOM.style.width !== cssWidth) {
nextDOM.style.width = cssWidth
}
nextDOM = nextDOM.nextSibling as HTMLElement
}
}
}
while (nextDOM) {
const after = nextDOM.nextSibling
nextDOM.parentNode?.removeChild(nextDOM)
nextDOM = after as HTMLElement
}
if (fixedWidth) {
table.style.width = `${totalWidth}px`
table.style.minWidth = ""
} else {
table.style.width = ""
table.style.minWidth = `${totalWidth}px`
}
}
const defaultTippyOptions: Partial<Props> = {
allowHTML: true,
arrow: false,
trigger: "click",
animation: "scale-subtle",
theme: "light-border no-padding",
interactive: true,
hideOnClick: true,
placement: "right"
}
function setCellsBackgroundColor(editor: Editor, backgroundColor) {
return editor
.chain()
.focus()
.updateAttributes("tableCell", {
background: backgroundColor
})
.updateAttributes("tableHeader", {
background: backgroundColor
})
.run()
}
const columnsToolboxItems = [
{
label: "Add Column Before",
icon: icons.insertColumnLeft,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run()
},
{
label: "Add Column After",
icon: icons.insertColumnRight,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run()
},
{
label: "Pick Column Color",
icon: icons.colorPicker,
action: ({ editor, triggerButton, controlsContainer }: { editor: Editor, triggerButton: HTMLElement, controlsContainer }) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color)
})
}
},
{
label: "Delete Column",
icon: icons.deleteColumn,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run()
}
]
const rowsToolboxItems = [
{
label: "Add Row Above",
icon: icons.insertRowTop,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run()
},
{
label: "Add Row Below",
icon: icons.insertRowBottom,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run()
},
{
label: "Pick a Color",
icon: icons.colorPicker,
action: ({ editor, triggerButton, controlsContainer }: { editor: Editor, triggerButton: HTMLButtonElement, controlsContainer: Element | "parent" | ((ref: Element) => Element) | undefined }) => {
createColorPickerToolbox({
triggerButton,
tippyOptions: {
appendTo: controlsContainer
},
onSelectColor: (color) => setCellsBackgroundColor(editor, color)
})
}
},
{
label: "Delete Row",
icon: icons.deleteRow,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteRow().run()
}
]
function createToolbox({
triggerButton,
items,
tippyOptions,
onClickItem
}: { triggerButton: HTMLElement, items: { icon: string, label: string }[], tippyOptions: any, onClickItem: any }): Instance<Props> {
const toolbox = tippy(triggerButton, {
content: h(
"div",
{ className: "tableToolbox" },
items.map((item) =>
h(
"div",
{
className: "toolboxItem",
onClick() {
onClickItem(item)
}
},
[
h("div", {
className: "iconContainer",
innerHTML: item.icon
}),
h("div", { className: "label" }, item.label)
]
)
)
),
...tippyOptions
})
return Array.isArray(toolbox) ? toolbox[0] : toolbox
}
function createColorPickerToolbox({
triggerButton,
tippyOptions,
onSelectColor = () => { }
}: {
triggerButton: HTMLElement
tippyOptions: Partial<Props>
onSelectColor?: (color: string) => void
}) {
const items = {
"Default": "rgb(var(--color-primary-100))",
"Light gray": "#e7f3f8",
"Dark gray": "#c7d2d7",
"Light blue": "#e7f3f8",
"Light red": "#ffc4c7",
"Light yellow": "#fbf3db"
}
const colorPicker = tippy(triggerButton, {
...defaultTippyOptions,
content: h(
"div",
{ className: "tableColorPickerToolbox" },
Object.entries(items).map(([key, value]) =>
h(
"div",
{
className: "toolboxItem",
onClick: () => {
onSelectColor(value)
colorPicker.hide()
}
},
[
h("div", {
className: "colorContainer",
style: {
backgroundColor: value
}
}),
h(
"div",
{
className: "label"
},
key
)
]
)
)
),
onHidden: (instance) => {
instance.destroy()
},
showOnCreate: true,
...tippyOptions
})
return colorPicker
}
export class TableView implements NodeView {
node: ProseMirrorNode
cellMinWidth: number
decorations: Decoration[]
editor: Editor
getPos: () => number
hoveredCell
map: TableMap
root: HTMLElement
table: HTMLElement
colgroup: HTMLElement
tbody: HTMLElement
rowsControl?: HTMLElement
columnsControl?: HTMLElement
columnsToolbox?: Instance<Props>
rowsToolbox?: Instance<Props>
controls?: HTMLElement
get dom() {
return this.root
}
get contentDOM() {
return this.tbody
}
constructor(
node: ProseMirrorNode,
cellMinWidth: number,
decorations: Decoration[],
editor: Editor,
getPos: () => number
) {
this.node = node
this.cellMinWidth = cellMinWidth
this.decorations = decorations
this.editor = editor
this.getPos = getPos
this.hoveredCell = null
this.map = TableMap.get(node)
/**
* DOM
*/
// Controllers
if (editor.isEditable) {
this.rowsControl = h(
"div",
{ className: "rowsControl" },
h("button", {
onClick: () => this.selectRow()
})
)
this.columnsControl = h(
"div",
{ className: "columnsControl" },
h("button", {
onClick: () => this.selectColumn()
})
)
this.controls = h(
"div",
{ className: "tableControls", contentEditable: "false" },
this.rowsControl,
this.columnsControl
)
this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector("button"),
items: columnsToolboxItems,
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls
},
onClickItem: (item) => {
item.action({
editor: this.editor,
triggerButton: this.columnsControl?.firstElementChild,
controlsContainer: this.controls
})
this.columnsToolbox?.hide()
}
})
this.rowsToolbox = createToolbox({
triggerButton: this.rowsControl.firstElementChild,
items: rowsToolboxItems,
tippyOptions: {
...defaultTippyOptions,
appendTo: this.controls
},
onClickItem: (item) => {
item.action({
editor: this.editor,
triggerButton: this.rowsControl?.firstElementChild,
controlsContainer: this.controls
})
this.rowsToolbox?.hide()
}
})
}
// Table
this.colgroup = h(
"colgroup",
null,
Array.from({ length: this.map.width }, () => 1).map(() => h("col"))
)
this.tbody = h("tbody")
this.table = h("table", null, this.colgroup, this.tbody)
this.root = h(
"div",
{
className: "tableWrapper controls--disabled"
},
this.controls,
this.table
)
this.render()
}
update(node: ProseMirrorNode, decorations) {
if (node.type !== this.node.type) {
return false
}
this.node = node
this.decorations = decorations
this.map = TableMap.get(this.node)
if (this.editor.isEditable) {
this.updateControls()
}
this.render()
return true
}
render() {
if (this.colgroup.children.length !== this.map.width) {
const cols = Array.from({ length: this.map.width }, () => 1).map(
() => h("col")
)
this.colgroup.replaceChildren(...cols)
}
updateColumnsOnResize(
this.node,
this.colgroup,
this.table,
this.cellMinWidth
)
}
ignoreMutation() {
return true
}
updateControls() {
const { hoveredTable: table, hoveredCell: cell } = Object.values(
this.decorations
).reduce((acc, curr) => {
if (curr.spec.hoveredCell !== undefined) {
acc["hoveredCell"] = curr.spec.hoveredCell
}
if (curr.spec.hoveredTable !== undefined) {
acc["hoveredTable"] = curr.spec.hoveredTable
}
return acc
}, {}) as any
if (table === undefined || cell === undefined) {
return this.root.classList.add("controls--disabled")
}
this.root.classList.remove("controls--disabled")
this.hoveredCell = cell
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement
const tableRect = this.table.getBoundingClientRect()
const cellRect = cellDom.getBoundingClientRect()
this.columnsControl.style.left = `${cellRect.left -
tableRect.left -
this.table.parentElement!.scrollLeft
}px`
this.columnsControl.style.width = `${cellRect.width}px`
this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`
this.rowsControl.style.height = `${cellRect.height}px`
}
selectColumn() {
if (!this.hoveredCell) return
const colIndex = this.map.colCount(
this.hoveredCell.pos - (this.getPos() + 1)
)
const anchorCellPos = this.hoveredCell.pos
const headCellPos =
this.map.map[colIndex + this.map.width * (this.map.height - 1)] +
(this.getPos() + 1)
const cellSelection = CellSelection.create(
this.editor.view.state.doc,
anchorCellPos,
headCellPos
)
this.editor.view.dispatch(
// @ts-ignore
this.editor.state.tr.setSelection(cellSelection)
)
}
selectRow() {
if (!this.hoveredCell) return
const anchorCellPos = this.hoveredCell.pos
const anchorCellIndex = this.map.map.indexOf(
anchorCellPos - (this.getPos() + 1)
)
const headCellPos =
this.map.map[anchorCellIndex + (this.map.width - 1)] +
(this.getPos() + 1)
const cellSelection = CellSelection.create(
this.editor.state.doc,
anchorCellPos,
headCellPos
)
this.editor.view.dispatch(
// @ts-ignore
this.editor.view.state.tr.setSelection(cellSelection)
)
}
}

View File

@ -0,0 +1,298 @@
import { TextSelection } from "@tiptap/pm/state"
import { callOrReturn, getExtensionField, mergeAttributes, Node, ParentConfig } from "@tiptap/core"
import {
addColumnAfter,
addColumnBefore,
addRowAfter,
addRowBefore,
CellSelection,
columnResizing,
deleteColumn,
deleteRow,
deleteTable,
fixTables,
goToNextCell,
mergeCells,
setCellAttr,
splitCell,
tableEditing,
toggleHeader,
toggleHeaderCell
} from "@tiptap/prosemirror-tables"
import { tableControls } from "./table-controls"
import { TableView } from "./table-view"
import { createTable } from "./utilities/create-table"
import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected"
export interface TableOptions {
HTMLAttributes: Record<string, any>
resizable: boolean
handleWidth: number
cellMinWidth: number
lastColumnResizable: boolean
allowTableNodeSelection: boolean
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
table: {
insertTable: (options?: {
rows?: number
cols?: number
withHeaderRow?: boolean
}) => ReturnType
addColumnBefore: () => ReturnType
addColumnAfter: () => ReturnType
deleteColumn: () => ReturnType
addRowBefore: () => ReturnType
addRowAfter: () => ReturnType
deleteRow: () => ReturnType
deleteTable: () => ReturnType
mergeCells: () => ReturnType
splitCell: () => ReturnType
toggleHeaderColumn: () => ReturnType
toggleHeaderRow: () => ReturnType
toggleHeaderCell: () => ReturnType
mergeOrSplit: () => ReturnType
setCellAttribute: (name: string, value: any) => ReturnType
goToNextCell: () => ReturnType
goToPreviousCell: () => ReturnType
fixTables: () => ReturnType
setCellSelection: (position: {
anchorCell: number
headCell?: number
}) => ReturnType
}
}
interface NodeConfig<Options, Storage> {
tableRole?:
| string
| ((this: {
name: string
options: Options
storage: Storage
parent: ParentConfig<NodeConfig<Options>>["tableRole"]
}) => string)
}
}
export default Node.create({
name: "table",
addOptions() {
return {
HTMLAttributes: {},
resizable: true,
handleWidth: 5,
cellMinWidth: 100,
lastColumnResizable: true,
allowTableNodeSelection: true
}
},
content: "tableRow+",
tableRole: "table",
isolating: true,
group: "block",
allowGapCursor: false,
parseHTML() {
return [{ tag: "table" }]
},
renderHTML({ HTMLAttributes }) {
return [
"table",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
["tbody", 0]
]
},
addCommands() {
return {
insertTable:
({ rows = 3, cols = 3, withHeaderRow = true} = {}) =>
({ tr, dispatch, editor }) => {
const node = createTable(
editor.schema,
rows,
cols,
withHeaderRow
)
if (dispatch) {
const offset = tr.selection.anchor + 1
tr.replaceSelectionWith(node)
.scrollIntoView()
.setSelection(
TextSelection.near(tr.doc.resolve(offset))
)
}
return true
},
addColumnBefore:
() =>
({ state, dispatch }) => addColumnBefore(state, dispatch),
addColumnAfter:
() =>
({ state, dispatch }) => addColumnAfter(state, dispatch),
deleteColumn:
() =>
({ state, dispatch }) => deleteColumn(state, dispatch),
addRowBefore:
() =>
({ state, dispatch }) => addRowBefore(state, dispatch),
addRowAfter:
() =>
({ state, dispatch }) => addRowAfter(state, dispatch),
deleteRow:
() =>
({ state, dispatch }) => deleteRow(state, dispatch),
deleteTable:
() =>
({ state, dispatch }) => deleteTable(state, dispatch),
mergeCells:
() =>
({ state, dispatch }) => mergeCells(state, dispatch),
splitCell:
() =>
({ state, dispatch }) => splitCell(state, dispatch),
toggleHeaderColumn:
() =>
({ state, dispatch }) => toggleHeader("column")(state, dispatch),
toggleHeaderRow:
() =>
({ state, dispatch }) => toggleHeader("row")(state, dispatch),
toggleHeaderCell:
() =>
({ state, dispatch }) => toggleHeaderCell(state, dispatch),
mergeOrSplit:
() =>
({ state, dispatch }) => {
if (mergeCells(state, dispatch)) {
return true
}
return splitCell(state, dispatch)
},
setCellAttribute:
(name, value) =>
({ state, dispatch }) => setCellAttr(name, value)(state, dispatch),
goToNextCell:
() =>
({ state, dispatch }) => goToNextCell(1)(state, dispatch),
goToPreviousCell:
() =>
({ state, dispatch }) => goToNextCell(-1)(state, dispatch),
fixTables:
() =>
({ state, dispatch }) => {
if (dispatch) {
fixTables(state)
}
return true
},
setCellSelection:
(position) =>
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(
tr.doc,
position.anchorCell,
position.headCell
)
// @ts-ignore
tr.setSelection(selection)
}
return true
}
}
},
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.commands.goToNextCell()) {
return true
}
if (!this.editor.can().addRowAfter()) {
return false
}
return this.editor.chain().addRowAfter().goToNextCell().run()
},
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
Backspace: deleteTableWhenAllCellsSelected,
"Mod-Backspace": deleteTableWhenAllCellsSelected,
Delete: deleteTableWhenAllCellsSelected,
"Mod-Delete": deleteTableWhenAllCellsSelected
}
},
addNodeView() {
return ({ editor, getPos, node, decorations }) => {
const { cellMinWidth } = this.options
return new TableView(
node,
cellMinWidth,
decorations,
editor,
getPos as () => number
)
}
},
addProseMirrorPlugins() {
const isResizable = this.options.resizable && this.editor.isEditable
const plugins = [
tableEditing({
allowTableNodeSelection: this.options.allowTableNodeSelection
}),
tableControls()
]
if (isResizable) {
plugins.unshift(
columnResizing({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
// View: TableView,
// @ts-ignore
lastColumnResizable: this.options.lastColumnResizable
})
)
}
return plugins
},
extendNodeSchema(extension) {
const context = {
name: extension.name,
options: extension.options,
storage: extension.storage
}
return {
tableRole: callOrReturn(
getExtensionField(extension, "tableRole", context)
)
}
}
})

View File

@ -0,0 +1,12 @@
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"
export function createCell(
cellType: NodeType,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
): ProsemirrorNode | null | undefined {
if (cellContent) {
return cellType.createChecked(null, cellContent)
}
return cellType.createAndFill()
}

View File

@ -0,0 +1,45 @@
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"
import { createCell } from "./create-cell"
import { getTableNodeTypes } from "./get-table-node-types"
export function createTable(
schema: Schema,
rowsCount: number,
colsCount: number,
withHeaderRow: boolean,
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
): ProsemirrorNode {
const types = getTableNodeTypes(schema)
const headerCells: ProsemirrorNode[] = []
const cells: ProsemirrorNode[] = []
for (let index = 0; index < colsCount; index += 1) {
const cell = createCell(types.cell, cellContent)
if (cell) {
cells.push(cell)
}
if (withHeaderRow) {
const headerCell = createCell(types.header_cell, cellContent)
if (headerCell) {
headerCells.push(headerCell)
}
}
}
const rows: ProsemirrorNode[] = []
for (let index = 0; index < rowsCount; index += 1) {
rows.push(
types.row.createChecked(
null,
withHeaderRow && index === 0 ? headerCells : cells
)
)
}
return types.table.createChecked(null, rows)
}

View File

@ -0,0 +1,39 @@
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"
import { isCellSelection } from "./is-cell-selection"
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({
editor
}) => {
const { selection } = editor.state
if (!isCellSelection(selection)) {
return false
}
let cellCount = 0
const table = findParentNodeClosestToPos(
selection.ranges[0].$from,
(node) => node.type.name === "table"
)
table?.node.descendants((node) => {
if (node.type.name === "table") {
return false
}
if (["tableCell", "tableHeader"].includes(node.type.name)) {
cellCount += 1
}
})
const allCellsSelected = cellCount === selection.ranges.length
if (!allCellsSelected) {
return false
}
editor.commands.deleteTable()
return true
}

View File

@ -0,0 +1,21 @@
import { NodeType, Schema } from "prosemirror-model"
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
if (schema.cached.tableNodeTypes) {
return schema.cached.tableNodeTypes
}
const roles: { [key: string]: NodeType } = {}
Object.keys(schema.nodes).forEach((type) => {
const nodeType = schema.nodes[type]
if (nodeType.spec.tableRole) {
roles[nodeType.spec.tableRole] = nodeType
}
})
schema.cached.tableNodeTypes = roles
return roles
}

View File

@ -0,0 +1,5 @@
import { CellSelection } from "@tiptap/prosemirror-tables"
export function isCellSelection(value: unknown): value is CellSelection {
return value instanceof CellSelection
}

View 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;
};

View 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;
};

View 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 };

View 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,
})

View File

@ -1,6 +1,6 @@
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
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 IMAGE_NODE_TYPE = "image";
@ -12,7 +12,7 @@ interface ImageNode extends ProseMirrorNode {
};
}
const TrackImageDeletionPlugin = (): Plugin =>
const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
@ -45,7 +45,7 @@ const TrackImageDeletionPlugin = (): Plugin =>
removedImages.forEach(async (node) => {
const src = node.attrs.src;
await onNodeDeleted(src);
await onNodeDeleted(src, deleteImage);
});
});
@ -55,10 +55,10 @@ const TrackImageDeletionPlugin = (): Plugin =>
export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string): Promise<void> {
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
const resStatus = await deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
}

View File

@ -1,6 +1,6 @@
import { UploadImage } from "../../types/upload-image";
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");
@ -57,7 +57,7 @@ export async function startImageUpload(
file: File,
view: EditorView,
pos: number,
workspaceSlug: string,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) {
if (!file.type.includes("image/")) {
@ -82,11 +82,8 @@ export async function startImageUpload(
view.dispatch(tr);
};
if (!workspaceSlug) {
return;
}
setIsSubmitting?.("submitting");
const src = await UploadImageHandler(file, workspaceSlug);
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
@ -100,28 +97,30 @@ export async function startImageUpload(
view.dispatch(transaction);
}
const UploadImageHandler = (file: File, workspaceSlug: string): Promise<string> => {
if (!workspaceSlug) {
return Promise.reject("Workspace slug is missing");
}
const UploadImageHandler = (file: File,
uploadFile: UploadImage
): Promise<string> => {
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);
try {
const imageUrl = await uploadFile(file)
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve(imageUrl);
};
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve(imageUrl);
};
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
reject(error);
}
});
} catch (error) {
console.log(error);
if (error instanceof Error) {
console.log(error.message);
}
return Promise.reject(error);
}
};

View File

@ -1,9 +1,10 @@
import { EditorProps } from "@tiptap/pm/view";
import { findTableAncestor } from "../lib/utils";
import { startImageUpload } from "./plugins/upload-image";
import { findTableAncestor } from "./table-menu";
import { UploadImage } from "../types/upload-image";
export function TiptapEditorProps(
workspaceSlug: string,
export function CoreEditorProps(
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
): EditorProps {
return {
@ -35,7 +36,7 @@ export function TiptapEditorProps(
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
startImageUpload(file, view, pos, workspaceSlug, setIsSubmitting);
startImageUpload(file, view, pos, uploadFile, setIsSubmitting);
return true;
}
return false;
@ -57,9 +58,8 @@ export function TiptapEditorProps(
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);
startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting);
}
return true;
}

View 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,
];

View 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`,
},
};

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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"
]
}

View File

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

View File

@ -0,0 +1,2 @@
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import { CustomListItem } from "./custom-list-extension";
import { EnterKeyExtension } from "./enter-key-extension";
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
CustomListItem,
EnterKeyExtension(onEnterKeyPress),
];

View 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 };

View File

@ -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>
);

View File

@ -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>
);
};

View 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 };

View 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 })
}
/>
);
};

View File

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

View File

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

View File

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

View File

@ -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 Editors 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" />
```

View 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"
]
}

View File

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

View File

@ -0,0 +1,4 @@
import "./styles/github-dark.css";
export { RichTextEditor, RichTextEditorWithRef } from "./ui";
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only";

View File

@ -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}

View 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,
}),
];

View File

@ -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 Suggestion from "@tiptap/suggestion";
import { ReactRenderer } from "@tiptap/react";
@ -17,8 +17,8 @@ import {
ImageIcon,
Table,
} from "lucide-react";
import { startImageUpload } from "../plugins/upload-image";
import { cn } from "../utils";
import { UploadImage } from "../";
import { cn, insertTableCommand, toggleBlockquote, toggleBulletList, toggleOrderedList, toggleTaskList, insertImageCommand, toggleHeadingOne, toggleHeadingTwo, toggleHeadingThree } from "@plane/editor-core";
interface CommandItemProps {
title: string;
@ -58,151 +58,128 @@ const Command = Extension.create({
const getSuggestionItems =
(
workspaceSlug: string,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) =>
({ query }: { query: string }) =>
[
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
({ query }: { query: string }) =>
[
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").run();
},
},
},
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
{
title: "Heading 1",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading1 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingOne(editor, range);
},
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingTwo(editor, range);
},
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleHeadingThree(editor, range);
},
},
},
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleTaskList(editor, range)
},
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleBulletList(editor, range);
},
},
},
{
title: "Divider",
description: "Visually divide blocks",
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
{
title: "Divider",
description: "Visually divide blocks",
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
icon: <MinusSquare size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
},
},
},
{
title: "Table",
description: "Create a Table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table size={18} />,
command: ({ editor, range }: CommandProps) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
.run();
{
title: "Table",
description: "Create a Table",
searchTerms: ["table", "cell", "db", "data", "tabular"],
icon: <Table size={18} />,
command: ({ editor, range }: CommandProps) => {
insertTableCommand(editor, range);
},
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }: CommandProps) => {
toggleOrderedList(editor, range)
},
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).run();
// upload image
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
startImageUpload(file, editor.view, pos, workspaceSlug, setIsSubmitting);
}
};
input.click();
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }: CommandProps) =>
toggleBlockquote(editor, range)
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
);
}
return true;
});
{
title: "Code",
description: "Capture a code snippet.",
searchTerms: ["codeblock"],
icon: <Code size={18} />,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }: CommandProps) => {
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
},
},
].filter((item) => {
if (typeof query === "string" && query.length > 0) {
const search = query.toLowerCase();
return (
item.title.toLowerCase().includes(search) ||
item.description.toLowerCase().includes(search) ||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
);
}
return true;
});
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
@ -312,13 +289,14 @@ const renderItems = () => {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
component = new ReactRenderer(CommandList, {
props,
// @ts-ignore
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector("#tiptap-container"),
appendTo: () => document.querySelector("#editor-container"),
content: component.element,
showOnCreate: true,
interactive: true,
@ -352,12 +330,12 @@ const renderItems = () => {
};
export const SlashCommand = (
workspaceSlug: string,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) =>
Command.configure({
suggestion: {
items: getSuggestionItems(workspaceSlug, setIsSubmitting),
items: getSuggestionItems(uploadFile, setIsSubmitting),
render: renderItems,
},
});

View 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};

View File

@ -1,10 +1,10 @@
import { BubbleMenu, BubbleMenuProps } from "@tiptap/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 { LinkSelector } from "./link-selector";
import { cn } from "../utils";
import { BoldItem, cn, CodeItem, ItalicItem, StrikeThroughItem, UnderLineItem } from "@plane/editor-core";
export interface BubbleMenuItem {
name: string;
@ -17,36 +17,11 @@ 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,
},
BoldItem(props.editor),
ItalicItem(props.editor),
UnderLineItem(props.editor),
StrikeThroughItem(props.editor),
CodeItem(props.editor),
];
const bubbleMenuProps: EditorBubbleMenuProps = {

View File

@ -1,8 +1,8 @@
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";
import { cn, isValidHttpUrl, setLinkEditor, unsetLinkEditor, } from "@plane/editor-core";
interface LinkSelectorProps {
editor: Editor;
isOpen: boolean;
@ -16,7 +16,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
const input = inputRef.current;
const url = input?.value;
if (url && isValidHttpUrl(url)) {
editor.chain().focus().setLink({ href: url }).run();
setLinkEditor(editor, url);
setIsOpen(false);
}
}, [editor, inputRef, setIsOpen]);
@ -68,7 +68,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
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();
unsetLinkEditor(editor);
setIsOpen(false);
}}
>

View File

@ -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 {
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;
@ -33,55 +26,14 @@ export const NodeSelector: FC<NodeSelectorProps> = ({ editor, isOpen, setIsOpen
!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"),
},
HeadingOneItem(editor),
HeadingTwoItem(editor),
HeadingThreeItem(editor),
TodoListItem(editor),
BulletListItem(editor),
NumberedListItem(editor),
QuoteItem(editor),
CodeItem(editor),
];
const activeItem = items.filter((item) => item.isActive()).pop() ?? {

View 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 };

View File

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

View File

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

View File

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

View File

@ -4,7 +4,12 @@
"description": "common tailwind configuration across monorepo",
"main": "index.js",
"devDependencies": {
"@tailwindcss/typography": "^0.5.10",
"tailwindcss-animate": "^1.0.7"
"@tailwindcss/typography": "^0.5.9",
"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"
}
}

View File

@ -1,14 +1,19 @@
const convertToRGB = (variableName) => `rgba(var(${variableName}))`;
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: [
"./components/**/*.tsx",
"./constants/**/*.{js,ts,jsx,tsx}",
"./layouts/**/*.tsx",
"./pages/**/*.tsx",
"./ui/**/*.tsx",
],
content: {
relative: true,
files: [
"./components/**/*.tsx",
"./constants/**/*.{js,ts,jsx,tsx}",
"./layouts/**/*.tsx",
"./pages/**/*.tsx",
"./ui/**/*.tsx",
"../packages/editor/**/*.{js,ts,jsx,tsx}"
]
},
theme: {
extend: {
boxShadow: {

View File

@ -16,5 +16,7 @@
"skipLibCheck": true,
"strict": true
},
"exclude": ["node_modules"]
"exclude": [
"node_modules"
]
}

View File

@ -3,8 +3,8 @@
"display": "React Library",
"extends": "./base.json",
"compilerOptions": {
"jsx": "react",
"lib": ["ES2015"],
"jsx": "react-jsx",
"lib": ["ES2015", "DOM"],
"module": "ESNext",
"target": "es6"
}

View File

@ -11,7 +11,9 @@ import { SecondaryButton } from "components/ui";
// types
import { Comment } from "types/issue";
// components
import { TipTapEditor } from "components/tiptap";
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
import fileService from "services/file.service";
// service
const defaultValues: Partial<Comment> = {
comment_html: "",
@ -69,8 +71,14 @@ export const AddComment: React.FC<Props> = observer((props) => {
name="comment_html"
control={control}
render={({ field: { value, onChange } }) => (
<TipTapEditor
workspaceSlug={workspace_slug as string}
<LiteTextEditorWithRef
onEnterKeyPress={(e) => {
userStore.requiredLogin(() => {
handleSubmit(onSubmit)(e);
});
}}
uploadFile={fileService.getUploadFileFunction(workspace_slug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}
value={
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)

View File

@ -9,7 +9,8 @@ import { Menu, Transition } from "@headlessui/react";
// lib
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { TipTapEditor } from "components/tiptap";
import { LiteReadOnlyEditorWithRef, LiteTextEditorWithRef } from "@plane/lite-text-editor";
import { CommentReactions } from "components/issues/peek-overview";
// icons
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";
// types
import { Comment } from "types/issue";
import fileService from "services/file.service";
// services
type Props = {
workspaceSlug: string;
@ -100,8 +103,10 @@ export const CommentCard: React.FC<Props> = observer((props) => {
control={control}
name="comment_html"
render={({ field: { onChange, value } }) => (
<TipTapEditor
workspaceSlug={workspaceSlug as string}
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
ref={editorRef}
value={value}
debouncedUpdatesEnabled={false}
@ -131,11 +136,9 @@ export const CommentCard: React.FC<Props> = observer((props) => {
</div>
</form>
<div className={`${isEditing ? "hidden" : ""}`}>
<TipTapEditor
workspaceSlug={workspaceSlug as string}
<LiteReadOnlyEditorWithRef
ref={showEditorRef}
value={comment.comment_html}
editable={false}
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
/>
<CommentReactions commentId={comment.id} projectId={comment.project} />
@ -147,7 +150,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
<Menu as="div" className="relative w-min text-left">
<Menu.Button
type="button"
onClick={() => {}}
onClick={() => { }}
className="relative grid place-items-center rounded p-1 text-custom-text-200 hover:text-custom-text-100 outline-none cursor-pointer hover:bg-custom-background-80"
>
<EllipsisVerticalIcon className="h-5 w-5 text-custom-text-200 duration-300" />
@ -171,9 +174,8 @@ export const CommentCard: React.FC<Props> = observer((props) => {
onClick={() => {
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 ${
active ? "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" : ""
}`}
>
Edit
</button>
@ -186,9 +188,8 @@ export const CommentCard: React.FC<Props> = observer((props) => {
<button
type="button"
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 ${
active ? "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" : ""
}`}
>
Delete
</button>

View File

@ -1,6 +1,5 @@
import { IssueReactions } from "components/issues/peek-overview";
import { TipTapEditor } from "components/tiptap";
import { useRouter } from "next/router";
import { RichReadOnlyEditor } from "@plane/rich-text-editor";
// types
import { IIssue } from "types/issue";
@ -8,33 +7,22 @@ type Props = {
issueDetails: IIssue;
};
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => {
const router = useRouter();
const { workspace_slug } = router.query;
return (
<div className="space-y-2">
<h6 className="font-medium text-custom-text-200">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
</h6>
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
<TipTapEditor
workspaceSlug={workspace_slug as string}
value={
!issueDetails.description_html ||
issueDetails.description_html === "" ||
(typeof issueDetails.description_html === "object" &&
Object.keys(issueDetails.description_html).length === 0)
? "<p></p>"
: issueDetails.description_html
}
customClassName="p-3 min-h-[50px] shadow-sm"
debouncedUpdatesEnabled={false}
editable={false}
/>
)}
<IssueReactions />
</div>
);
};
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => (
<div className="space-y-2">
<h6 className="font-medium text-custom-text-200">
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
</h6>
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
<RichReadOnlyEditor
value={!issueDetails.description_html ||
issueDetails.description_html === "" ||
(typeof issueDetails.description_html === "object" &&
Object.keys(issueDetails.description_html).length === 0)
? "<p></p>"
: issueDetails.description_html}
customClassName="p-3 min-h-[50px] shadow-sm" />
)}
<IssueReactions />
</div>
);

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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:";
}

View File

@ -1,149 +0,0 @@
import StarterKit from "@tiptap/starter-kit";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import TiptapLink from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
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 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 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 { Table } from "./table/table";
import { TableHeader } from "./table/table-header";
import { TableRow } from "@tiptap/extension-table-row";
lowlight.registerLanguage("ts", ts);
export const TiptapExtensions = (
workspaceSlug: string,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) => [
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,
}),
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,
TiptapLink.configure({
protocols: ["http", "https"],
validate: (url) => isValidHttpUrl(url),
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
UpdatedImage.configure({
HTMLAttributes: {
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,
TextStyle,
Color,
Highlight.configure({
multicolor: true,
}),
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,
];

View File

@ -1,32 +0,0 @@
import { TableCell } from "@tiptap/extension-table-cell";
export const CustomTableCell = TableCell.extend({
addAttributes() {
return {
...this.parent?.(),
isHeader: {
default: false,
parseHTML: (element) => {
isHeader: element.tagName === "TD";
},
renderHTML: (attributes) => {
tag: attributes.isHeader ? "th" : "td";
},
},
};
},
renderHTML({ HTMLAttributes }) {
if (HTMLAttributes.isHeader) {
return [
"th",
{
...HTMLAttributes,
class: `relative ${HTMLAttributes.class}`,
},
["span", { class: "absolute top-0 right-0" }],
0,
];
}
return ["td", HTMLAttributes, 0];
},
});

View File

@ -1,7 +0,0 @@
import { TableHeader as BaseTableHeader } from "@tiptap/extension-table-header";
const TableHeader = BaseTableHeader.extend({
content: "paragraph",
});
export { TableHeader };

View File

@ -1,9 +0,0 @@
import { Table as BaseTable } from "@tiptap/extension-table";
const Table = BaseTable.configure({
resizable: true,
cellMinWidth: 100,
allowTableNodeSelection: true,
});
export { Table };

View File

@ -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;

View File

@ -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 };

View File

@ -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);
}
}

View File

@ -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);
}
};

View File

@ -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;
},
};
}

View File

@ -1,16 +0,0 @@
const InsertBottomTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M212.309-152.31q-30.308 0-51.308-21t-21-51.307V-360q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307v135.383q0 30.307-21 51.307-21 21-51.308 21H212.309Zm0-375.383q-30.308 0-51.308-21t-21-51.307v-135.383q0-30.307 21-51.307 21-21 51.308-21h535.382q30.308 0 51.308 21t21 51.307V-600q0 30.307-21 51.307-21 21-51.308 21H212.309Zm535.382-219.998H212.309q-4.616 0-8.463 3.846-3.846 3.846-3.846 8.462V-600q0 4.616 3.846 8.462 3.847 3.847 8.463 3.847h535.382q4.616 0 8.463-3.847Q760-595.384 760-600v-135.383q0-4.616-3.846-8.462-3.847-3.846-8.463-3.846ZM200-587.691v-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertBottomTableIcon;

View File

@ -1,15 +0,0 @@
const InsertLeftTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M224.617-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm147.691-607.69q0-4.616-3.846-8.463-3.846-3.846-8.462-3.846H600q-4.616 0-8.462 3.846-3.847 3.847-3.847 8.463v535.382q0 4.616 3.847 8.463Q595.384-200 600-200h135.383q4.616 0 8.462-3.846 3.846-3.847 3.846-8.463v-535.382ZM587.691-200h160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertLeftTableIcon;

View File

@ -1,16 +0,0 @@
const InsertRightTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M600-140.001q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21h135.383q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H600Zm-375.383 0q-30.307 0-51.307-21-21-21-21-51.308v-535.382q0-30.308 21-51.308t51.307-21H360q30.307 0 51.307 21 21 21 21 51.308v535.382q0 30.308-21 51.308t-51.307 21H224.617Zm-12.308-607.69v535.382q0 4.616 3.846 8.463 3.846 3.846 8.462 3.846H360q4.616 0 8.462-3.846 3.847-3.847 3.847-8.463v-535.382q0-4.616-3.847-8.463Q364.616-760 360-760H224.617q-4.616 0-8.462 3.846-3.846 3.847-3.846 8.463Zm160 547.691h-160 160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertRightTableIcon;

Some files were not shown because too many files have changed in this diff Show More