mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
chore: resolved merge conflicts
This commit is contained in:
commit
2478c7b813
@ -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.");
|
||||||
|
@ -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";
|
||||||
|
@ -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];
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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({
|
||||||
|
@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
|
@ -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);
|
|
||||||
}
|
|
||||||
};
|
|
2
packages/types/src/notifications.d.ts
vendored
2
packages/types/src/notifications.d.ts
vendored
@ -57,7 +57,7 @@ export interface INotificationIssueLite {
|
|||||||
state_group: string;
|
state_group: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NotificationType = "created" | "assigned" | "watching" | null;
|
export type NotificationType = "created" | "assigned" | "watching" | "all";
|
||||||
|
|
||||||
export interface INotificationParams {
|
export interface INotificationParams {
|
||||||
snoozed?: boolean;
|
snoozed?: boolean;
|
||||||
|
@ -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
|
||||||
|
@ -169,9 +169,9 @@ export const KanbanGroup = observer((props: IKanbanGroup) => {
|
|||||||
preloadedData = { ...preloadedData, state_id: subGroupValue };
|
preloadedData = { ...preloadedData, state_id: subGroupValue };
|
||||||
} else if (subGroupByKey === "priority") {
|
} else if (subGroupByKey === "priority") {
|
||||||
preloadedData = { ...preloadedData, priority: subGroupValue };
|
preloadedData = { ...preloadedData, priority: subGroupValue };
|
||||||
} else if (groupByKey === "cycle") {
|
} else if (subGroupByKey === "cycle") {
|
||||||
preloadedData = { ...preloadedData, cycle_id: subGroupValue };
|
preloadedData = { ...preloadedData, cycle_id: subGroupValue };
|
||||||
} else if (groupByKey === "module") {
|
} else if (subGroupByKey === "module") {
|
||||||
preloadedData = { ...preloadedData, module_ids: [subGroupValue] };
|
preloadedData = { ...preloadedData, module_ids: [subGroupValue] };
|
||||||
} else if (subGroupByKey === "labels" && subGroupValue != "None") {
|
} else if (subGroupByKey === "labels" && subGroupValue != "None") {
|
||||||
preloadedData = { ...preloadedData, label_ids: [subGroupValue] };
|
preloadedData = { ...preloadedData, label_ids: [subGroupValue] };
|
||||||
|
@ -30,12 +30,13 @@ import { shouldRenderProject } from "@/helpers/project.helper";
|
|||||||
// hooks
|
// hooks
|
||||||
import {
|
import {
|
||||||
useAppRouter,
|
useAppRouter,
|
||||||
|
useProjectEstimates,
|
||||||
useInstance,
|
useInstance,
|
||||||
useIssueDetail,
|
useIssueDetail,
|
||||||
useProject,
|
useProject,
|
||||||
useWorkspace,
|
useWorkspace,
|
||||||
useProjectEstimates,
|
|
||||||
} from "@/hooks/store";
|
} 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";
|
||||||
@ -128,6 +129,21 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
|||||||
const { getProjectById } = useProject();
|
const { getProjectById } = useProject();
|
||||||
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
const { areEstimateEnabledByProjectId } = useProjectEstimates();
|
||||||
|
|
||||||
|
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();
|
||||||
@ -175,6 +191,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
|
||||||
: {
|
: {
|
||||||
@ -747,7 +773,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 && (
|
||||||
|
@ -140,6 +140,7 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
|||||||
}
|
}
|
||||||
|
|
||||||
setActiveProjectId(null);
|
setActiveProjectId(null);
|
||||||
|
setChangesMade(null);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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 () => {
|
||||||
|
@ -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;
|
||||||
|
@ -3,14 +3,13 @@ import { useRouter } from "next/router";
|
|||||||
// swr
|
// swr
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import useSWRInfinite from "swr/infinite";
|
import useSWRInfinite from "swr/infinite";
|
||||||
// services
|
|
||||||
import { UNREAD_NOTIFICATIONS_COUNT, getPaginatedNotificationKey } from "@/constants/fetch-keys";
|
|
||||||
import { NotificationService } from "@/services/notification.service";
|
|
||||||
// fetch-keys
|
|
||||||
// type
|
|
||||||
import type { NotificationType, NotificationCount, IMarkAllAsReadPayload } from "@plane/types";
|
import type { NotificationType, NotificationCount, IMarkAllAsReadPayload } from "@plane/types";
|
||||||
// ui
|
// ui
|
||||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||||
|
// constant
|
||||||
|
import { UNREAD_NOTIFICATIONS_COUNT, getPaginatedNotificationKey } from "@/constants/fetch-keys";
|
||||||
|
// services
|
||||||
|
import { NotificationService } from "@/services/notification.service";
|
||||||
|
|
||||||
const PER_PAGE = 30;
|
const PER_PAGE = 30;
|
||||||
|
|
||||||
@ -258,7 +257,7 @@ const useUserNotification = (): any => {
|
|||||||
|
|
||||||
if (snoozed) markAsReadParams = { archived: false, snoozed: true };
|
if (snoozed) markAsReadParams = { archived: false, snoozed: true };
|
||||||
else if (archived) markAsReadParams = { archived: true, snoozed: false };
|
else if (archived) markAsReadParams = { archived: true, snoozed: false };
|
||||||
else markAsReadParams = { archived: false, snoozed: false, type: selectedTab };
|
else markAsReadParams = { archived: false, snoozed: false, type: readNotification ? "all" : selectedTab };
|
||||||
|
|
||||||
await userNotificationServices
|
await userNotificationServices
|
||||||
.markAllNotificationsAsRead(workspaceSlug.toString(), markAsReadParams)
|
.markAllNotificationsAsRead(workspaceSlug.toString(), markAsReadParams)
|
||||||
|
@ -14,7 +14,7 @@ type TStoreWrapper = {
|
|||||||
const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
|
const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
// theme
|
// theme
|
||||||
const { setTheme } = useTheme();
|
const {resolvedTheme, setTheme } = useTheme();
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
// store hooks
|
// store hooks
|
||||||
@ -38,7 +38,7 @@ const StoreWrapper: FC<TStoreWrapper> = observer((props) => {
|
|||||||
* Setting up the theme of the user by fetching it from local storage
|
* Setting up the theme of the user by fetching it from local storage
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTheme(userProfile?.theme?.theme || "system");
|
setTheme(userProfile?.theme?.theme || resolvedTheme || "system");
|
||||||
if (!userProfile?.theme?.theme) return;
|
if (!userProfile?.theme?.theme) return;
|
||||||
|
|
||||||
if (userProfile?.theme?.theme === "custom" && userProfile?.theme?.palette) {
|
if (userProfile?.theme?.theme === "custom" && userProfile?.theme?.palette) {
|
||||||
|
@ -38,7 +38,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
// store hooks
|
// store hooks
|
||||||
const { createPage, getPageById } = useProjectPages(projectId?.toString() ?? "");
|
const { createPage, getPageById } = useProjectPages(projectId?.toString() ?? "");
|
||||||
const page = usePage(pageId?.toString() ?? "");
|
const page = usePage(pageId?.toString() ?? "");
|
||||||
const { description_html, id, name } = page;
|
const { access, description_html, id, name } = page;
|
||||||
// editor markings hook
|
// editor markings hook
|
||||||
const { markings, updateMarkings } = useEditorMarkings();
|
const { markings, updateMarkings } = useEditorMarkings();
|
||||||
// fetch page details
|
// fetch page details
|
||||||
@ -81,6 +81,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
|
|||||||
const formData: Partial<TPage> = {
|
const formData: Partial<TPage> = {
|
||||||
name: "Copy of " + name,
|
name: "Copy of " + name,
|
||||||
description_html: description_html ?? "<p></p>",
|
description_html: description_html ?? "<p></p>",
|
||||||
|
access,
|
||||||
};
|
};
|
||||||
|
|
||||||
await handleCreatePage(formData)
|
await handleCreatePage(formData)
|
||||||
|
Loading…
Reference in New Issue
Block a user