[fix]: Error Handling for Images and Table Fix for Form Submissions in Editor (#2710)

* cancellable uploads and image limits with better error handling

* fixed table row/column picker behaviour on modals

* Merge branch 'rerender-debounce-editor-fix' into editor-draggable-nodes

* fix: added mention suggestions and highlights in `create-issue-modal`

* removed uncessary files

* solved lint error of trailing spaces

* added plane/ui dependency for tooltips

---------

Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com>
This commit is contained in:
M. Palanikannan 2023-11-08 18:00:53 +05:30 committed by GitHub
parent faaba45e59
commit 206f5744a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 339 additions and 281 deletions

View File

@ -3,21 +3,28 @@ 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,
},
};
},
});
const ImageExtension = (
deleteImage: DeleteImage,
cancelUploadImage?: () => any,
) =>
Image.extend({
addProseMirrorPlugins() {
return [
UploadImagesPlugin(cancelUploadImage),
TrackImageDeletionPlugin(deleteImage),
];
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: "35%",
},
height: {
default: null,
},
};
},
});
export default ImageExtension;

View File

@ -20,82 +20,89 @@ import { isValidHttpUrl } from "../../lib/utils";
import { IMentionSuggestion } from "../../types/mention-suggestion";
import { Mentions } from "../mentions";
export const CoreEditorExtensions = (
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] },
mentionConfig: {
mentionSuggestions: IMentionSuggestion[];
mentionHighlights: string[];
},
deleteFile: DeleteImage,
cancelUploadImage?: () => any,
) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
},
blockquote: {
HTMLAttributes: {
class: "border-l-4 border-custom-border-300",
},
},
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),
},
code: {
HTMLAttributes: {
class:
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
spellcheck: "false",
},
}),
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,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, 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, cancelUploadImage).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,
Mentions(
mentionConfig.mentionSuggestions,
mentionConfig.mentionHighlights,
false,
),
];

View File

@ -202,6 +202,7 @@ function createToolbox({
"div",
{
className: "toolboxItem",
itemType: "button",
onClick() {
onClickItem(item);
},
@ -253,6 +254,7 @@ function createColorPickerToolbox({
"div",
{
className: "toolboxItem",
itemType: "button",
onClick: () => {
onSelectColor(value);
colorPicker.hide();
@ -331,7 +333,9 @@ export class TableView implements NodeView {
this.rowsControl = h(
"div",
{ className: "rowsControl" },
h("button", {
h("div", {
itemType: "button",
className: "rowsControlDiv",
onClick: () => this.selectRow(),
}),
);
@ -339,7 +343,9 @@ export class TableView implements NodeView {
this.columnsControl = h(
"div",
{ className: "columnsControl" },
h("button", {
h("div", {
itemType: "button",
className: "columnsControlDiv",
onClick: () => this.selectColumn(),
}),
);
@ -352,7 +358,7 @@ export class TableView implements NodeView {
);
this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector("button"),
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
items: columnsToolboxItems,
tippyOptions: {
...defaultTippyOptions,

View File

@ -29,11 +29,13 @@ interface CustomEditorProps {
forwardedRef?: any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
cancelUploadImage?: () => any;
}
export const useEditor = ({
uploadFile,
deleteFile,
cancelUploadImage,
editorProps = {},
value,
extensions = [],
@ -42,7 +44,7 @@ export const useEditor = ({
forwardedRef,
setShouldShowAlert,
mentionHighlights,
mentionSuggestions
mentionSuggestions,
}: CustomEditorProps) => {
const editor = useCustomEditor(
{
@ -50,7 +52,17 @@ export const useEditor = ({
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
extensions: [...CoreEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}, deleteFile), ...extensions],
extensions: [
...CoreEditorExtensions(
{
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
},
deleteFile,
cancelUploadImage,
),
...extensions,
],
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
onUpdate: async ({ editor }) => {
@ -82,4 +94,5 @@ export const useEditor = ({
}
return editor;
};
};

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;

View File

@ -1,15 +0,0 @@
const InsertTopTableIcon = (props: any) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width={24}
height={24}
viewBox="0 -960 960 960"
{...props}
>
<path
d="M212.309-527.693q-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.309Zm0 375.383q-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-59.999h535.382q4.616 0 8.463-3.846 3.846-3.846 3.846-8.462V-360q0-4.616-3.846-8.462-3.847-3.847-8.463-3.847H212.309q-4.616 0-8.463 3.847Q200-364.616 200-360v135.383q0 4.616 3.846 8.462 3.847 3.846 8.463 3.846Zm-12.309-160v160-160Z"
fill="rgb(var(--color-text-300))"
/>
</svg>
);
export default InsertTopTableIcon;

View File

@ -1,77 +0,0 @@
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

@ -15,7 +15,11 @@ interface ImageNode extends ProseMirrorNode {
const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
appendTransaction: (
transactions: readonly Transaction[],
oldState: EditorState,
newState: EditorState,
) => {
const newImageSources = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
@ -55,7 +59,10 @@ const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
async function onNodeDeleted(
src: string,
deleteImage: DeleteImage,
): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await deleteImage(assetUrlWithWorkspaceId);

View File

@ -4,7 +4,7 @@ import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
const uploadKey = new PluginKey("upload-image");
const UploadImagesPlugin = () =>
const UploadImagesPlugin = (cancelUploadImage?: () => any) =>
new Plugin({
key: uploadKey,
state: {
@ -21,15 +21,46 @@ const UploadImagesPlugin = () =>
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.setAttribute(
"class",
"opacity-10 rounded-lg border border-custom-border-300",
);
image.src = src;
placeholder.appendChild(image);
// Create cancel button
const cancelButton = document.createElement("button");
cancelButton.style.position = "absolute";
cancelButton.style.right = "3px";
cancelButton.style.top = "3px";
cancelButton.setAttribute("class", "opacity-90 rounded-lg");
cancelButton.onclick = () => {
cancelUploadImage?.();
};
// Create an SVG element from the SVG string
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-circle"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`;
const parser = new DOMParser();
const svgElement = parser.parseFromString(
svgString,
"image/svg+xml",
).documentElement;
cancelButton.appendChild(svgElement);
placeholder.appendChild(cancelButton);
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));
set = set.remove(
set.find(
undefined,
undefined,
(spec) => spec.id == action.remove.id,
),
);
}
return set;
},
@ -48,19 +79,39 @@ function findPlaceholder(state: EditorState, id: {}) {
const found = decos.find(
undefined,
undefined,
(spec: { id: number | undefined }) => spec.id == id
(spec: { id: number | undefined }) => spec.id == id,
);
return found.length ? found[0].from : null;
}
const removePlaceholder = (view: EditorView, id: {}) => {
const removePlaceholderTr = view.state.tr.setMeta(uploadKey, {
remove: { id },
});
view.dispatch(removePlaceholderTr);
};
export async function startImageUpload(
file: File,
view: EditorView,
pos: number,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void,
) {
if (!file) {
alert("No file selected. Please select a file to upload.");
return;
}
if (!file.type.includes("image/")) {
alert("Invalid file type. Please select an image file.");
return;
}
if (file.size > 5 * 1024 * 1024) {
alert("File size too large. Please select a file smaller than 5MB.");
return;
}
@ -82,28 +133,42 @@ export async function startImageUpload(
view.dispatch(tr);
};
// Handle FileReader errors
reader.onerror = (error) => {
console.error("FileReader error: ", error);
removePlaceholder(view, id);
return;
};
setIsSubmitting?.("submitting");
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) return;
const imageSrc = typeof src === "object" ? reader.result : src;
try {
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
const node = schema.nodes.image.create({ src: imageSrc });
const transaction = view.state.tr
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
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);
} catch (error) {
console.error("Upload error: ", error);
removePlaceholder(view, id);
}
}
const UploadImageHandler = (file: File,
uploadFile: UploadImage
const UploadImageHandler = (
file: File,
uploadFile: UploadImage,
): Promise<string> => {
try {
return new Promise(async (resolve, reject) => {
try {
const imageUrl = await uploadFile(file)
const imageUrl = await uploadFile(file);
const image = new Image();
image.src = imageUrl;
@ -118,9 +183,6 @@ const UploadImageHandler = (file: File,
}
});
} catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
return Promise.reject(error);
}
};

View File

@ -29,6 +29,7 @@
},
"dependencies": {
"@plane/editor-core": "*",
"@plane/ui": "*",
"@tiptap/extension-list-item": "^2.1.11",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",

View File

@ -47,6 +47,7 @@ interface ILiteTextEditor {
}[];
};
onEnterKeyPress?: (e?: any) => void;
cancelUploadImage?: () => any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
submitButton?: React.ReactNode;
@ -64,6 +65,7 @@ interface EditorHandle {
const LiteTextEditor = (props: LiteTextEditorProps) => {
const {
onChange,
cancelUploadImage,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
@ -84,6 +86,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
const editor = useEditor({
onChange,
cancelUploadImage,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,

View File

@ -14,8 +14,8 @@ import {
TableItem,
UnderLineItem,
} from "@plane/editor-core";
import { Tooltip } from "../../tooltip";
import { UploadImage } from "../..";
import { Tooltip } from "@plane/ui";
import { UploadImage } from "../../";
export interface BubbleMenuItem {
name: string;

View File

@ -1,8 +1,13 @@
"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';
"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>;
@ -14,9 +19,9 @@ export type IMentionSuggestion = {
title: string;
subtitle: string;
redirect_uri: string;
}
};
export type IMentionHighlight = string
export type IMentionHighlight = string;
interface IRichTextEditor {
value: string;
@ -24,10 +29,13 @@ interface IRichTextEditor {
deleteFile: DeleteImage;
noBorder?: boolean;
borderOnFocus?: boolean;
cancelUploadImage?: () => any;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
@ -54,11 +62,12 @@ const RichTextEditor = ({
uploadFile,
deleteFile,
noBorder,
cancelUploadImage,
borderOnFocus,
customClassName,
forwardedRef,
mentionHighlights,
mentionSuggestions
mentionSuggestions,
}: RichTextEditorProps) => {
const editor = useEditor({
onChange,
@ -67,14 +76,19 @@ const RichTextEditor = ({
setShouldShowAlert,
value,
uploadFile,
cancelUploadImage,
deleteFile,
forwardedRef,
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting),
mentionHighlights,
mentionSuggestions
mentionSuggestions,
});
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
});
if (!editor) return null;
@ -82,16 +96,19 @@ const RichTextEditor = ({
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
{editor && <EditorBubbleMenu editor={editor} />}
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<EditorContentWrapper
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
</div>
</EditorContainer >
</EditorContainer>
);
};
const RichTextEditorWithRef = React.forwardRef<EditorHandle, IRichTextEditor>((props, ref) => (
<RichTextEditor {...props} forwardedRef={ref} />
));
const RichTextEditorWithRef = React.forwardRef<EditorHandle, IRichTextEditor>(
(props, ref) => <RichTextEditor {...props} forwardedRef={ref} />,
);
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
export { RichTextEditor, RichTextEditorWithRef};
export { RichTextEditor, RichTextEditorWithRef };

View File

@ -76,6 +76,7 @@ export const AddComment: React.FC<Props> = observer((props) => {
handleSubmit(onSubmit)(e);
});
}}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspace_slug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}

View File

@ -103,6 +103,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
render={({ field: { onChange, value } }) => (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
ref={editorRef}

View File

@ -1,5 +1,6 @@
import APIService from "services/api.service";
import { API_BASE_URL } from "helpers/common.helper";
import axios from "axios";
interface UnSplashImage {
id: string;
@ -26,25 +27,37 @@ interface UnSplashImageUrls {
}
class FileService extends APIService {
private cancelSource: any;
constructor() {
super(API_BASE_URL);
this.uploadFile = this.uploadFile.bind(this);
this.deleteImage = this.deleteImage.bind(this);
this.cancelUpload = this.cancelUpload.bind(this);
}
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {
this.cancelSource = axios.CancelToken.source();
return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, {
headers: {
...this.getHeaders(),
"Content-Type": "multipart/form-data",
},
cancelToken: this.cancelSource.token,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
if (axios.isCancel(error)) {
console.log(error.message);
} else {
throw error?.response?.data;
}
});
}
cancelUpload() {
this.cancelSource.cancel("Upload cancelled");
}
getUploadFileFunction(workspaceSlug: string): (file: File) => Promise<string> {
return async (file: File) => {
const formData = new FormData();

View File

@ -92,7 +92,7 @@
transform: translateY(-50%);
}
.tableWrapper .tableControls .columnsControl > button {
.tableWrapper .tableControls .columnsControl .columnsControlDiv {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
width: 30px;
@ -104,26 +104,42 @@
transform: translateX(-50%);
}
.tableWrapper .tableControls .rowsControl > button {
.tableWrapper .tableControls .rowsControl .rowsControlDiv {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
height: 30px;
width: 15px;
}
.tableWrapper .tableControls button {
.tableWrapper .tableControls .rowsControlDiv {
background-color: rgba(var(--color-primary-100));
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition: transform ease-out 100ms, background-color ease-out 100ms;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: #000 0px 2px 4px;
cursor: pointer;
}
.tableWrapper .tableControls .columnsControlDiv {
background-color: rgba(var(--color-primary-100));
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: #000 0px 2px 4px;
cursor: pointer;
}
.tableWrapper .tableControls .tableToolbox,
.tableWrapper .tableControls .tableColorPickerToolbox {
border: 1px solid rgba(var(--color-border-300));

View File

@ -15,6 +15,7 @@ import { IssuePrioritySelect } from "components/issues/select";
import { Button, Input, ToggleSwitch } from "@plane/ui";
// types
import { IIssue } from "types";
import useEditorSuggestions from "hooks/use-editor-suggestions";
type Props = {
isOpen: boolean;
@ -40,6 +41,8 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const editorRef = useRef<any>(null);
const editorSuggestion = useEditorSuggestions()
const router = useRouter();
const { workspaceSlug, projectId, inboxId } = router.query;
@ -134,6 +137,7 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditorWithRef
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}
@ -143,6 +147,8 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
onChange={(description, description_html: string) => {
onChange(description_html);
}}
mentionSuggestions={editorSuggestion.mentionSuggestions}
mentionHighlights={editorSuggestion.mentionHighlights}
/>
)}
/>

View File

@ -84,6 +84,7 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleAddComment)}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}

View File

@ -105,6 +105,7 @@ export const CommentCard: React.FC<Props> = ({
<div>
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(onEnter)}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}

View File

@ -145,6 +145,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditor
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={value}

View File

@ -425,6 +425,7 @@ export const DraftIssueForm: FC<IssueFormProps> = (props) => {
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditorWithRef
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}

View File

@ -381,6 +381,7 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditorWithRef
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}

View File

@ -114,6 +114,7 @@ export const IssueCommentCard: React.FC<IIssueCommentCard> = (props) => {
<div>
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(formSubmit)}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}

View File

@ -85,6 +85,7 @@ export const IssueCommentEditor: React.FC<IIssueCommentEditor> = (props) => {
render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
<LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleAddComment)}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}

View File

@ -140,6 +140,7 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
</div>
<span>{errors.name ? errors.name.message : null}</span>
<RichTextEditor
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={issue?.description_html}

View File

@ -295,6 +295,7 @@ export const CreateUpdateBlockInline: FC<Props> = ({
if (!data)
return (
<RichTextEditorWithRef
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}
@ -316,6 +317,7 @@ export const CreateUpdateBlockInline: FC<Props> = ({
return (
<RichTextEditorWithRef
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
ref={editorRef}

View File

@ -420,6 +420,7 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails, showBl
{showBlockDetails
? block.description_html.length > 7 && (
<RichTextEditor
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
value={block.description_html}

View File

@ -2,6 +2,7 @@
import { APIService } from "services/api.service";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
import axios from "axios";
export interface UnSplashImage {
id: string;
@ -28,25 +29,38 @@ export interface UnSplashImageUrls {
}
export class FileService extends APIService {
private cancelSource: any;
constructor() {
super(API_BASE_URL);
this.uploadFile = this.uploadFile.bind(this);
this.deleteImage = this.deleteImage.bind(this);
this.cancelUpload = this.cancelUpload.bind(this);
}
async uploadFile(workspaceSlug: string, file: FormData): Promise<any> {
this.cancelSource = axios.CancelToken.source();
return this.post(`/api/workspaces/${workspaceSlug}/file-assets/`, file, {
headers: {
...this.getHeaders(),
"Content-Type": "multipart/form-data",
},
cancelToken: this.cancelSource.token,
})
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
if (axios.isCancel(error)) {
console.log(error.message);
} else {
throw error?.response?.data;
}
});
}
cancelUpload() {
this.cancelSource.cancel("Upload cancelled");
}
getUploadFileFunction(workspaceSlug: string): (file: File) => Promise<string> {
return async (file: File) => {
const formData = new FormData();

View File

@ -92,7 +92,7 @@
transform: translateY(-50%);
}
.tableWrapper .tableControls .columnsControl > button {
.tableWrapper .tableControls .columnsControl .columnsControlDiv {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
width: 30px;
@ -104,14 +104,14 @@
transform: translateX(-50%);
}
.tableWrapper .tableControls .rowsControl > button {
.tableWrapper .tableControls .rowsControl .rowsControlDiv {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
height: 30px;
width: 15px;
}
.tableWrapper .tableControls button {
.tableWrapper .tableControls .rowsControlDiv {
background-color: rgba(var(--color-primary-100));
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
@ -124,6 +124,18 @@
cursor: pointer;
}
.tableWrapper .tableControls .columnsControlDiv {
background-color: rgba(var(--color-primary-100));
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition: transform ease-out 100ms, background-color ease-out 100ms;
outline: none;
box-shadow: #000 0px 2px 4px;
cursor: pointer;
}
.tableWrapper .tableControls .tableToolbox,
.tableWrapper .tableControls .tableColorPickerToolbox {
border: 1px solid rgba(var(--color-border-300));