fix: Image restoration fixed (marks/unmarks an image to be deleted after a week) (#2859)

* image restoration fixed (marks an image to be deleted after a week)

* removed clgs

* added image constraints

* formatted editor-core package using yarn format

* lite-text-editor nothing to format

* rich-text-editor nothing to format

* formatted document-editor with prettier

* modified file service to follow api change

* fixed more formatting in document editor

* fixed all instances of types with that from the package

* fixed delete to work consistently (minor optimizations turned off)

* stop duplicate images inside editor

* restore image on editor creation

say if user A deletes image number 2, user B was also in the same issue and in their screen the image was there, if user B makes certain changes and that gets saved in backend, according to user B image 2 should exist but since user A deleted it, it'll not get restored and get deleted in 7 days, hence I've added a check such that whenever a issue loads we restore all images by default

* added restore image function with types

* replaced all instances to have restore image logic

* fixed issue detail for peek view

* disabled option to insert table inside a table
This commit is contained in:
M. Palanikannan 2023-11-28 11:34:20 +05:30 committed by sriram veeraghanta
parent 0fcadca53a
commit e01ca97fc9
63 changed files with 471 additions and 225 deletions

View file

@ -1,6 +1,7 @@
import { UploadImage } from "@plane/editor-types";
import { Editor, Range } from "@tiptap/core";
import { UploadImage } from "../types/upload-image";
import { startImageUpload } from "../ui/plugins/upload-image";
import { findTableAncestor } from "./utils";
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
if (range)
@ -95,6 +96,15 @@ export const toggleBlockquote = (editor: Editor, range?: Range) => {
};
export const insertTableCommand = (editor: Editor, range?: Range) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
if (range)
editor
.chain()

View file

@ -1 +0,0 @@
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;

View file

@ -1,10 +0,0 @@
export type IMentionSuggestion = {
id: string;
type: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
};
export type IMentionHighlight = string;

View file

@ -1 +0,0 @@
export type UploadImage = (file: File) => Promise<string>;

View file

@ -1,30 +1,33 @@
import { getNodeType } from '@tiptap/core'
import { NodeType } from '@tiptap/pm/model'
import { EditorState } from '@tiptap/pm/state'
import { getNodeType } from "@tiptap/core";
import { NodeType } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
export const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
const { $from } = state.selection
const nodeType = getNodeType(typeOrName, state.schema)
export const findListItemPos = (
typeOrName: string | NodeType,
state: EditorState,
) => {
const { $from } = state.selection;
const nodeType = getNodeType(typeOrName, state.schema);
let currentNode = null
let currentDepth = $from.depth
let currentPos = $from.pos
let targetDepth: number | null = null
let currentNode = null;
let currentDepth = $from.depth;
let currentPos = $from.pos;
let targetDepth: number | null = null;
while (currentDepth > 0 && targetDepth === null) {
currentNode = $from.node(currentDepth)
currentNode = $from.node(currentDepth);
if (currentNode.type === nodeType) {
targetDepth = currentDepth
targetDepth = currentDepth;
} else {
currentDepth -= 1
currentPos -= 1
currentDepth -= 1;
currentPos -= 1;
}
}
if (targetDepth === null) {
return null
return null;
}
return { $pos: state.doc.resolve(currentPos), depth: targetDepth }
}
return { $pos: state.doc.resolve(currentPos), depth: targetDepth };
};

View file

@ -1,15 +1,19 @@
import { EditorState } from '@tiptap/pm/state'
import { EditorState } from "@tiptap/pm/state";
export const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => {
const { $anchor } = editorState.selection
export const hasListBefore = (
editorState: EditorState,
name: string,
parentListTypes: string[],
) => {
const { $anchor } = editorState.selection;
const previousNodePos = Math.max(0, $anchor.pos - 2)
const previousNodePos = Math.max(0, $anchor.pos - 2);
const previousNode = editorState.doc.resolve(previousNodePos).node()
const previousNode = editorState.doc.resolve(previousNodePos).node();
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
return false
return false;
}
return true
}
return true;
};

View file

@ -1,17 +1,20 @@
import { EditorState } from '@tiptap/pm/state'
import { EditorState } from "@tiptap/pm/state";
export const hasListItemAfter = (typeOrName: string, state: EditorState): boolean => {
const { $anchor } = state.selection
export const hasListItemAfter = (
typeOrName: string,
state: EditorState,
): boolean => {
const { $anchor } = state.selection;
const $targetPos = state.doc.resolve($anchor.pos - $anchor.parentOffset - 2)
const $targetPos = state.doc.resolve($anchor.pos - $anchor.parentOffset - 2);
if ($targetPos.index() === $targetPos.parent.childCount - 1) {
return false
return false;
}
if ($targetPos.nodeAfter?.type.name !== typeOrName) {
return false
return false;
}
return true
}
return true;
};

View file

@ -1,17 +1,20 @@
import { EditorState } from '@tiptap/pm/state'
import { EditorState } from "@tiptap/pm/state";
export const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
const { $anchor } = state.selection
export const hasListItemBefore = (
typeOrName: string,
state: EditorState,
): boolean => {
const { $anchor } = state.selection;
const $targetPos = state.doc.resolve($anchor.pos - 2)
const $targetPos = state.doc.resolve($anchor.pos - 2);
if ($targetPos.index() === 0) {
return false
return false;
}
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
return false
return false;
}
return true
}
return true;
};

View file

@ -1,19 +1,135 @@
import Image from "@tiptap/extension-image";
import TrackImageDeletionPlugin from "../../plugins/delete-image";
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import UploadImagesPlugin from "../../plugins/upload-image";
import { DeleteImage } from "../../../types/delete-image";
import ImageExt from "@tiptap/extension-image";
import { onNodeDeleted, onNodeRestored } from "../../plugins/delete-image";
import { DeleteImage, RestoreImage } from "@plane/editor-types";
interface ImageNode extends ProseMirrorNode {
attrs: {
src: string;
id: string;
};
}
const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";
const ImageExtension = (
deleteImage: DeleteImage,
restoreFile: RestoreImage,
cancelUploadImage?: () => any,
) =>
Image.extend({
ImageExt.extend({
addProseMirrorPlugins() {
return [
UploadImagesPlugin(cancelUploadImage),
TrackImageDeletionPlugin(deleteImage),
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;
},
}),
];
},
onCreate(this) {
const imageSources = new Set<string>();
this.editor.state.doc.descendants((node) => {
if (node.type.name === IMAGE_NODE_TYPE) {
imageSources.add(node.attrs.src);
}
});
imageSources.forEach(async (src) => {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
await restoreFile(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error restoring image: ", error);
}
});
},
// storage to keep track of image states Map<src, isDeleted>
addStorage() {
return {
images: new Map<string, boolean>(),
};
},
addAttributes() {
return {
...this.parent?.(),

View file

@ -15,14 +15,17 @@ import HorizontalRule from "./horizontal-rule";
import ImageExtension from "./image";
import { DeleteImage } from "../../types/delete-image";
import { isValidHttpUrl } from "../../lib/utils";
import { IMentionSuggestion } from "../../types/mention-suggestion";
import { Mentions } from "../mentions";
import { CustomKeymap } from "./keymap";
import { CustomCodeBlock } from "./code";
import { ListKeymap } from "./custom-list-keymap";
import {
IMentionSuggestion,
DeleteImage,
RestoreImage,
} from "@plane/editor-types";
export const CoreEditorExtensions = (
mentionConfig: {
@ -30,6 +33,7 @@ export const CoreEditorExtensions = (
mentionHighlights: string[];
},
deleteFile: DeleteImage,
restoreFile: RestoreImage,
cancelUploadImage?: () => any,
) => [
StarterKit.configure({
@ -71,7 +75,7 @@ export const CoreEditorExtensions = (
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtension(deleteFile, cancelUploadImage).configure({
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
},

View file

@ -1,22 +1,27 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { DeleteImage } from "../../types/delete-image";
import { CoreEditorProps } from "../props";
import { CoreEditorExtensions } from "../extensions";
import { EditorProps } from "@tiptap/pm/view";
import { getTrimmedHTML } from "../../lib/utils";
import { UploadImage } from "../../types/upload-image";
import { useInitializedContent } from "./useInitializedContent";
import { IMentionSuggestion } from "../../types/mention-suggestion";
import {
DeleteImage,
IMentionSuggestion,
RestoreImage,
UploadImage,
} from "@plane/editor-types";
interface CustomEditorProps {
uploadFile: UploadImage;
restoreFile: RestoreImage;
deleteFile: DeleteImage;
cancelUploadImage?: () => any;
setIsSubmitting?: (
isSubmitting: "submitting" | "submitted" | "saved",
) => void;
setShouldShowAlert?: (showAlert: boolean) => void;
value: string;
deleteFile: DeleteImage;
debouncedUpdatesEnabled?: boolean;
onStart?: (json: any, html: string) => void;
onChange?: (json: any, html: string) => void;
@ -25,7 +30,6 @@ interface CustomEditorProps {
forwardedRef?: any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
cancelUploadImage?: () => any;
}
export const useEditor = ({
@ -39,6 +43,7 @@ export const useEditor = ({
onChange,
setIsSubmitting,
forwardedRef,
restoreFile,
setShouldShowAlert,
mentionHighlights,
mentionSuggestions,
@ -56,6 +61,7 @@ export const useEditor = ({
mentionHighlights: mentionHighlights ?? [],
},
deleteFile,
restoreFile,
cancelUploadImage,
),
...extensions,
@ -63,7 +69,7 @@ export const useEditor = ({
content:
typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
onCreate: async ({ editor }) => {
onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()))
onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
onUpdate: async ({ editor }) => {
// for instant feedback loop

View file

@ -8,7 +8,7 @@ import {
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
import { EditorProps } from "@tiptap/pm/view";
import { IMentionSuggestion } from "../../types/mention-suggestion";
import { IMentionSuggestion } from "@plane/editor-types";
interface CustomReadOnlyEditorProps {
value: string;

View file

@ -1,14 +1,16 @@
"use client";
import * as React from "react";
import { Extension } from "@tiptap/react";
import { UploadImage } from "../types/upload-image";
import { DeleteImage } from "../types/delete-image";
import { getEditorClassNames } from "../lib/utils";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor } from "./hooks/useEditor";
import { EditorContainer } from "../ui/components/editor-container";
import { EditorContentWrapper } from "../ui/components/editor-content";
import { IMentionSuggestion } from "../types/mention-suggestion";
import {
UploadImage,
DeleteImage,
IMentionSuggestion,
} from "@plane/editor-types";
interface ICoreEditor {
value: string;

View file

@ -1,3 +1,4 @@
import { IMentionSuggestion } from "@plane/editor-types";
import { Editor } from "@tiptap/react";
import React, {
forwardRef,
@ -7,8 +8,6 @@ import React, {
useState,
} from "react";
import { IMentionSuggestion } from "../../types/mention-suggestion";
interface MentionListProps {
items: IMentionSuggestion[];
command: (item: {

View file

@ -2,7 +2,8 @@ import { Mention, MentionOptions } from "@tiptap/extension-mention";
import { mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import mentionNodeView from "./mentionNodeView";
import { IMentionHighlight } from "../../types/mention-suggestion";
import { IMentionHighlight } from "@plane/editor-types";
export interface CustomMentionOptions extends MentionOptions {
mentionHighlights: IMentionHighlight[];
readonly?: boolean;

View file

@ -2,10 +2,7 @@
import suggestion from "./suggestion";
import { CustomMention } from "./custom";
import {
IMentionHighlight,
IMentionSuggestion,
} from "../../types/mention-suggestion";
import { IMentionHighlight, IMentionSuggestion } from "@plane/editor-types";
export const Mentions = (
mentionSuggestions: IMentionSuggestion[],

View file

@ -3,7 +3,7 @@
import { NodeViewWrapper } from "@tiptap/react";
import { cn } from "../../lib/utils";
import { useRouter } from "next/router";
import { IMentionHighlight } from "../../types/mention-suggestion";
import { IMentionHighlight } from "@plane/editor-types";
// eslint-disable-next-line import/no-anonymous-default-export
export default (props) => {

View file

@ -3,7 +3,7 @@ import { Editor } from "@tiptap/core";
import tippy from "tippy.js";
import MentionList from "./MentionList";
import { IMentionSuggestion } from "../../types/mention-suggestion";
import { IMentionSuggestion } from "@plane/editor-types";
const Suggestion = (suggestions: IMentionSuggestion[]) => ({
items: ({ query }: { query: string }) =>

View file

@ -15,7 +15,6 @@ import {
CodeIcon,
} from "lucide-react";
import { Editor } from "@tiptap/react";
import { UploadImage } from "../../../types/upload-image";
import {
insertImageCommand,
insertTableCommand,
@ -32,6 +31,7 @@ import {
toggleTaskList,
toggleUnderline,
} from "../../../lib/editor-commands";
import { UploadImage } from "@plane/editor-types";
export interface EditorMenuItem {
name: string;

View file

@ -1,6 +1,6 @@
import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { DeleteImage } from "../../types/delete-image";
import { DeleteImage, RestoreImage } from "@plane/editor-types";
const deleteKey = new PluginKey("delete-image");
const IMAGE_NODE_TYPE = "image";
@ -59,7 +59,7 @@ const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
export default TrackImageDeletionPlugin;
async function onNodeDeleted(
export async function onNodeDeleted(
src: string,
deleteImage: DeleteImage,
): Promise<void> {
@ -73,3 +73,18 @@ async function onNodeDeleted(
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);
const resStatus = await restoreImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image restored successfully");
}
} catch (error) {
console.error("Error restoring image: ", error);
}
}

View file

@ -1,4 +1,4 @@
import { UploadImage } from "../../types/upload-image";
import { UploadImage } from "@plane/editor-types";
import { EditorState, Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";

View file

@ -1,7 +1,7 @@
import { UploadImage } from "@plane/editor-types";
import { EditorProps } from "@tiptap/pm/view";
import { findTableAncestor } from "../lib/utils";
import { startImageUpload } from "./plugins/upload-image";
import { UploadImage } from "../types/upload-image";
export function CoreEditorProps(
uploadFile: UploadImage,
@ -82,5 +82,8 @@ export function CoreEditorProps(
}
return false;
},
transformPastedHTML(html) {
return html.replace(/<img.*?>/g, "");
},
};
}

View file

@ -16,7 +16,7 @@ import TableRow from "../extensions/table/table-row/table-row";
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
import { isValidHttpUrl } from "../../lib/utils";
import { Mentions } from "../mentions";
import { IMentionSuggestion } from "../../types/mention-suggestion";
import { IMentionSuggestion } from "@plane/editor-types";
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
mentionSuggestions: IMentionSuggestion[];