forked from github/plane
[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:
parent
faaba45e59
commit
206f5744a3
@ -3,9 +3,16 @@ import TrackImageDeletionPlugin from "../../plugins/delete-image";
|
||||
import UploadImagesPlugin from "../../plugins/upload-image";
|
||||
import { DeleteImage } from "../../../types/delete-image";
|
||||
|
||||
const ImageExtension = (deleteImage: DeleteImage) => Image.extend({
|
||||
const ImageExtension = (
|
||||
deleteImage: DeleteImage,
|
||||
cancelUploadImage?: () => any,
|
||||
) =>
|
||||
Image.extend({
|
||||
addProseMirrorPlugins() {
|
||||
return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)];
|
||||
return [
|
||||
UploadImagesPlugin(cancelUploadImage),
|
||||
TrackImageDeletionPlugin(deleteImage),
|
||||
];
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
|
@ -20,10 +20,13 @@ 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: {
|
||||
@ -70,7 +73,7 @@ export const CoreEditorExtensions = (
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
ImageExtension(deleteFile).configure({
|
||||
ImageExtension(deleteFile, cancelUploadImage).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-lg border border-custom-border-300",
|
||||
},
|
||||
@ -97,5 +100,9 @@ export const CoreEditorExtensions = (
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
|
||||
Mentions(
|
||||
mentionConfig.mentionSuggestions,
|
||||
mentionConfig.mentionHighlights,
|
||||
false,
|
||||
),
|
||||
];
|
||||
|
@ -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,
|
||||
|
@ -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 }) => {
|
||||
@ -83,3 +95,4 @@ export const useEditor = ({
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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 })
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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);
|
||||
|
@ -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,7 +133,16 @@ export async function startImageUpload(
|
||||
view.dispatch(tr);
|
||||
};
|
||||
|
||||
// Handle FileReader errors
|
||||
reader.onerror = (error) => {
|
||||
console.error("FileReader error: ", error);
|
||||
removePlaceholder(view, id);
|
||||
return;
|
||||
};
|
||||
|
||||
setIsSubmitting?.("submitting");
|
||||
|
||||
try {
|
||||
const src = await UploadImageHandler(file, uploadFile);
|
||||
const { schema } = view.state;
|
||||
pos = findPlaceholder(view.state, id);
|
||||
@ -95,15 +155,20 @@ export async function startImageUpload(
|
||||
.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);
|
||||
}
|
||||
};
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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,15 +96,18 @@ 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>
|
||||
);
|
||||
};
|
||||
|
||||
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";
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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) => {
|
||||
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();
|
||||
|
@ -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));
|
||||
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
|
@ -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) => {
|
||||
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();
|
||||
|
@ -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));
|
||||
|
Loading…
Reference in New Issue
Block a user