From ade6eded691a859c70771b0b902876e6a60d5212 Mon Sep 17 00:00:00 2001 From: "M. Palanikannan" <73993394+Palanikannan1437@users.noreply.github.com> Date: Wed, 29 May 2024 18:25:03 +0530 Subject: [PATCH] [WEB-1244] fix: add better image insertion and replacement logic in the editor (#4508) * fix: add better image insertion and replacement logic * refactor: image handling in editor * chore: remove passing uploadKey around * refactor: remove unused code * fix: redundant files removed * fix: add is editor ready to discard api to control behvaiours from our app * fix: focus issues and image insertion position when not using slash command * fix: import order fixed --- packages/editor/core/src/hooks/use-editor.tsx | 3 +- packages/editor/core/src/index.ts | 2 +- .../editor/core/src/lib/editor-commands.ts | 4 +- .../editor/core/src/types/editor-ref-api.ts | 1 + .../editor/core/src/ui/extensions/drop.tsx | 2 +- .../core/src/ui/extensions/image/index.tsx | 100 +-------- .../editor/core/src/ui/extensions/index.tsx | 3 + .../menus/menu-items/{index.tsx => index.ts} | 7 +- .../core/src/ui/plugins/delete-image.tsx | 73 ------- .../core/src/ui/plugins/image/constants.ts | 7 + .../core/src/ui/plugins/image/delete-image.ts | 54 +++++ .../ui/plugins/image/image-upload-handler.ts | 114 +++++++++++ .../src/ui/plugins/image/restore-image.ts | 57 ++++++ .../src/ui/plugins/image/types/image-node.ts | 13 ++ .../core/src/ui/plugins/image/upload-image.ts | 91 +++++++++ .../src/ui/plugins/image/utils/placeholder.ts | 16 ++ .../ui/plugins/image/utils/validate-file.ts | 19 ++ .../core/src/ui/plugins/upload-image.tsx | 189 ------------------ .../modals/create-edit-modal/create-root.tsx | 43 +++- web/components/issues/issue-modal/form.tsx | 43 +++- web/components/issues/peek-overview/view.tsx | 2 + web/hooks/use-keypress.tsx | 6 +- 22 files changed, 483 insertions(+), 366 deletions(-) rename packages/editor/core/src/ui/menus/menu-items/{index.tsx => index.ts} (97%) delete mode 100644 packages/editor/core/src/ui/plugins/delete-image.tsx create mode 100644 packages/editor/core/src/ui/plugins/image/constants.ts create mode 100644 packages/editor/core/src/ui/plugins/image/delete-image.ts create mode 100644 packages/editor/core/src/ui/plugins/image/image-upload-handler.ts create mode 100644 packages/editor/core/src/ui/plugins/image/restore-image.ts create mode 100644 packages/editor/core/src/ui/plugins/image/types/image-node.ts create mode 100644 packages/editor/core/src/ui/plugins/image/upload-image.ts create mode 100644 packages/editor/core/src/ui/plugins/image/utils/placeholder.ts create mode 100644 packages/editor/core/src/ui/plugins/image/utils/validate-file.ts delete mode 100644 packages/editor/core/src/ui/plugins/upload-image.tsx diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index 2d2e1662a..76071791b 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -147,7 +147,7 @@ export const useEditor = ({ const item = getEditorMenuItem(itemName); if (item) { if (item.key === "image") { - item.command(savedSelection); + item.command(savedSelectionRef.current); } else { item.command(); } @@ -186,6 +186,7 @@ export const useEditor = ({ if (!editorRef.current) return; scrollSummary(editorRef.current, marking); }, + isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false, setFocusAtPosition: (position: number) => { if (!editorRef.current || editorRef.current.isDestroyed) { console.error("Editor reference is not available or has been destroyed."); diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts index 86066eeba..493f02d2f 100644 --- a/packages/editor/core/src/index.ts +++ b/packages/editor/core/src/index.ts @@ -9,7 +9,7 @@ export { isCellSelection } from "src/ui/extensions/table/table/utilities/is-cell // utils export * from "src/lib/utils"; export * from "src/ui/extensions/table/table"; -export { startImageUpload } from "src/ui/plugins/upload-image"; +export { startImageUpload } from "src/ui/plugins/image/image-upload-handler"; // components export { EditorContainer } from "src/ui/components/editor-container"; diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index b82b1f354..911347e7f 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -1,5 +1,5 @@ import { Editor, Range } from "@tiptap/core"; -import { startImageUpload } from "src/ui/plugins/upload-image"; +import { startImageUpload } from "src/ui/plugins/image/image-upload-handler"; import { findTableAncestor } from "src/lib/utils"; import { Selection } from "@tiptap/pm/state"; import { UploadImage } from "src/types/upload-image"; @@ -194,7 +194,7 @@ export const insertImageCommand = ( if (range) editor.chain().focus().deleteRange(range).run(); const input = document.createElement("input"); input.type = "file"; - input.accept = "image/*"; + input.accept = ".jpeg, .jpg, .png, .webp, .svg"; input.onchange = async () => { if (input.files?.length) { const file = input.files[0]; diff --git a/packages/editor/core/src/types/editor-ref-api.ts b/packages/editor/core/src/types/editor-ref-api.ts index 4eed815d6..b15ae943d 100644 --- a/packages/editor/core/src/types/editor-ref-api.ts +++ b/packages/editor/core/src/types/editor-ref-api.ts @@ -15,4 +15,5 @@ export interface EditorRefApi extends EditorReadOnlyRefApi { isMenuItemActive: (itemName: EditorMenuItemNames) => boolean; onStateChange: (callback: () => void) => () => void; setFocusAtPosition: (position: number) => void; + isEditorReadyToDiscard: () => boolean; } diff --git a/packages/editor/core/src/ui/extensions/drop.tsx b/packages/editor/core/src/ui/extensions/drop.tsx index ed206bc42..4bf4e2625 100644 --- a/packages/editor/core/src/ui/extensions/drop.tsx +++ b/packages/editor/core/src/ui/extensions/drop.tsx @@ -1,7 +1,7 @@ import { Extension } from "@tiptap/core"; import { Plugin, PluginKey } from "prosemirror-state"; import { UploadImage } from "src/types/upload-image"; -import { startImageUpload } from "../plugins/upload-image"; +import { startImageUpload } from "src/ui/plugins/image/image-upload-handler"; export const DropHandlerExtension = (uploadFile: UploadImage) => Extension.create({ diff --git a/packages/editor/core/src/ui/extensions/image/index.tsx b/packages/editor/core/src/ui/extensions/image/index.tsx index b85100fe5..7ea12fb11 100644 --- a/packages/editor/core/src/ui/extensions/image/index.tsx +++ b/packages/editor/core/src/ui/extensions/image/index.tsx @@ -1,25 +1,16 @@ -import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { UploadImagesPlugin } from "src/ui/plugins/upload-image"; +import { UploadImagesPlugin } from "src/ui/plugins/image/upload-image"; import ImageExt from "@tiptap/extension-image"; -import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image"; +import { TrackImageDeletionPlugin } from "src/ui/plugins/image/delete-image"; import { DeleteImage } from "src/types/delete-image"; import { RestoreImage } from "src/types/restore-image"; import { insertLineBelowImageAction } from "./utilities/insert-line-below-image"; import { insertLineAboveImageAction } from "./utilities/insert-line-above-image"; +import { TrackImageRestorationPlugin } from "src/ui/plugins/image/restore-image"; +import { IMAGE_NODE_TYPE } from "src/ui/plugins/image/constants"; +import { ImageExtensionStorage } from "src/ui/plugins/image/types/image-node"; -interface ImageNode extends ProseMirrorNode { - attrs: { - src: string; - id: string; - }; -} - -const deleteKey = new PluginKey("delete-image"); -const IMAGE_NODE_TYPE = "image"; - -export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => void) => - ImageExt.extend({ +export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) => + ImageExt.extend({ addKeyboardShortcuts() { return { ArrowDown: insertLineBelowImageAction, @@ -29,77 +20,8 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma addProseMirrorPlugins() { return [ UploadImagesPlugin(this.editor, cancelUploadImage), - new Plugin({ - key: deleteKey, - appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const newImageSources = new Set(); - newState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { - newImageSources.add(node.attrs.src); - } - }); - - transactions.forEach((transaction) => { - // transaction could be a selection - if (!transaction.docChanged) return; - - const removedImages: ImageNode[] = []; - - // iterate through all the nodes in the old state - oldState.doc.descendants((oldNode, oldPos) => { - // if the node is not an image, then return as no point in checking - if (oldNode.type.name !== IMAGE_NODE_TYPE) return; - - // Check if the node has been deleted or replaced - if (!newImageSources.has(oldNode.attrs.src)) { - removedImages.push(oldNode as ImageNode); - } - }); - - removedImages.forEach(async (node) => { - const src = node.attrs.src; - this.storage.images.set(src, true); - await onNodeDeleted(src, deleteImage); - }); - }); - - return null; - }, - }), - new Plugin({ - key: new PluginKey("imageRestoration"), - appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const oldImageSources = new Set(); - oldState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { - oldImageSources.add(node.attrs.src); - } - }); - - transactions.forEach((transaction) => { - if (!transaction.docChanged) return; - - const addedImages: ImageNode[] = []; - - newState.doc.descendants((node, pos) => { - if (node.type.name !== IMAGE_NODE_TYPE) return; - if (pos < 0 || pos > newState.doc.content.size) return; - if (oldImageSources.has(node.attrs.src)) return; - addedImages.push(node as ImageNode); - }); - - addedImages.forEach(async (image) => { - const wasDeleted = this.storage.images.get(image.attrs.src); - if (wasDeleted === undefined) { - this.storage.images.set(image.attrs.src, false); - } else if (wasDeleted === true) { - await onNodeRestored(image.attrs.src, restoreFile); - } - }); - }); - return null; - }, - }), + TrackImageDeletionPlugin(this.editor, deleteImage), + TrackImageRestorationPlugin(this.editor, restoreImage), ]; }, @@ -113,7 +35,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma imageSources.forEach(async (src) => { try { const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreFile(assetUrlWithWorkspaceId); + await restoreImage(assetUrlWithWorkspaceId); } catch (error) { console.error("Error restoring image: ", error); } @@ -123,7 +45,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma // storage to keep track of image states Map addStorage() { return { - images: new Map(), + deletedImageSet: new Map(), uploadInProgress: false, }; }, diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 425ad89b0..2507aca36 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -141,8 +141,11 @@ export const CoreEditorExtensions = ({ placeholder: ({ editor, node }) => { if (node.type.name === "heading") return `Heading ${node.attrs.level}`; + if (editor.storage.image.uploadInProgress) return ""; + const shouldHidePlaceholder = editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); + if (shouldHidePlaceholder) return ""; if (placeholder) { diff --git a/packages/editor/core/src/ui/menus/menu-items/index.tsx b/packages/editor/core/src/ui/menus/menu-items/index.ts similarity index 97% rename from packages/editor/core/src/ui/menus/menu-items/index.tsx rename to packages/editor/core/src/ui/menus/menu-items/index.ts index 46b1ed92a..ab2ad8ed4 100644 --- a/packages/editor/core/src/ui/menus/menu-items/index.tsx +++ b/packages/editor/core/src/ui/menus/menu-items/index.ts @@ -239,8 +239,5 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag ]; } -export type EditorMenuItemNames = ReturnType extends (infer U)[] - ? U extends { key: infer N } - ? N - : never - : never; +export type EditorMenuItemNames = + ReturnType extends (infer U)[] ? (U extends { key: infer N } ? N : never) : never; diff --git a/packages/editor/core/src/ui/plugins/delete-image.tsx b/packages/editor/core/src/ui/plugins/delete-image.tsx deleted file mode 100644 index 03b4dbd10..000000000 --- a/packages/editor/core/src/ui/plugins/delete-image.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; -import { Node as ProseMirrorNode } from "@tiptap/pm/model"; -import { DeleteImage } from "src/types/delete-image"; -import { RestoreImage } from "src/types/restore-image"; - -const deleteKey = new PluginKey("delete-image"); -const IMAGE_NODE_TYPE = "image"; - -interface ImageNode extends ProseMirrorNode { - attrs: { - src: string; - id: string; - }; -} - -export const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin => - new Plugin({ - key: deleteKey, - appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - const newImageSources = new Set(); - newState.doc.descendants((node) => { - if (node.type.name === IMAGE_NODE_TYPE) { - newImageSources.add(node.attrs.src); - } - }); - - transactions.forEach((transaction) => { - if (!transaction.docChanged) return; - - const removedImages: ImageNode[] = []; - - oldState.doc.descendants((oldNode, oldPos) => { - if (oldNode.type.name !== IMAGE_NODE_TYPE) return; - if (oldPos < 0 || oldPos > newState.doc.content.size) return; - if (!newState.doc.resolve(oldPos).parent) return; - - const newNode = newState.doc.nodeAt(oldPos); - - // Check if the node has been deleted or replaced - if (!newNode || newNode.type.name !== IMAGE_NODE_TYPE) { - if (!newImageSources.has(oldNode.attrs.src)) { - removedImages.push(oldNode as ImageNode); - } - } - }); - - removedImages.forEach(async (node) => { - const src = node.attrs.src; - await onNodeDeleted(src, deleteImage); - }); - }); - - return null; - }, - }); - -export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { - try { - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await deleteImage(assetUrlWithWorkspaceId); - } catch (error) { - console.error("Error deleting image: ", error); - } -} - -export async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { - try { - const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); - await restoreImage(assetUrlWithWorkspaceId); - } catch (error) { - console.error("Error restoring image: ", error); - } -} diff --git a/packages/editor/core/src/ui/plugins/image/constants.ts b/packages/editor/core/src/ui/plugins/image/constants.ts new file mode 100644 index 000000000..72fae6710 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/constants.ts @@ -0,0 +1,7 @@ +import { PluginKey } from "@tiptap/pm/state"; + +export const uploadKey = new PluginKey("upload-image"); +export const deleteKey = new PluginKey("delete-image"); +export const restoreKey = new PluginKey("restore-image"); + +export const IMAGE_NODE_TYPE = "image"; diff --git a/packages/editor/core/src/ui/plugins/image/delete-image.ts b/packages/editor/core/src/ui/plugins/image/delete-image.ts new file mode 100644 index 000000000..645dda99e --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/delete-image.ts @@ -0,0 +1,54 @@ +import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; +import { DeleteImage } from "src/types/delete-image"; +import { Editor } from "@tiptap/core"; + +import { type ImageNode } from "src/ui/plugins/image/types/image-node"; +import { deleteKey, IMAGE_NODE_TYPE } from "src/ui/plugins/image/constants"; + +export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImage): Plugin => + new Plugin({ + key: deleteKey, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const newImageSources = new Set(); + newState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + newImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + // transaction could be a selection + if (!transaction.docChanged) return; + + const removedImages: ImageNode[] = []; + + // iterate through all the nodes in the old state + oldState.doc.descendants((oldNode) => { + // if the node is not an image, then return as no point in checking + if (oldNode.type.name !== IMAGE_NODE_TYPE) return; + + // Check if the node has been deleted or replaced + if (!newImageSources.has(oldNode.attrs.src)) { + removedImages.push(oldNode as ImageNode); + } + }); + + removedImages.forEach(async (node) => { + const src = node.attrs.src; + editor.storage.image.deletedImageSet.set(src, true); + await onNodeDeleted(src, deleteImage); + }); + }); + + return null; + }, + }); + +async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + await deleteImage(assetUrlWithWorkspaceId); + } catch (error) { + console.error("Error deleting image: ", error); + } +} diff --git a/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts b/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts new file mode 100644 index 000000000..0be22e0dd --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/image-upload-handler.ts @@ -0,0 +1,114 @@ +import { type UploadImage } from "src/types/upload-image"; + +// utilities +import { v4 as uuidv4 } from "uuid"; + +// types +import { isFileValid } from "src/ui/plugins/image/utils/validate-file"; +import { Editor } from "@tiptap/core"; +import { EditorView } from "@tiptap/pm/view"; +import { uploadKey } from "./constants"; +import { removePlaceholder, findPlaceholder } from "./utils/placeholder"; + +export async function startImageUpload( + editor: Editor, + file: File, + view: EditorView, + pos: number | null, + uploadFile: UploadImage +) { + editor.storage.image.uploadInProgress = true; + + if (!isFileValid(file)) { + editor.storage.image.uploadInProgress = false; + return; + } + + const id = uuidv4(); + + const tr = view.state.tr; + if (!tr.selection.empty) tr.deleteSelection(); + + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => { + tr.setMeta(uploadKey, { + add: { + id, + pos, + src: reader.result, + }, + }); + view.dispatch(tr); + }; + + // Handle FileReader errors + reader.onerror = (error) => { + console.error("FileReader error: ", error); + removePlaceholder(editor, view, id); + return; + }; + + try { + view.focus(); + + const src = await uploadAndValidateImage(file, uploadFile); + + if (src == null) { + throw new Error("Resolved image URL is undefined."); + } + + const { schema } = view.state; + pos = findPlaceholder(view.state, id); + + if (pos == null) { + editor.storage.image.uploadInProgress = false; + return; + } + const imageSrc = typeof src === "object" ? reader.result : src; + + const node = schema.nodes.image.create({ src: imageSrc }); + + if (pos < 0 || pos > view.state.doc.content.size) { + throw new Error("Invalid position to insert the image node."); + } + + // insert the image node at the position of the placeholder and remove the placeholder + const transaction = view.state.tr.insert(pos, node).setMeta(uploadKey, { remove: { id } }); + + view.dispatch(transaction); + + editor.storage.image.uploadInProgress = false; + } catch (error) { + console.error("Error in uploading and inserting image: ", error); + removePlaceholder(editor, view, id); + } +} + +async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Promise { + try { + const imageUrl = await uploadFile(file); + + if (imageUrl == null) { + throw new Error("Image URL is undefined."); + } + + await new Promise((resolve, reject) => { + const image = new Image(); + image.src = imageUrl; + image.onload = () => { + resolve(); + }; + image.onerror = (error) => { + console.error("Error in loading image: ", error); + reject(error); + }; + }); + + return imageUrl; + } catch (error) { + console.error("Error in uploading image: ", error); + // throw error to remove the placeholder + throw error; + } +} diff --git a/packages/editor/core/src/ui/plugins/image/restore-image.ts b/packages/editor/core/src/ui/plugins/image/restore-image.ts new file mode 100644 index 000000000..61a7a7a34 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/restore-image.ts @@ -0,0 +1,57 @@ +import { Editor } from "@tiptap/core"; +import { EditorState, Plugin, Transaction } from "@tiptap/pm/state"; +import { RestoreImage } from "src/types/restore-image"; + +import { restoreKey, IMAGE_NODE_TYPE } from "./constants"; +import { type ImageNode } from "./types/image-node"; + +export const TrackImageRestorationPlugin = (editor: Editor, restoreImage: RestoreImage): Plugin => + new Plugin({ + key: restoreKey, + appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { + const oldImageSources = new Set(); + oldState.doc.descendants((node) => { + if (node.type.name === IMAGE_NODE_TYPE) { + oldImageSources.add(node.attrs.src); + } + }); + + transactions.forEach((transaction) => { + if (!transaction.docChanged) return; + + const addedImages: ImageNode[] = []; + + newState.doc.descendants((node, pos) => { + if (node.type.name !== IMAGE_NODE_TYPE) return; + if (pos < 0 || pos > newState.doc.content.size) return; + if (oldImageSources.has(node.attrs.src)) return; + addedImages.push(node as ImageNode); + }); + + addedImages.forEach(async (image) => { + const wasDeleted = editor.storage.image.deletedImageSet.get(image.attrs.src); + if (wasDeleted === undefined) { + editor.storage.image.deletedImageSet.set(image.attrs.src, false); + } else if (wasDeleted === true) { + try { + await onNodeRestored(image.attrs.src, restoreImage); + editor.storage.image.deletedImageSet.set(image.attrs.src, false); + } catch (error) { + console.error("Error restoring image: ", error); + } + } + }); + }); + return null; + }, + }); + +async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise { + try { + const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); + await restoreImage(assetUrlWithWorkspaceId); + } catch (error) { + console.error("Error restoring image: ", error); + throw error; + } +} diff --git a/packages/editor/core/src/ui/plugins/image/types/image-node.ts b/packages/editor/core/src/ui/plugins/image/types/image-node.ts new file mode 100644 index 000000000..67afc8315 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/types/image-node.ts @@ -0,0 +1,13 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; + +export interface ImageNode extends ProseMirrorNode { + attrs: { + src: string; + id: string; + }; +} + +export type ImageExtensionStorage = { + deletedImageSet: Map; + uploadInProgress: boolean; +}; diff --git a/packages/editor/core/src/ui/plugins/image/upload-image.ts b/packages/editor/core/src/ui/plugins/image/upload-image.ts new file mode 100644 index 000000000..554e37de2 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/upload-image.ts @@ -0,0 +1,91 @@ +import { Editor } from "@tiptap/core"; +import { Plugin } from "@tiptap/pm/state"; +import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; + +// utils +import { removePlaceholder } from "src/ui/plugins/image/utils/placeholder"; + +// constants +import { uploadKey } from "src/ui/plugins/image/constants"; + +export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => { + let currentView: EditorView | null = null; + + const createPlaceholder = (src: string): HTMLElement => { + const placeholder = document.createElement("div"); + placeholder.setAttribute("class", "img-placeholder"); + const image = document.createElement("img"); + image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300"); + image.src = src; + placeholder.appendChild(image); + + return placeholder; + }; + + const createCancelButton = (id: string): HTMLButtonElement => { + const cancelButton = document.createElement("button"); + cancelButton.type = "button"; + cancelButton.style.position = "absolute"; + cancelButton.style.right = "3px"; + cancelButton.style.top = "3px"; + cancelButton.setAttribute("class", "opacity-90 rounded-lg"); + + cancelButton.onclick = () => { + if (currentView) { + cancelUploadImage?.(); + removePlaceholder(editor, currentView, id); + } + }; + + // Create an SVG element from the SVG string + const svgString = ``; + const parser = new DOMParser(); + const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement; + + cancelButton.appendChild(svgElement); + + return cancelButton; + }; + + return new Plugin({ + key: uploadKey, + view(editorView) { + currentView = editorView; + return { + destroy() { + currentView = null; + }, + }; + }, + state: { + init() { + return DecorationSet.empty; + }, + apply(tr, set) { + set = set.map(tr.mapping, tr.doc); + const action = tr.getMeta(uploadKey); + if (action && action.add) { + const { id, pos, src } = action.add; + + const placeholder = createPlaceholder(src); + const cancelButton = createCancelButton(id); + + placeholder.appendChild(cancelButton); + + const deco = Decoration.widget(pos, placeholder, { + id, + }); + set = set.add(tr.doc, [deco]); + } else if (action && action.remove) { + set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id)); + } + return set; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, + }); +}; diff --git a/packages/editor/core/src/ui/plugins/image/utils/placeholder.ts b/packages/editor/core/src/ui/plugins/image/utils/placeholder.ts new file mode 100644 index 000000000..9636da4a7 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/utils/placeholder.ts @@ -0,0 +1,16 @@ +import { Editor } from "@tiptap/core"; +import { EditorState } from "@tiptap/pm/state"; +import { DecorationSet, EditorView } from "@tiptap/pm/view"; +import { uploadKey } from "src/ui/plugins/image/constants"; + +export function findPlaceholder(state: EditorState, id: string): number | null { + const decos = uploadKey.getState(state) as DecorationSet; + const found = decos.find(undefined, undefined, (spec: { id: string }) => spec.id === id); + return found.length ? found[0].from : null; +} + +export function removePlaceholder(editor: Editor, view: EditorView, id: string) { + const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { remove: { id } }); + view.dispatch(removePlaceholderTr); + editor.storage.image.uploadInProgress = false; +} diff --git a/packages/editor/core/src/ui/plugins/image/utils/validate-file.ts b/packages/editor/core/src/ui/plugins/image/utils/validate-file.ts new file mode 100644 index 000000000..a7952a0e1 --- /dev/null +++ b/packages/editor/core/src/ui/plugins/image/utils/validate-file.ts @@ -0,0 +1,19 @@ +export function isFileValid(file: File): boolean { + if (!file) { + alert("No file selected. Please select a file to upload."); + return false; + } + + const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/svg+xml"]; + if (!allowedTypes.includes(file.type)) { + alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP, or SVG image file."); + return false; + } + + if (file.size > 5 * 1024 * 1024) { + alert("File size too large. Please select a file smaller than 5MB."); + return false; + } + + return true; +} diff --git a/packages/editor/core/src/ui/plugins/upload-image.tsx b/packages/editor/core/src/ui/plugins/upload-image.tsx deleted file mode 100644 index 7a370da4e..000000000 --- a/packages/editor/core/src/ui/plugins/upload-image.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { Editor } from "@tiptap/core"; -import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state"; -import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; -import { UploadImage } from "src/types/upload-image"; - -const uploadKey = new PluginKey("upload-image"); - -export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => { - let currentView: EditorView | null = null; - return new Plugin({ - key: uploadKey, - view(editorView) { - currentView = editorView; - return { - destroy() { - currentView = null; - }, - }; - }, - state: { - init() { - return DecorationSet.empty; - }, - apply(tr, set) { - set = set.map(tr.mapping, tr.doc); - // See if the transaction adds or removes any placeholders - const action = tr.getMeta(uploadKey); - if (action && action.add) { - const { id, pos, src } = action.add; - - const placeholder = document.createElement("div"); - placeholder.setAttribute("class", "img-placeholder"); - const image = document.createElement("img"); - image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300"); - image.src = src; - placeholder.appendChild(image); - - // Create cancel button - const cancelButton = document.createElement("button"); - cancelButton.type = "button"; - cancelButton.style.position = "absolute"; - cancelButton.style.right = "3px"; - cancelButton.style.top = "3px"; - cancelButton.setAttribute("class", "opacity-90 rounded-lg"); - - cancelButton.onclick = () => { - if (currentView) { - cancelUploadImage?.(); - removePlaceholder(editor, currentView, id); - } - }; - - // Create an SVG element from the SVG string - const svgString = ``; - const parser = new DOMParser(); - const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement; - - cancelButton.appendChild(svgElement); - placeholder.appendChild(cancelButton); - const deco = Decoration.widget(pos, placeholder, { - id, - }); - set = set.add(tr.doc, [deco]); - } else if (action && action.remove) { - set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id)); - } - return set; - }, - }, - props: { - decorations(state) { - return this.getState(state); - }, - }, - }); -}; - -function findPlaceholder(state: EditorState, id: {}) { - const decos = uploadKey.getState(state); - const found = decos.find(undefined, undefined, (spec: { id: number | undefined }) => spec.id == id); - return found.length ? found[0].from : null; -} - -const removePlaceholder = (editor: Editor, view: EditorView, id: {}) => { - const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { - remove: { id }, - }); - view.dispatch(removePlaceholderTr); - editor.storage.image.uploadInProgress = false; -}; - -export async function startImageUpload( - editor: Editor, - file: File, - view: EditorView, - pos: number, - uploadFile: UploadImage -) { - editor.storage.image.uploadInProgress = true; - - if (!file) { - alert("No file selected. Please select a file to upload."); - editor.storage.image.uploadInProgress = false; - return; - } - - if (!file.type.includes("image/")) { - alert("Invalid file type. Please select an image file."); - editor.storage.image.uploadInProgress = false; - return; - } - - if (file.size > 5 * 1024 * 1024) { - alert("File size too large. Please select a file smaller than 5MB."); - editor.storage.image.uploadInProgress = false; - return; - } - - const id = {}; - - const tr = view.state.tr; - if (!tr.selection.empty) tr.deleteSelection(); - - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => { - tr.setMeta(uploadKey, { - add: { - id, - pos, - src: reader.result, - }, - }); - view.dispatch(tr); - }; - - // Handle FileReader errors - reader.onerror = (error) => { - console.error("FileReader error: ", error); - removePlaceholder(editor, view, id); - return; - }; - - // setIsSubmitting?.("submitting"); - - try { - const src = await UploadImageHandler(file, uploadFile); - const { schema } = view.state; - pos = findPlaceholder(view.state, id); - - if (pos == null) { - editor.storage.image.uploadInProgress = false; - return; - } - const imageSrc = typeof src === "object" ? reader.result : src; - - const node = schema.nodes.image.create({ src: imageSrc }); - const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } }); - - view.dispatch(transaction); - if (view.hasFocus()) view.focus(); - editor.storage.image.uploadInProgress = false; - } catch (error) { - removePlaceholder(editor, view, id); - } -} - -const UploadImageHandler = (file: File, uploadFile: UploadImage): Promise => { - try { - return new Promise(async (resolve, reject) => { - try { - const imageUrl = await uploadFile(file); - - const image = new Image(); - image.src = imageUrl; - image.onload = () => { - resolve(imageUrl); - }; - } catch (error) { - if (error instanceof Error) { - console.log(error.message); - } - reject(error); - } - }); - } catch (error) { - return Promise.reject(error); - } -}; diff --git a/web/components/inbox/modals/create-edit-modal/create-root.tsx b/web/components/inbox/modals/create-edit-modal/create-root.tsx index e22c48557..08743f63c 100644 --- a/web/components/inbox/modals/create-edit-modal/create-root.tsx +++ b/web/components/inbox/modals/create-edit-modal/create-root.tsx @@ -18,6 +18,7 @@ import { ISSUE_CREATED } from "@/constants/event-tracker"; import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; // hooks import { useEventTracker, useProjectInbox, useWorkspace } from "@/hooks/store"; +import useKeypress from "@/hooks/use-keypress"; type TInboxIssueCreateRoot = { workspaceSlug: string; @@ -62,8 +63,33 @@ export const InboxIssueCreateRoot: FC = observer((props) [formData] ); + const handleEscKeyDown = (event: KeyboardEvent) => { + if (descriptionEditorRef.current?.isEditorReadyToDiscard()) { + handleModalClose(); + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Editor is still processing changes. Please wait before proceeding.", + }); + event.preventDefault(); // Prevent default action if editor is not ready to discard + } + }; + + useKeypress("Escape", handleEscKeyDown); + const handleFormSubmit = async (event: FormEvent) => { event.preventDefault(); + + if (descriptionEditorRef.current?.isEditorReadyToDiscard()) { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Editor is still processing changes. Please wait before proceeding.", + }); + return; + } + const payload: Partial = { name: formData.name || "", description_html: formData.description_html || "

", @@ -155,7 +181,22 @@ export const InboxIssueCreateRoot: FC = observer((props) Create more
-
- {isDraft && ( diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index 1c014c9ad..803f02fb7 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -65,6 +65,7 @@ export const IssueView: FC = observer((props) => { }, issueId ); + const handleKeyDown = () => { const slashCommandDropdownElement = document.querySelector("#slash-command"); const dropdownElement = document.activeElement?.tagName === "INPUT"; @@ -74,6 +75,7 @@ export const IssueView: FC = observer((props) => { if (issueElement) issueElement?.focus(); } }; + useKeypress("Escape", handleKeyDown); const handleRestore = async () => { diff --git a/web/hooks/use-keypress.tsx b/web/hooks/use-keypress.tsx index d04cd1445..c7243348d 100644 --- a/web/hooks/use-keypress.tsx +++ b/web/hooks/use-keypress.tsx @@ -1,10 +1,10 @@ import { useEffect } from "react"; -const useKeypress = (key: string, callback: () => void) => { +const useKeypress = (key: string, callback: (event: KeyboardEvent) => void) => { useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { if (event.key === key) { - callback(); + callback(event); } }; @@ -13,7 +13,7 @@ const useKeypress = (key: string, callback: () => void) => { return () => { document.removeEventListener("keydown", handleKeydown); }; - }); + }, [key, callback]); }; export default useKeypress;