fix: Image resize, Link selector in Modals, Delete/ sync images and so much more (#1896)

* added image-resizing support

* link form removed

* updated image upload logic and 35% default width on upload

* removed shadow, added alert if not saved and ts errors

* prevent enter key on Link selector to trigger modal submit

* added workspace slug to all tiptap instances

* added delete plugin with loading indicator

* added better syncing of "Saved" state of editor and Image uploads

* removed redundant description_html check
This commit is contained in:
M. Palanikannan 2023-08-19 18:58:54 +05:30 committed by GitHub
parent cebc8bdc8d
commit d470adf262
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 493 additions and 179 deletions

View file

@ -140,14 +140,14 @@ export const GptAssistantModal: React.FC<Props> = ({
return (
<div
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${
isOpen ? "block" : "hidden"
}`}
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-custom-border-200 bg-custom-background-100 p-4 shadow ${isOpen ? "block" : "hidden"
}`}
>
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
<div className="text-sm">
Content:
<TiptapEditor
workspaceSlug={workspaceSlug as string}
value={htmlContent ?? `<p>${content}</p>`}
customClassName="-m-3"
noBorder
@ -161,6 +161,7 @@ export const GptAssistantModal: React.FC<Props> = ({
<div className="page-block-section text-sm">
Response:
<Tiptap
workspaceSlug={workspaceSlug as string}
value={`<p>${response}</p>`}
customClassName="-mx-3 -my-3"
noBorder
@ -179,11 +180,10 @@ export const GptAssistantModal: React.FC<Props> = ({
type="text"
name="task"
register={register}
placeholder={`${
content && content !== ""
placeholder={`${content && content !== ""
? "Tell AI what action to perform on this content..."
: "Ask AI anything..."
}`}
}`}
autoComplete="off"
/>
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
@ -219,8 +219,8 @@ export const GptAssistantModal: React.FC<Props> = ({
{isSubmitting
? "Generating response..."
: response === ""
? "Generate response"
: "Generate again"}
? "Generate response"
: "Generate again"}
</PrimaryButton>
</div>
</div>

View file

@ -293,6 +293,7 @@ export const InboxMainContent: React.FC = () => {
</div>
<div>
<IssueDescriptionForm
workspaceSlug={workspaceSlug as string}
issue={{
name: issueDetails.name,
description: issueDetails.description,

View file

@ -175,6 +175,7 @@ export const IssueActivitySection: React.FC<Props> = ({ issueId, user }) => {
return (
<div key={activityItem.id} className="mt-4">
<CommentCard
workspaceSlug={workspaceSlug as string}
comment={activityItem as IIssueComment}
onSubmit={handleCommentUpdate}
handleCommentDeletion={handleCommentDelete}

View file

@ -93,11 +93,12 @@ export const AddComment: React.FC<Props> = ({ issueId, user, disabled = false })
control={control}
render={({ field: { value, onChange } }) => (
<TiptapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? watch("comment_html")
: value
}

View file

@ -22,12 +22,13 @@ const TiptapEditor = React.forwardRef<ITiptapRichTextEditor, ITiptapRichTextEdit
TiptapEditor.displayName = "TiptapEditor";
type Props = {
workspaceSlug: string;
comment: IIssueComment;
onSubmit: (comment: IIssueComment) => void;
handleCommentDeletion: (comment: string) => void;
};
export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentDeletion }) => {
export const CommentCard: React.FC<Props> = ({ comment, workspaceSlug, onSubmit, handleCommentDeletion }) => {
const { user } = useUser();
const editorRef = React.useRef<any>(null);
@ -109,6 +110,7 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
>
<div>
<TiptapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={watch("comment_html")}
debouncedUpdatesEnabled={false}
@ -138,6 +140,7 @@ export const CommentCard: React.FC<Props> = ({ comment, onSubmit, handleCommentD
</form>
<div className={`${isEditing ? "hidden" : ""}`}>
<TiptapEditor
workspaceSlug={workspaceSlug as string}
ref={showEditorRef}
value={comment.comment_html}
editable={false}

View file

@ -24,6 +24,7 @@ export interface IssueDetailsProps {
description: string;
description_html: string;
};
workspaceSlug: string;
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
isAllowed: boolean;
}
@ -31,6 +32,7 @@ export interface IssueDetailsProps {
export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
issue,
handleFormSubmit,
workspaceSlug,
isAllowed,
}) => {
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
@ -69,11 +71,15 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
useEffect(() => {
if (isSubmitting === "submitted") {
setShowAlert(false);
setTimeout(async () => {
setIsSubmitting("saved");
}, 2000);
} else if (isSubmitting === "submitting") {
setShowAlert(true);
}
}, [isSubmitting]);
}, [isSubmitting, setShowAlert]);
// reset form values
useEffect(() => {
@ -112,9 +118,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
{characterLimit && (
<div className="pointer-events-none absolute bottom-1 right-1 z-[2] rounded bg-custom-background-100 text-custom-text-200 p-0.5 text-xs">
<span
className={`${
watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
}`}
className={`${watch("name").length === 0 || watch("name").length > 255 ? "text-red-500" : ""
}`}
>
{watch("name").length}
</span>
@ -134,16 +139,19 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
<Tiptap
value={
!value ||
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
value === "" ||
(typeof value === "object" && Object.keys(value).length === 0)
? watch("description_html")
: value
}
workspaceSlug={workspaceSlug}
debouncedUpdatesEnabled={true}
setShouldShowAlert={setShowAlert}
setIsSubmitting={setIsSubmitting}
customClassName="min-h-[150px] shadow-sm"
editorContentCustomClassNames="pb-9"
onChange={(description: Object, description_html: string) => {
setShowAlert(true);
setIsSubmitting("submitting");
onChange(description_html);
setValue("description", description);
@ -156,9 +164,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
}}
/>
<div
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</div>

View file

@ -370,6 +370,7 @@ export const IssueForm: FC<IssueFormProps> = ({
return (
<TiptapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
debouncedUpdatesEnabled={false}
value={

View file

@ -124,6 +124,7 @@ export const IssueMainContent: React.FC<Props> = ({
</div>
) : null}
<IssueDescriptionForm
workspaceSlug={workspaceSlug as string}
issue={issueDetails}
handleFormSubmit={submitChanges}
isAllowed={memberRole.isMember || memberRole.isOwner || !uneditable}

View file

@ -231,9 +231,9 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
description:
!data.description || data.description === ""
? {
type: "doc",
content: [{ type: "paragraph" }],
}
type: "doc",
content: [{ type: "paragraph" }],
}
: data.description,
description_html: data.description_html ?? "<p></p>",
});
@ -292,6 +292,7 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
if (!data)
return (
<TiptapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={"<p></p>"}
debouncedUpdatesEnabled={false}
@ -311,12 +312,11 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
return (
<TiptapEditor
workspaceSlug={workspaceSlug as string}
ref={editorRef}
value={
value && value !== "" && Object.keys(value).length > 0
? value
: watch("description_html") && watch("description_html") !== ""
? watch("description_html")
: { type: "doc", content: [{ type: "paragraph" }] }
}
debouncedUpdatesEnabled={false}
@ -334,9 +334,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
<div className="m-2 mt-6 flex">
<button
type="button"
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-80 ${
iAmFeelingLucky ? "cursor-wait bg-custom-background-90" : ""
}`}
className={`flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-custom-background-80 ${iAmFeelingLucky ? "cursor-wait bg-custom-background-90" : ""
}`}
onClick={handleAutoGenerateDescription}
disabled={iAmFeelingLucky}
>
@ -368,8 +367,8 @@ export const CreateUpdateBlockInline: React.FC<Props> = ({
? "Updating..."
: "Update block"
: isSubmitting
? "Adding..."
: "Add block"}
? "Adding..."
: "Add block"}
</PrimaryButton>
</div>
</form>

View file

@ -456,6 +456,7 @@ export const SinglePageBlock: React.FC<Props> = ({
{showBlockDetails
? block.description_html.length > 7 && (
<TiptapEditor
workspaceSlug={workspaceSlug as string}
value={block.description_html}
customClassName="text-sm min-h-[150px]"
noBorder

View file

@ -1,17 +1,27 @@
import { Editor } from "@tiptap/core";
import { Check, Trash } from "lucide-react";
import { Dispatch, FC, SetStateAction, useEffect, useRef } from "react";
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
import { cn } from "../utils";
import isValidHttpUrl from "./utils/link-validator";
interface LinkSelectorProps {
editor: Editor;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen }) => {
const inputRef = useRef<HTMLInputElement>(null);
const onLinkSubmit = useCallback(() => {
const input = inputRef.current;
const url = input?.value;
if (url && isValidHttpUrl(url)) {
editor.chain().focus().setLink({ href: url }).run();
setIsOpen(false);
}
}, [editor, inputRef, setIsOpen]);
useEffect(() => {
inputRef.current && inputRef.current?.focus();
});
@ -38,15 +48,13 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
</p>
</button>
{isOpen && (
<form
onSubmit={(e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const input = form.elements[0] as HTMLInputElement;
editor.chain().focus().setLink({ href: input.value }).run();
setIsOpen(false);
}}
<div
className="fixed top-full z-[99999] mt-1 flex w-60 overflow-hidden rounded border border-custom-border-300 bg-custom-background-100 dow-xl animate-in fade-in slide-in-from-top-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault(); onLinkSubmit();
}
}}
>
<input
ref={inputRef}
@ -57,6 +65,7 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
/>
{editor.getAttributes("link").href ? (
<button
type="button"
className="flex items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
onClick={() => {
editor.chain().focus().unsetLink().run();
@ -66,11 +75,15 @@ export const LinkSelector: FC<LinkSelectorProps> = ({ editor, isOpen, setIsOpen
<Trash className="h-4 w-4" />
</button>
) : (
<button className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90">
<button className="flex items-center rounded-sm p-1 text-custom-text-300 transition-all hover:bg-custom-background-90" type="button"
onClick={() => {
onLinkSubmit();
}}
>
<Check className="h-4 w-4" />
</button>
)}
</form>
</div>
)}
</div>
);

View file

@ -0,0 +1,12 @@
export default function isValidHttpUrl(string: string): boolean {
let url;
try {
url = new URL(string);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
}

View file

@ -0,0 +1,57 @@
import { Editor } from "@tiptap/react";
import Moveable from "react-moveable";
export const ImageResizer = ({ editor }: { editor: Editor }) => {
const updateMediaSize = () => {
const imageInfo = document.querySelector(
".ProseMirror-selectednode",
) as HTMLImageElement;
if (imageInfo) {
const selection = editor.state.selection;
editor.commands.setImage({
src: imageInfo.src,
width: Number(imageInfo.style.width.replace("px", "")),
height: Number(imageInfo.style.height.replace("px", "")),
} as any);
editor.commands.setNodeSelection(selection.from);
}
};
return (
<>
<Moveable
target={document.querySelector(".ProseMirror-selectednode") as any}
container={null}
origin={false}
edge={false}
throttleDrag={0}
keepRatio={true}
resizable={true}
throttleResize={0}
onResize={({
target,
width,
height,
delta,
}:
any) => {
delta[0] && (target!.style.width = `${width}px`);
delta[1] && (target!.style.height = `${height}px`);
}}
onResizeEnd={() => {
updateMediaSize();
}}
scalable={true}
renderDirections={["w", "e"]}
onScale={({
target,
transform,
}:
any) => {
target!.style.transform = transform;
}}
/>
</>
);
};

View file

@ -1,7 +1,6 @@
import StarterKit from "@tiptap/starter-kit";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import TiptapLink from "@tiptap/extension-link";
import TiptapImage from "@tiptap/extension-image";
import Placeholder from "@tiptap/extension-placeholder";
import TiptapUnderline from "@tiptap/extension-underline";
import TextStyle from "@tiptap/extension-text-style";
@ -18,18 +17,13 @@ import { InputRule } from "@tiptap/core";
import ts from "highlight.js/lib/languages/typescript";
import "highlight.js/styles/github-dark.css";
import UploadImagesPlugin from "../plugins/upload-image";
import UniqueID from "@tiptap-pro/extension-unique-id";
import UpdatedImage from "./updated-image";
import isValidHttpUrl from "../bubble-menu/utils/link-validator";
lowlight.registerLanguage("ts", ts);
const CustomImage = TiptapImage.extend({
addProseMirrorPlugins() {
return [UploadImagesPlugin()];
},
});
export const TiptapExtensions = [
export const TiptapExtensions = (workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@ -93,13 +87,14 @@ export const TiptapExtensions = [
},
}),
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",
},
}),
CustomImage.configure({
allowBase64: true,
UpdatedImage.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},
@ -117,7 +112,7 @@ export const TiptapExtensions = [
UniqueID.configure({
types: ["image"],
}),
SlashCommand,
SlashCommand(workspaceSlug, setIsSubmitting),
TiptapUnderline,
TextStyle,
Color,

View file

@ -0,0 +1,22 @@
import Image from "@tiptap/extension-image";
import TrackImageDeletionPlugin from "../plugins/delete-image";
import UploadImagesPlugin from "../plugins/upload-image";
const UpdatedImage = Image.extend({
addProseMirrorPlugins() {
return [UploadImagesPlugin(), TrackImageDeletionPlugin()];
},
addAttributes() {
return {
...this.parent?.(),
width: {
default: '35%',
},
height: {
default: null,
},
};
},
});
export default UpdatedImage;

View file

@ -1,14 +1,10 @@
// @ts-nocheck
import { useEditor, EditorContent, Editor } from "@tiptap/react";
import { useDebouncedCallback } from "use-debounce";
import { EditorBubbleMenu } from "./bubble-menu";
import { TiptapExtensions } from "./extensions";
import { TiptapEditorProps } from "./props";
import { Node } from "@tiptap/pm/model";
import { Editor as CoreEditor } from "@tiptap/core";
import { useCallback, useImperativeHandle, useRef } from "react";
import { EditorState } from "@tiptap/pm/state";
import fileService from "services/file.service";
import { useImperativeHandle, useRef } from "react";
import { ImageResizer } from "./extensions/image-resize";
export interface ITiptapRichTextEditor {
value: string;
@ -18,6 +14,8 @@ export interface ITiptapRichTextEditor {
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
workspaceSlug: string;
editable?: boolean;
forwardedRef?: any;
debouncedUpdatesEnabled?: boolean;
@ -30,22 +28,24 @@ const Tiptap = (props: ITiptapRichTextEditor) => {
forwardedRef,
editable,
setIsSubmitting,
setShouldShowAlert,
editorContentCustomClassNames,
value,
noBorder,
workspaceSlug,
borderOnFocus,
customClassName,
} = props;
const editor = useEditor({
editable: editable ?? true,
editorProps: TiptapEditorProps,
extensions: TiptapExtensions,
editorProps: TiptapEditorProps(workspaceSlug, setIsSubmitting),
extensions: TiptapExtensions(workspaceSlug, setIsSubmitting),
content: value,
onUpdate: async ({ editor }) => {
// for instant feedback loop
setIsSubmitting?.("submitting");
checkForNodeDeletions(editor);
setShouldShowAlert?.(true);
if (debouncedUpdatesEnabled) {
debouncedUpdates({ onChange, editor });
} else {
@ -65,45 +65,6 @@ const Tiptap = (props: ITiptapRichTextEditor) => {
},
}));
const previousState = useRef<EditorState>();
const onNodeDeleted = useCallback(async (node: Node) => {
if (node.type.name === "image") {
const assetUrlWithWorkspaceId = new URL(node.attrs.src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("file deleted successfully");
}
}
}, []);
const checkForNodeDeletions = useCallback(
(editor: CoreEditor) => {
const prevNodesById: Record<string, Node> = {};
previousState.current?.doc.forEach((node) => {
if (node.attrs.id) {
prevNodesById[node.attrs.id] = node;
}
});
const nodesById: Record<string, Node> = {};
editor.state?.doc.forEach((node) => {
if (node.attrs.id) {
nodesById[node.attrs.id] = node;
}
});
previousState.current = editor.state;
for (const [id, node] of Object.entries(prevNodesById)) {
if (nodesById[id] === undefined) {
onNodeDeleted(node);
}
}
},
[onNodeDeleted]
);
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
setTimeout(async () => {
if (onChange) {
@ -112,10 +73,9 @@ const Tiptap = (props: ITiptapRichTextEditor) => {
}, 500);
}, 1000);
const editorClassNames = `relative w-full max-w-screen-lg mt-2 p-3 relative focus:outline-none rounded-lg
${noBorder ? "" : "border border-custom-border-200"} ${
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
const editorClassNames = `relative w-full max-w-screen-lg sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md
${noBorder ? "" : "border border-custom-border-200"} ${borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0"
} ${customClassName}`;
if (!editor) return null;
editorRef.current = editor;
@ -131,6 +91,7 @@ const Tiptap = (props: ITiptapRichTextEditor) => {
{editor && <EditorBubbleMenu editor={editor} />}
<div className={`${editorContentCustomClassNames}`}>
<EditorContent editor={editor} />
{editor?.isActive("image") && <ImageResizer editor={editor} />}
</div>
</div>
);

View file

@ -0,0 +1,56 @@
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from '@tiptap/pm/model';
import fileService from "services/file.service";
const deleteKey = new PluginKey("delete-image");
const TrackImageDeletionPlugin = () =>
new Plugin({
key: deleteKey,
appendTransaction: (transactions, oldState, newState) => {
transactions.forEach((transaction) => {
if (!transaction.docChanged) return;
const removedImages: ProseMirrorNode[] = [];
oldState.doc.descendants((oldNode, oldPos) => {
if (oldNode.type.name !== 'image') return;
if (!newState.doc.resolve(oldPos).parent) return;
const newNode = newState.doc.nodeAt(oldPos);
// Check if the node has been deleted or replaced
if (!newNode || newNode.type.name !== 'image') {
// Check if the node still exists elsewhere in the document
let nodeExists = false;
newState.doc.descendants((node) => {
if (node.attrs.id === oldNode.attrs.id) {
nodeExists = true;
}
});
if (!nodeExists) {
removedImages.push(oldNode as ProseMirrorNode);
}
}
});
removedImages.forEach((node) => {
const src = node.attrs.src;
onNodeDeleted(src);
});
});
return null;
},
});
export default TrackImageDeletionPlugin;
async function onNodeDeleted(src: string) {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await fileService.deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
}
}

View file

@ -57,7 +57,7 @@ function findPlaceholder(state: EditorState, id: {}) {
return found.length ? found[0].from : null;
}
export async function startImageUpload(file: File, view: EditorView, pos: number) {
export async function startImageUpload(file: File, view: EditorView, pos: number, workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) {
if (!file.type.includes("image/")) {
return;
} else if (file.size / 1024 / 1024 > 20) {
@ -82,7 +82,11 @@ export async function startImageUpload(file: File, view: EditorView, pos: number
view.dispatch(tr);
};
const src = await UploadImageHandler(file);
if (!workspaceSlug) {
return;
}
setIsSubmitting?.("submitting")
const src = await UploadImageHandler(file, workspaceSlug);
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
@ -96,7 +100,10 @@ export async function startImageUpload(file: File, view: EditorView, pos: number
view.dispatch(transaction);
}
const UploadImageHandler = (file: File): Promise<string> => {
const UploadImageHandler = (file: File, workspaceSlug: string): Promise<string> => {
if (!workspaceSlug) {
return Promise.reject("Workspace slug is missing");
}
try {
const formData = new FormData();
formData.append("asset", file);
@ -104,7 +111,7 @@ const UploadImageHandler = (file: File): Promise<string> => {
return new Promise(async (resolve, reject) => {
const imageUrl = await fileService
.uploadFile("plane", formData)
.uploadFile(workspaceSlug, formData)
.then((response) => response.asset);
const image = new Image();

View file

@ -1,56 +1,56 @@
import { EditorProps } from "@tiptap/pm/view";
import { startImageUpload } from "./plugins/upload-image";
export const TiptapEditorProps: EditorProps = {
attributes: {
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
},
handleDOMEvents: {
keydown: (_view, event) => {
// prevent default event listeners from firing when slash command is active
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
export function TiptapEditorProps(workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorProps {
return {
attributes: {
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
},
},
handlePaste: (view, event) => {
if (
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files[0]
) {
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
startImageUpload(file, view, pos);
return true;
}
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
// here we deduct 1 from the pos or else the image will create an extra node
if (coordinates) {
startImageUpload(file, view, coordinates.pos - 1);
handleDOMEvents: {
keydown: (_view, event) => {
// prevent default event listeners from firing when slash command is active
if (["ArrowUp", "ArrowDown", "Enter"].includes(event.key)) {
const slashCommand = document.querySelector("#slash-command");
if (slashCommand) {
return true;
}
}
},
},
handlePaste: (view, event) => {
if (
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files[0]
) {
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
startImageUpload(file, view, pos, workspaceSlug, setIsSubmitting);
return true;
}
return true;
}
return false;
},
};
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files[0]
) {
event.preventDefault();
const file = event.dataTransfer.files[0];
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
// here we deduct 1 from the pos or else the image will create an extra node
if (coordinates) {
startImageUpload(file, view, coordinates.pos - 1, workspaceSlug, setIsSubmitting);
}
return true;
}
return false;
},
};
}

View file

@ -52,7 +52,7 @@ const Command = Extension.create({
},
});
const getSuggestionItems = ({ query }: { query: string }) =>
const getSuggestionItems = (workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) => ({ query }: { query: string }) =>
[
{
title: "Text",
@ -163,7 +163,7 @@ const getSuggestionItems = ({ query }: { query: string }) =>
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
startImageUpload(file, editor.view, pos);
startImageUpload(file, editor.view, pos, workspaceSlug, setIsSubmitting);
}
};
input.click();
@ -328,11 +328,12 @@ const renderItems = () => {
};
};
const SlashCommand = Command.configure({
suggestion: {
items: getSuggestionItems,
render: renderItems,
},
});
export const SlashCommand = (workspaceSlug: string, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void) =>
Command.configure({
suggestion: {
items: getSuggestionItems(workspaceSlug, setIsSubmitting),
render: renderItems,
},
});
export default SlashCommand;