[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
This commit is contained in:
M. Palanikannan 2024-05-29 18:25:03 +05:30 committed by GitHub
parent 061a447734
commit ade6eded69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 483 additions and 366 deletions

View File

@ -147,7 +147,7 @@ export const useEditor = ({
const item = getEditorMenuItem(itemName); const item = getEditorMenuItem(itemName);
if (item) { if (item) {
if (item.key === "image") { if (item.key === "image") {
item.command(savedSelection); item.command(savedSelectionRef.current);
} else { } else {
item.command(); item.command();
} }
@ -186,6 +186,7 @@ export const useEditor = ({
if (!editorRef.current) return; if (!editorRef.current) return;
scrollSummary(editorRef.current, marking); scrollSummary(editorRef.current, marking);
}, },
isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false,
setFocusAtPosition: (position: number) => { setFocusAtPosition: (position: number) => {
if (!editorRef.current || editorRef.current.isDestroyed) { if (!editorRef.current || editorRef.current.isDestroyed) {
console.error("Editor reference is not available or has been destroyed."); console.error("Editor reference is not available or has been destroyed.");

View File

@ -9,7 +9,7 @@ export { isCellSelection } from "src/ui/extensions/table/table/utilities/is-cell
// utils // utils
export * from "src/lib/utils"; export * from "src/lib/utils";
export * from "src/ui/extensions/table/table"; 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 // components
export { EditorContainer } from "src/ui/components/editor-container"; export { EditorContainer } from "src/ui/components/editor-container";

View File

@ -1,5 +1,5 @@
import { Editor, Range } from "@tiptap/core"; 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 { findTableAncestor } from "src/lib/utils";
import { Selection } from "@tiptap/pm/state"; import { Selection } from "@tiptap/pm/state";
import { UploadImage } from "src/types/upload-image"; import { UploadImage } from "src/types/upload-image";
@ -194,7 +194,7 @@ export const insertImageCommand = (
if (range) editor.chain().focus().deleteRange(range).run(); if (range) editor.chain().focus().deleteRange(range).run();
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.accept = "image/*"; input.accept = ".jpeg, .jpg, .png, .webp, .svg";
input.onchange = async () => { input.onchange = async () => {
if (input.files?.length) { if (input.files?.length) {
const file = input.files[0]; const file = input.files[0];

View File

@ -15,4 +15,5 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean; isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
onStateChange: (callback: () => void) => () => void; onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void; setFocusAtPosition: (position: number) => void;
isEditorReadyToDiscard: () => boolean;
} }

View File

@ -1,7 +1,7 @@
import { Extension } from "@tiptap/core"; import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "prosemirror-state"; import { Plugin, PluginKey } from "prosemirror-state";
import { UploadImage } from "src/types/upload-image"; 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) => export const DropHandlerExtension = (uploadFile: UploadImage) =>
Extension.create({ Extension.create({

View File

@ -1,25 +1,16 @@
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; import { UploadImagesPlugin } from "src/ui/plugins/image/upload-image";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { UploadImagesPlugin } from "src/ui/plugins/upload-image";
import ImageExt from "@tiptap/extension-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 { DeleteImage } from "src/types/delete-image";
import { RestoreImage } from "src/types/restore-image"; import { RestoreImage } from "src/types/restore-image";
import { insertLineBelowImageAction } from "./utilities/insert-line-below-image"; import { insertLineBelowImageAction } from "./utilities/insert-line-below-image";
import { insertLineAboveImageAction } from "./utilities/insert-line-above-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 { export const ImageExtension = (deleteImage: DeleteImage, restoreImage: RestoreImage, cancelUploadImage?: () => void) =>
attrs: { ImageExt.extend<any, ImageExtensionStorage>({
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({
addKeyboardShortcuts() { addKeyboardShortcuts() {
return { return {
ArrowDown: insertLineBelowImageAction, ArrowDown: insertLineBelowImageAction,
@ -29,77 +20,8 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma
addProseMirrorPlugins() { addProseMirrorPlugins() {
return [ return [
UploadImagesPlugin(this.editor, cancelUploadImage), UploadImagesPlugin(this.editor, cancelUploadImage),
new Plugin({ TrackImageDeletionPlugin(this.editor, deleteImage),
key: deleteKey, TrackImageRestorationPlugin(this.editor, restoreImage),
appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => {
const newImageSources = new Set<string>();
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<string>();
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;
},
}),
]; ];
}, },
@ -113,7 +35,7 @@ export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreIma
imageSources.forEach(async (src) => { imageSources.forEach(async (src) => {
try { try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1); const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreFile(assetUrlWithWorkspaceId); await restoreImage(assetUrlWithWorkspaceId);
} catch (error) { } catch (error) {
console.error("Error restoring image: ", 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<src, isDeleted> // storage to keep track of image states Map<src, isDeleted>
addStorage() { addStorage() {
return { return {
images: new Map<string, boolean>(), deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false, uploadInProgress: false,
}; };
}, },

View File

@ -141,8 +141,11 @@ export const CoreEditorExtensions = ({
placeholder: ({ editor, node }) => { placeholder: ({ editor, node }) => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`; if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
if (editor.storage.image.uploadInProgress) return "";
const shouldHidePlaceholder = const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
if (shouldHidePlaceholder) return ""; if (shouldHidePlaceholder) return "";
if (placeholder) { if (placeholder) {

View File

@ -239,8 +239,5 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
]; ];
} }
export type EditorMenuItemNames = ReturnType<typeof getEditorMenuItems> extends (infer U)[] export type EditorMenuItemNames =
? U extends { key: infer N } ReturnType<typeof getEditorMenuItems> extends (infer U)[] ? (U extends { key: infer N } ? N : never) : never;
? N
: never
: never;

View File

@ -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<string>();
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<void> {
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<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error restoring image: ", error);
}
}

View File

@ -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";

View File

@ -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<string>();
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<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await deleteImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error deleting image: ", error);
}
}

View File

@ -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<string | undefined> {
try {
const imageUrl = await uploadFile(file);
if (imageUrl == null) {
throw new Error("Image URL is undefined.");
}
await new Promise<void>((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;
}
}

View File

@ -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<string>();
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<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error restoring image: ", error);
throw error;
}
}

View File

@ -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<string, boolean>;
uploadInProgress: boolean;
};

View File

@ -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 = `<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);
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);
},
},
});
};

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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 = `<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, 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<string> => {
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);
}
};

View File

@ -18,6 +18,7 @@ import { ISSUE_CREATED } from "@/constants/event-tracker";
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks // hooks
import { useEventTracker, useProjectInbox, useWorkspace } from "@/hooks/store"; import { useEventTracker, useProjectInbox, useWorkspace } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
type TInboxIssueCreateRoot = { type TInboxIssueCreateRoot = {
workspaceSlug: string; workspaceSlug: string;
@ -62,8 +63,33 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
[formData] [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<HTMLFormElement>) => { const handleFormSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); 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<TIssue> = { const payload: Partial<TIssue> = {
name: formData.name || "", name: formData.name || "",
description_html: formData.description_html || "<p></p>", description_html: formData.description_html || "<p></p>",
@ -155,7 +181,22 @@ export const InboxIssueCreateRoot: FC<TInboxIssueCreateRoot> = observer((props)
<span className="text-xs">Create more</span> <span className="text-xs">Create more</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button variant="neutral-primary" size="sm" type="button" onClick={handleModalClose}> <Button
variant="neutral-primary"
size="sm"
type="button"
onClick={() => {
if (descriptionEditorRef.current?.isEditorReadyToDiscard()) {
handleModalClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
}
}}
>
Discard Discard
</Button> </Button>
<Button <Button

View File

@ -29,6 +29,7 @@ import { getChangedIssuefields, getDescriptionPlaceholder } from "@/helpers/issu
import { shouldRenderProject } from "@/helpers/project.helper"; import { shouldRenderProject } from "@/helpers/project.helper";
// hooks // hooks
import { useAppRouter, useEstimate, useInstance, useIssueDetail, useProject, useWorkspace } from "@/hooks/store"; import { useAppRouter, useEstimate, useInstance, useIssueDetail, useProject, useWorkspace } from "@/hooks/store";
import useKeypress from "@/hooks/use-keypress";
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties"; import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
// services // services
import { AIService } from "@/services/ai.service"; import { AIService } from "@/services/ai.service";
@ -121,6 +122,21 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { areEstimatesEnabledForProject } = useEstimate(); const { areEstimatesEnabledForProject } = useEstimate();
function handleKeyDown(event: KeyboardEvent) {
if (editorRef.current?.isEditorReadyToDiscard()) {
onClose();
} 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", handleKeyDown);
const { const {
issue: { getIssueById }, issue: { getIssueById },
} = useIssueDetail(); } = useIssueDetail();
@ -168,6 +184,16 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
const issueName = watch("name"); const issueName = watch("name");
const handleFormSubmit = async (formData: Partial<TIssue>, is_draft_issue = false) => { const handleFormSubmit = async (formData: Partial<TIssue>, is_draft_issue = false) => {
// Check if the editor is ready to discard
if (!editorRef.current?.isEditorReadyToDiscard()) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is not ready to discard changes.",
});
return;
}
const submitData = !data?.id const submitData = !data?.id
? formData ? formData
: { : {
@ -740,7 +766,22 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={getTabIndex("discard_button")}> <Button
variant="neutral-primary"
size="sm"
onClick={() => {
if (editorRef.current?.isEditorReadyToDiscard()) {
onClose();
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Editor is still processing changes. Please wait before proceeding.",
});
}
}}
tabIndex={getTabIndex("discard_button")}
>
Discard Discard
</Button> </Button>
{isDraft && ( {isDraft && (

View File

@ -65,6 +65,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
}, },
issueId issueId
); );
const handleKeyDown = () => { const handleKeyDown = () => {
const slashCommandDropdownElement = document.querySelector("#slash-command"); const slashCommandDropdownElement = document.querySelector("#slash-command");
const dropdownElement = document.activeElement?.tagName === "INPUT"; const dropdownElement = document.activeElement?.tagName === "INPUT";
@ -74,6 +75,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
if (issueElement) issueElement?.focus(); if (issueElement) issueElement?.focus();
} }
}; };
useKeypress("Escape", handleKeyDown); useKeypress("Escape", handleKeyDown);
const handleRestore = async () => { const handleRestore = async () => {

View File

@ -1,10 +1,10 @@
import { useEffect } from "react"; import { useEffect } from "react";
const useKeypress = (key: string, callback: () => void) => { const useKeypress = (key: string, callback: (event: KeyboardEvent) => void) => {
useEffect(() => { useEffect(() => {
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
if (event.key === key) { if (event.key === key) {
callback(); callback(event);
} }
}; };
@ -13,7 +13,7 @@ const useKeypress = (key: string, callback: () => void) => {
return () => { return () => {
document.removeEventListener("keydown", handleKeydown); document.removeEventListener("keydown", handleKeydown);
}; };
}); }, [key, callback]);
}; };
export default useKeypress; export default useKeypress;