forked from github/plane
[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:
parent
061a447734
commit
ade6eded69
@ -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.");
|
||||
|
@ -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";
|
||||
|
@ -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];
|
||||
|
@ -15,4 +15,5 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
isEditorReadyToDiscard: () => boolean;
|
||||
}
|
||||
|
@ -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({
|
||||
|
@ -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<any, ImageExtensionStorage>({
|
||||
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<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;
|
||||
},
|
||||
}),
|
||||
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<src, isDeleted>
|
||||
addStorage() {
|
||||
return {
|
||||
images: new Map<string, boolean>(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
};
|
||||
},
|
||||
|
@ -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) {
|
||||
|
@ -239,8 +239,5 @@ export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImag
|
||||
];
|
||||
}
|
||||
|
||||
export type EditorMenuItemNames = ReturnType<typeof getEditorMenuItems> extends (infer U)[]
|
||||
? U extends { key: infer N }
|
||||
? N
|
||||
: never
|
||||
: never;
|
||||
export type EditorMenuItemNames =
|
||||
ReturnType<typeof getEditorMenuItems> extends (infer U)[] ? (U extends { key: infer N } ? N : never) : never;
|
@ -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);
|
||||
}
|
||||
}
|
7
packages/editor/core/src/ui/plugins/image/constants.ts
Normal file
7
packages/editor/core/src/ui/plugins/image/constants.ts
Normal 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";
|
54
packages/editor/core/src/ui/plugins/image/delete-image.ts
Normal file
54
packages/editor/core/src/ui/plugins/image/delete-image.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
57
packages/editor/core/src/ui/plugins/image/restore-image.ts
Normal file
57
packages/editor/core/src/ui/plugins/image/restore-image.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
91
packages/editor/core/src/ui/plugins/image/upload-image.ts
Normal file
91
packages/editor/core/src/ui/plugins/image/upload-image.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
};
|
@ -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<TInboxIssueCreateRoot> = 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<HTMLFormElement>) => {
|
||||
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> = {
|
||||
name: formData.name || "",
|
||||
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>
|
||||
</div>
|
||||
<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
|
||||
</Button>
|
||||
<Button
|
||||
|
@ -29,6 +29,7 @@ import { getChangedIssuefields, getDescriptionPlaceholder } from "@/helpers/issu
|
||||
import { shouldRenderProject } from "@/helpers/project.helper";
|
||||
// hooks
|
||||
import { useAppRouter, useEstimate, useInstance, useIssueDetail, useProject, useWorkspace } from "@/hooks/store";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
|
||||
// services
|
||||
import { AIService } from "@/services/ai.service";
|
||||
@ -121,6 +122,21 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
const { getProjectById } = useProject();
|
||||
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 {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
@ -168,6 +184,16 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
const issueName = watch("name");
|
||||
|
||||
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
|
||||
? formData
|
||||
: {
|
||||
@ -740,7 +766,22 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
)}
|
||||
</div>
|
||||
<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
|
||||
</Button>
|
||||
{isDraft && (
|
||||
|
@ -65,6 +65,7 @@ export const IssueView: FC<IIssueView> = 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<IIssueView> = observer((props) => {
|
||||
if (issueElement) issueElement?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
useKeypress("Escape", handleKeyDown);
|
||||
|
||||
const handleRestore = async () => {
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user