[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 UploadImagesPlugin from "../../plugins/upload-image";
import { DeleteImage } from "../../../types/delete-image"; import { DeleteImage } from "../../../types/delete-image";
const ImageExtension = (deleteImage: DeleteImage) => Image.extend({ const ImageExtension = (
addProseMirrorPlugins() { deleteImage: DeleteImage,
return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)]; cancelUploadImage?: () => any,
}, ) =>
addAttributes() { Image.extend({
return { addProseMirrorPlugins() {
...this.parent?.(), return [
width: { UploadImagesPlugin(cancelUploadImage),
default: "35%", TrackImageDeletionPlugin(deleteImage),
}, ];
height: { },
default: null, addAttributes() {
}, return {
}; ...this.parent?.(),
}, width: {
}); default: "35%",
},
height: {
default: null,
},
};
},
});
export default ImageExtension; export default ImageExtension;

View File

@ -20,82 +20,89 @@ import { isValidHttpUrl } from "../../lib/utils";
import { IMentionSuggestion } from "../../types/mention-suggestion"; import { IMentionSuggestion } from "../../types/mention-suggestion";
import { Mentions } from "../mentions"; import { Mentions } from "../mentions";
export const CoreEditorExtensions = ( export const CoreEditorExtensions = (
mentionConfig: { mentionSuggestions: IMentionSuggestion[], mentionHighlights: string[] }, mentionConfig: {
mentionSuggestions: IMentionSuggestion[];
mentionHighlights: string[];
},
deleteFile: DeleteImage, deleteFile: DeleteImage,
cancelUploadImage?: () => any,
) => [ ) => [
StarterKit.configure({ StarterKit.configure({
bulletList: { bulletList: {
HTMLAttributes: { HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2", class: "list-disc list-outside leading-3 -mt-2",
},
}, },
orderedList: { },
HTMLAttributes: { orderedList: {
class: "list-decimal list-outside leading-3 -mt-2", HTMLAttributes: {
}, class: "list-decimal list-outside leading-3 -mt-2",
}, },
listItem: { },
HTMLAttributes: { listItem: {
class: "leading-normal -mb-2", HTMLAttributes: {
}, class: "leading-normal -mb-2",
}, },
blockquote: { },
HTMLAttributes: { blockquote: {
class: "border-l-4 border-custom-border-300", HTMLAttributes: {
}, class: "border-l-4 border-custom-border-300",
}, },
code: { },
HTMLAttributes: { code: {
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: { HTMLAttributes: {
class: 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({ codeBlock: false,
HTMLAttributes: { horizontalRule: false,
class: "rounded-lg border border-custom-border-300", dropcursor: {
}, color: "rgba(var(--color-text-100))",
}), width: 2,
TiptapUnderline, },
TextStyle, gapcursor: false,
Color, }),
TaskList.configure({ Gapcursor,
HTMLAttributes: { TiptapLink.configure({
class: "not-prose pl-2", protocols: ["http", "https"],
}, validate: (url) => isValidHttpUrl(url),
}), HTMLAttributes: {
TaskItem.configure({ class:
HTMLAttributes: { "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
class: "flex items-start my-4", },
}, }),
nested: true, ImageExtension(deleteFile, cancelUploadImage).configure({
}), HTMLAttributes: {
Markdown.configure({ class: "rounded-lg border border-custom-border-300",
html: true, },
transformCopiedText: true, }),
}), TiptapUnderline,
Table, TextStyle,
TableHeader, Color,
TableCell, TaskList.configure({
TableRow, HTMLAttributes: {
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false), 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", "div",
{ {
className: "toolboxItem", className: "toolboxItem",
itemType: "button",
onClick() { onClick() {
onClickItem(item); onClickItem(item);
}, },
@ -253,6 +254,7 @@ function createColorPickerToolbox({
"div", "div",
{ {
className: "toolboxItem", className: "toolboxItem",
itemType: "button",
onClick: () => { onClick: () => {
onSelectColor(value); onSelectColor(value);
colorPicker.hide(); colorPicker.hide();
@ -331,7 +333,9 @@ export class TableView implements NodeView {
this.rowsControl = h( this.rowsControl = h(
"div", "div",
{ className: "rowsControl" }, { className: "rowsControl" },
h("button", { h("div", {
itemType: "button",
className: "rowsControlDiv",
onClick: () => this.selectRow(), onClick: () => this.selectRow(),
}), }),
); );
@ -339,7 +343,9 @@ export class TableView implements NodeView {
this.columnsControl = h( this.columnsControl = h(
"div", "div",
{ className: "columnsControl" }, { className: "columnsControl" },
h("button", { h("div", {
itemType: "button",
className: "columnsControlDiv",
onClick: () => this.selectColumn(), onClick: () => this.selectColumn(),
}), }),
); );
@ -352,7 +358,7 @@ export class TableView implements NodeView {
); );
this.columnsToolbox = createToolbox({ this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector("button"), triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
items: columnsToolboxItems, items: columnsToolboxItems,
tippyOptions: { tippyOptions: {
...defaultTippyOptions, ...defaultTippyOptions,

View File

@ -29,11 +29,13 @@ interface CustomEditorProps {
forwardedRef?: any; forwardedRef?: any;
mentionHighlights?: string[]; mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[]; mentionSuggestions?: IMentionSuggestion[];
cancelUploadImage?: () => any;
} }
export const useEditor = ({ export const useEditor = ({
uploadFile, uploadFile,
deleteFile, deleteFile,
cancelUploadImage,
editorProps = {}, editorProps = {},
value, value,
extensions = [], extensions = [],
@ -42,7 +44,7 @@ export const useEditor = ({
forwardedRef, forwardedRef,
setShouldShowAlert, setShouldShowAlert,
mentionHighlights, mentionHighlights,
mentionSuggestions mentionSuggestions,
}: CustomEditorProps) => { }: CustomEditorProps) => {
const editor = useCustomEditor( const editor = useCustomEditor(
{ {
@ -50,7 +52,17 @@ export const useEditor = ({
...CoreEditorProps(uploadFile, setIsSubmitting), ...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps, ...editorProps,
}, },
extensions: [...CoreEditorExtensions({ mentionSuggestions: mentionSuggestions ?? [], mentionHighlights: mentionHighlights ?? []}, deleteFile), ...extensions], extensions: [
...CoreEditorExtensions(
{
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
},
deleteFile,
cancelUploadImage,
),
...extensions,
],
content: content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>", typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
onUpdate: async ({ editor }) => { onUpdate: async ({ editor }) => {
@ -82,4 +94,5 @@ export const useEditor = ({
} }
return editor; 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 => const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
new Plugin({ new Plugin({
key: deleteKey, key: deleteKey,
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { appendTransaction: (
transactions: readonly Transaction[],
oldState: EditorState,
newState: EditorState,
) => {
const newImageSources = new Set<string>(); const newImageSources = new Set<string>();
newState.doc.descendants((node) => { newState.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) { if (node.type.name === IMAGE_NODE_TYPE) {
@ -55,7 +59,10 @@ const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
export default TrackImageDeletionPlugin; export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> { async function onNodeDeleted(
src: string,
deleteImage: DeleteImage,
): Promise<void> {
try { try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await deleteImage(assetUrlWithWorkspaceId); 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 uploadKey = new PluginKey("upload-image");
const UploadImagesPlugin = () => const UploadImagesPlugin = (cancelUploadImage?: () => any) =>
new Plugin({ new Plugin({
key: uploadKey, key: uploadKey,
state: { state: {
@ -21,15 +21,46 @@ const UploadImagesPlugin = () =>
const placeholder = document.createElement("div"); const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder"); placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img"); 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; image.src = src;
placeholder.appendChild(image); 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, { const deco = Decoration.widget(pos + 1, placeholder, {
id, id,
}); });
set = set.add(tr.doc, [deco]); set = set.add(tr.doc, [deco]);
} else if (action && action.remove) { } 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; return set;
}, },
@ -48,19 +79,39 @@ function findPlaceholder(state: EditorState, id: {}) {
const found = decos.find( const found = decos.find(
undefined, undefined,
undefined, undefined,
(spec: { id: number | undefined }) => spec.id == id (spec: { id: number | undefined }) => spec.id == id,
); );
return found.length ? found[0].from : null; 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( export async function startImageUpload(
file: File, file: File,
view: EditorView, view: EditorView,
pos: number, pos: number,
uploadFile: UploadImage, 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/")) { 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; return;
} }
@ -82,28 +133,42 @@ export async function startImageUpload(
view.dispatch(tr); view.dispatch(tr);
}; };
// Handle FileReader errors
reader.onerror = (error) => {
console.error("FileReader error: ", error);
removePlaceholder(view, id);
return;
};
setIsSubmitting?.("submitting"); setIsSubmitting?.("submitting");
const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) return; try {
const imageSrc = typeof src === "object" ? reader.result : src; const src = await UploadImageHandler(file, uploadFile);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
const node = schema.nodes.image.create({ src: imageSrc }); if (pos == null) return;
const transaction = view.state.tr const imageSrc = typeof src === "object" ? reader.result : src;
.replaceWith(pos, pos, node)
.setMeta(uploadKey, { remove: { id } }); const node = schema.nodes.image.create({ src: imageSrc });
view.dispatch(transaction); 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, const UploadImageHandler = (
uploadFile: UploadImage file: File,
uploadFile: UploadImage,
): Promise<string> => { ): Promise<string> => {
try { try {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
try { try {
const imageUrl = await uploadFile(file) const imageUrl = await uploadFile(file);
const image = new Image(); const image = new Image();
image.src = imageUrl; image.src = imageUrl;
@ -118,9 +183,6 @@ const UploadImageHandler = (file: File,
} }
}); });
} catch (error) { } catch (error) {
if (error instanceof Error) {
console.log(error.message);
}
return Promise.reject(error); return Promise.reject(error);
} }
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -92,7 +92,7 @@
transform: translateY(-50%); transform: translateY(-50%);
} }
.tableWrapper .tableControls .columnsControl > button { .tableWrapper .tableControls .columnsControl .columnsControlDiv {
color: white; 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"); 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; width: 30px;
@ -104,26 +104,42 @@
transform: translateX(-50%); transform: translateX(-50%);
} }
.tableWrapper .tableControls .rowsControl > button { .tableWrapper .tableControls .rowsControl .rowsControlDiv {
color: white; 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"); 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; height: 30px;
width: 15px; width: 15px;
} }
.tableWrapper .tableControls button { .tableWrapper .tableControls .rowsControlDiv {
background-color: rgba(var(--color-primary-100)); background-color: rgba(var(--color-primary-100));
border: 1px solid rgba(var(--color-border-200)); border: 1px solid rgba(var(--color-border-200));
border-radius: 2px; border-radius: 2px;
background-size: 1.25rem; background-size: 1.25rem;
background-repeat: no-repeat; background-repeat: no-repeat;
background-position: center; 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; outline: none;
box-shadow: #000 0px 2px 4px; box-shadow: #000 0px 2px 4px;
cursor: pointer; 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 .tableToolbox,
.tableWrapper .tableControls .tableColorPickerToolbox { .tableWrapper .tableControls .tableColorPickerToolbox {
border: 1px solid rgba(var(--color-border-300)); 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"; import { Button, Input, ToggleSwitch } from "@plane/ui";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
import useEditorSuggestions from "hooks/use-editor-suggestions";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -40,6 +41,8 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const editorSuggestion = useEditorSuggestions()
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, inboxId } = router.query; const { workspaceSlug, projectId, inboxId } = router.query;
@ -134,6 +137,7 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
control={control} control={control}
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<RichTextEditorWithRef <RichTextEditorWithRef
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)} uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage} deleteFile={fileService.deleteImage}
ref={editorRef} ref={editorRef}
@ -143,6 +147,8 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
onChange={(description, description_html: string) => { onChange={(description, description_html: string) => {
onChange(description_html); 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 } }) => ( render={({ field: { onChange: onCommentChange, value: commentValue } }) => (
<LiteTextEditorWithRef <LiteTextEditorWithRef
onEnterKeyPress={handleSubmit(handleAddComment)} onEnterKeyPress={handleSubmit(handleAddComment)}
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)} uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage} deleteFile={fileService.deleteImage}
ref={editorRef} ref={editorRef}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -92,7 +92,7 @@
transform: translateY(-50%); transform: translateY(-50%);
} }
.tableWrapper .tableControls .columnsControl > button { .tableWrapper .tableControls .columnsControl .columnsControlDiv {
color: white; 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"); 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; width: 30px;
@ -104,14 +104,14 @@
transform: translateX(-50%); transform: translateX(-50%);
} }
.tableWrapper .tableControls .rowsControl > button { .tableWrapper .tableControls .rowsControl .rowsControlDiv {
color: white; 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"); 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; height: 30px;
width: 15px; width: 15px;
} }
.tableWrapper .tableControls button { .tableWrapper .tableControls .rowsControlDiv {
background-color: rgba(var(--color-primary-100)); background-color: rgba(var(--color-primary-100));
border: 1px solid rgba(var(--color-border-200)); border: 1px solid rgba(var(--color-border-200));
border-radius: 2px; border-radius: 2px;
@ -124,6 +124,18 @@
cursor: pointer; 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 .tableToolbox,
.tableWrapper .tableControls .tableColorPickerToolbox { .tableWrapper .tableControls .tableColorPickerToolbox {
border: 1px solid rgba(var(--color-border-300)); border: 1px solid rgba(var(--color-border-300));