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 GitHub
parent 8ee8270697
commit 6607caade7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 471 additions and 225 deletions

View File

@ -19,7 +19,8 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
"check-types": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
"next": "12.3.2",
@ -28,6 +29,7 @@
},
"dependencies": {
"@tiptap/core": "^2.1.7",
"@plane/editor-types": "*",
"@tiptap/extension-code-block-lowlight": "^2.1.12",
"@tiptap/extension-color": "^2.1.11",
"@tiptap/extension-image": "^2.1.7",

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[];

View File

@ -18,7 +18,8 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
"check-types": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
"next": "12.3.2",
@ -30,6 +31,7 @@
"@plane/ui": "*",
"@plane/editor-core": "*",
"@plane/editor-extensions": "*",
"@plane/editor-types": "*",
"@tiptap/core": "^2.1.7",
"@tiptap/extension-placeholder": "^2.1.11",
"@types/node": "18.15.3",

View File

@ -1,3 +1,6 @@
export { DocumentEditor, DocumentEditorWithRef } from "./ui"
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "./ui/readonly"
export { FixedMenu } from "./ui/menu/fixed-menu"
export { DocumentEditor, DocumentEditorWithRef } from "./ui";
export {
DocumentReadOnlyEditor,
DocumentReadOnlyEditorWithRef,
} from "./ui/readonly";
export { FixedMenu } from "./ui/menu/fixed-menu";

View File

@ -3,31 +3,34 @@ import { useState } from "react";
import { IMarking } from "..";
export const useEditorMarkings = () => {
const [markings, setMarkings] = useState<IMarking[]>([])
const [markings, setMarkings] = useState<IMarking[]>([]);
const updateMarkings = (json: any) => {
const nodes = json.content as any[]
const tempMarkings: IMarking[] = []
let h1Sequence: number = 0
let h2Sequence: number = 0
const nodes = json.content as any[];
const tempMarkings: IMarking[] = [];
let h1Sequence: number = 0;
let h2Sequence: number = 0;
if (nodes) {
nodes.forEach((node) => {
if (node.type === "heading" && (node.attrs.level === 1 || node.attrs.level === 2) && node.content) {
if (
node.type === "heading" &&
(node.attrs.level === 1 || node.attrs.level === 2) &&
node.content
) {
tempMarkings.push({
type: "heading",
level: node.attrs.level,
text: node.content[0].text,
sequence: node.attrs.level === 1 ? ++h1Sequence : ++h2Sequence
})
sequence: node.attrs.level === 1 ? ++h1Sequence : ++h2Sequence,
});
}
})
}
setMarkings(tempMarkings)
});
}
setMarkings(tempMarkings);
};
return {
updateMarkings,
markings,
}
}
};
};

View File

@ -14,15 +14,14 @@ import { DocumentDetails } from "./types/editor-types";
import { PageRenderer } from "./components/page-renderer";
import { getMenuOptions } from "./utils/menu-options";
import { useRouter } from "next/router";
export type UploadImage = (file: File) => Promise<string>;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
import { UploadImage, DeleteImage, RestoreImage } from "@plane/editor-types";
interface IDocumentEditor {
documentDetails: DocumentDetails;
value: string;
uploadFile: UploadImage;
deleteFile: DeleteImage;
restoreFile: RestoreImage;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange: (json: any, html: string) => void;
@ -62,6 +61,7 @@ const DocumentEditor = ({
value,
uploadFile,
deleteFile,
restoreFile,
customClassName,
forwardedRef,
duplicationConfig,
@ -82,6 +82,7 @@ const DocumentEditor = ({
updateMarkings(json);
},
debouncedUpdatesEnabled,
restoreFile,
setIsSubmitting,
setShouldShowAlert,
value,

View File

@ -19,7 +19,7 @@ import {
HeadingThreeItem,
findTableAncestor,
} from "@plane/editor-core";
import { UploadImage } from "..";
import { UploadImage } from "@plane/editor-types";
export interface BubbleMenuItem {
name: string;

View File

@ -6,8 +6,9 @@ type Props = {
};
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
<span className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}>
<span
className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}
>
{iconName}
</span>
);

View File

@ -1,4 +1,4 @@
import * as React from 'react';
import * as React from "react";
// next-themes
import { useTheme } from "next-themes";
@ -69,8 +69,16 @@ export const Tooltip: React.FC<Props> = ({
</div>
}
position={position}
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
renderTarget={({
isOpen: isTooltipOpen,
ref: eleReference,
...tooltipProps
}) =>
React.cloneElement(children, {
ref: eleReference,
...tooltipProps,
...children.props,
})
}
/>
);

View File

@ -1,4 +1,3 @@
export interface DocumentDetails {
title: string;
created_by: string;

View File

@ -1,14 +1,13 @@
export interface IDuplicationConfig {
action: () => Promise<void>
action: () => Promise<void>;
}
export interface IPageLockConfig {
is_locked: boolean,
action: () => Promise<void>
locked_by?: string,
is_locked: boolean;
action: () => Promise<void>;
locked_by?: string;
}
export interface IPageArchiveConfig {
is_archived: boolean,
archived_at?: Date,
action: () => Promise<void>
}
is_archived: boolean;
archived_at?: Date;
action: () => Promise<void>;
}

View File

@ -5,7 +5,7 @@ function findNthH1(editor: Editor, n: number, level: number): number {
let count = 0;
let pos = 0;
editor.state.doc.descendants((node, position) => {
if (node.type.name === 'heading' && node.attrs.level === level) {
if (node.type.name === "heading" && node.attrs.level === level) {
count++;
if (count === n) {
pos = position;
@ -14,22 +14,21 @@ function findNthH1(editor: Editor, n: number, level: number): number {
}
});
return pos;
}
}
function scrollToNode(editor: Editor, pos: number): void {
function scrollToNode(editor: Editor, pos: number): void {
const headingNode = editor.state.doc.nodeAt(pos);
if (headingNode) {
const headingDOM = editor.view.nodeDOM(pos);
if (headingDOM instanceof HTMLElement) {
headingDOM.scrollIntoView({ behavior: 'smooth' });
}
headingDOM.scrollIntoView({ behavior: "smooth" });
}
}
}
export function scrollSummary(editor: Editor, marking: IMarking) {
export function scrollSummary(editor: Editor, marking: IMarking) {
if (editor) {
const pos = findNthH1(editor, marking.sequence, marking.level)
scrollToNode(editor, pos)
const pos = findNthH1(editor, marking.sequence, marking.level);
scrollToNode(editor, pos);
}
}
}

View File

@ -1,12 +1,12 @@
import { Editor } from "@tiptap/core"
import { Editor } from "@tiptap/core";
export const copyMarkdownToClipboard = (editor: Editor | null) => {
const markdownOutput = editor?.storage.markdown.getMarkdown();
navigator.clipboard.writeText(markdownOutput)
}
navigator.clipboard.writeText(markdownOutput);
};
export const CopyPageLink = () => {
if (window){
navigator.clipboard.writeText(window.location.toString())
if (window) {
navigator.clipboard.writeText(window.location.toString());
}
}
};

View File

@ -19,7 +19,8 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
"check-types": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
"next": "12.3.2",
@ -29,7 +30,8 @@
},
"dependencies": {
"@plane/editor-core": "*",
"@plane/ui": "*"
"@plane/ui": "*",
"@plane/editor-types": "*"
},
"devDependencies": {
"@types/node": "18.15.3",

View File

@ -1,3 +1,6 @@
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
export type { IMentionSuggestion, IMentionHighlight } from "./ui";
export type {
IMentionSuggestion,
IMentionHighlight,
} from "@plane/editor-types";

View File

@ -7,24 +7,19 @@ import {
} from "@plane/editor-core";
import { FixedMenu } from "./menus/fixed-menu";
import { LiteTextEditorExtensions } from "./extensions";
export type UploadImage = (file: File) => Promise<string>;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
export type IMentionSuggestion = {
id: string;
type: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
};
export type IMentionHighlight = string;
import {
UploadImage,
DeleteImage,
IMentionSuggestion,
RestoreImage,
} from "@plane/editor-types";
interface ILiteTextEditor {
value: string;
uploadFile: UploadImage;
deleteFile: DeleteImage;
restoreFile: RestoreImage;
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
@ -73,6 +68,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
value,
uploadFile,
deleteFile,
restoreFile,
noBorder,
borderOnFocus,
customClassName,
@ -93,6 +89,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => {
value,
uploadFile,
deleteFile,
restoreFile,
forwardedRef,
extensions: LiteTextEditorExtensions(onEnterKeyPress),
mentionHighlights,

View File

@ -16,11 +16,12 @@ import {
UnderLineItem,
} from "@plane/editor-core";
import { Tooltip } from "@plane/ui";
import { UploadImage } from "../../";
import type { SVGProps } from "react";
import { UploadImage } from "@plane/editor-types";
interface LucideProps extends Partial<SVGProps<SVGSVGElement>> {
size?: string | number
absoluteStrokeWidth?: boolean
size?: string | number;
absoluteStrokeWidth?: boolean;
}
type LucideIcon = (props: LucideProps) => JSX.Element;

View File

@ -19,7 +19,8 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit"
"check-types": "tsc --noEmit",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
"next": "12.3.2",
@ -30,6 +31,7 @@
"dependencies": {
"@plane/editor-core": "*",
"@tiptap/core": "^2.1.11",
"@plane/editor-types": "*",
"@plane/editor-extensions": "*",
"@tiptap/extension-placeholder": "^2.1.11",
"lucide-react": "^0.244.0"

View File

@ -1,3 +1,7 @@
export { RichTextEditor, RichTextEditorWithRef } from "./ui";
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only";
export type { IMentionSuggestion, IMentionHighlight } from "./ui";
export type { RichTextEditorProps, IRichTextEditor } from "./ui";
export type {
IMentionHighlight,
IMentionSuggestion,
} from "@plane/editor-types";

View File

@ -1,7 +1,7 @@
import { SlashCommand } from "@plane/editor-extensions";
import Placeholder from "@tiptap/extension-placeholder";
import { DragAndDrop } from "@plane/editor-extensions";
import { UploadImage } from "../";
import { UploadImage } from "@plane/editor-types";
export const RichTextEditorExtensions = (
uploadFile: UploadImage,

View File

@ -8,25 +8,18 @@ import {
} from "@plane/editor-core";
import { EditorBubbleMenu } from "./menus/bubble-menu";
import { RichTextEditorExtensions } from "./extensions";
import {
DeleteImage,
IMentionSuggestion,
RestoreImage,
UploadImage,
} from "@plane/editor-types";
export type UploadImage = (file: File) => Promise<string>;
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
export type IMentionSuggestion = {
id: string;
type: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
};
export type IMentionHighlight = string;
interface IRichTextEditor {
export type IRichTextEditor = {
value: string;
dragDropEnabled?: boolean;
uploadFile: UploadImage;
restoreFile: RestoreImage;
deleteFile: DeleteImage;
noBorder?: boolean;
borderOnFocus?: boolean;
@ -42,9 +35,9 @@ interface IRichTextEditor {
debouncedUpdatesEnabled?: boolean;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
}
};
interface RichTextEditorProps extends IRichTextEditor {
export interface RichTextEditorProps extends IRichTextEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
@ -67,6 +60,7 @@ const RichTextEditor = ({
cancelUploadImage,
borderOnFocus,
customClassName,
restoreFile,
forwardedRef,
mentionHighlights,
mentionSuggestions,
@ -80,6 +74,7 @@ const RichTextEditor = ({
uploadFile,
cancelUploadImage,
deleteFile,
restoreFile,
forwardedRef,
extensions: RichTextEditorExtensions(
uploadFile,

View File

@ -1,5 +1,6 @@
export type { DeleteImage } from "./types/delete-image";
export type { UploadImage } from "./types/upload-image";
export type { RestoreImage } from "./types/restore-image";
export type {
IMentionHighlight,
IMentionSuggestion,

View File

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

View File

@ -79,6 +79,7 @@ export const AddComment: React.FC<Props> = observer((props) => {
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspace_slug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
value={
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)

View File

@ -106,6 +106,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
value={value}
debouncedUpdatesEnabled={false}

View File

@ -1,4 +1,6 @@
// services
import APIService from "services/api.service";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
import axios from "axios";
@ -33,6 +35,7 @@ class FileService extends APIService {
super(API_BASE_URL);
this.uploadFile = this.uploadFile.bind(this);
this.deleteImage = this.deleteImage.bind(this);
this.restoreImage = this.restoreImage.bind(this);
this.cancelUpload = this.cancelUpload.bind(this);
}
@ -50,6 +53,7 @@ class FileService extends APIService {
if (axios.isCancel(error)) {
console.log(error.message);
} else {
console.log(error);
throw error?.response?.data;
}
});
@ -58,6 +62,7 @@ class FileService extends APIService {
cancelUpload() {
this.cancelSource.cancel("Upload cancelled");
}
getUploadFileFunction(workspaceSlug: string): (file: File) => Promise<string> {
return async (file: File) => {
const formData = new FormData();
@ -77,6 +82,17 @@ class FileService extends APIService {
});
}
async restoreImage(assetUrlWithWorkspaceId: string): Promise<any> {
return this.post(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/restore/`, {
headers: this.getHeaders(),
"Content-Type": "application/json",
})
.then((response) => response?.status)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteFile(workspaceId: string, assetUrl: string): Promise<any> {
const lastIndex = assetUrl.lastIndexOf("/");
const assetId = assetUrl.substring(lastIndex + 1);

View File

@ -155,6 +155,7 @@ export const CreateInboxIssueModal: React.FC<Props> = observer((props) => {
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
debouncedUpdatesEnabled={false}
value={!value || value === "" ? "<p></p>" : value}

View File

@ -87,6 +87,7 @@ export const AddComment: React.FC<Props> = ({ disabled = false, onSubmit, showAc
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
customClassName="p-2 h-full"

View File

@ -108,6 +108,7 @@ export const CommentCard: React.FC<Props> = ({
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
value={watch("comment_html")}
debouncedUpdatesEnabled={false}

View File

@ -148,6 +148,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
value={value}
setShouldShowAlert={setShowAlert}
setIsSubmitting={setIsSubmitting}

View File

@ -425,6 +425,7 @@ export const DraftIssueForm: FC<IssueFormProps> = observer((props) => {
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
debouncedUpdatesEnabled={false}
value={

View File

@ -379,6 +379,7 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
debouncedUpdatesEnabled={false}
value={

View File

@ -117,6 +117,7 @@ export const IssueCommentCard: React.FC<IIssueCommentCard> = (props) => {
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
value={watch("comment_html")}
debouncedUpdatesEnabled={false}

View File

@ -88,6 +88,7 @@ export const IssueCommentEditor: React.FC<IIssueCommentEditor> = (props) => {
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
value={!commentValue || commentValue === "" ? "<p></p>" : commentValue}
customClassName="p-2 h-full"

View File

@ -66,11 +66,15 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
[issue, issueUpdate]
);
const debouncedIssueDescription = useDebouncedCallback(async (_data: any) => {
issueUpdate({ ...issue, description_html: _data });
}, 1500);
const [localTitleValue, setLocalTitleValue] = useState("");
const issueTitleCurrentValue = watch("name");
useEffect(() => {
if (localTitleValue === "" && issueTitleCurrentValue !== "") {
setLocalTitleValue(issueTitleCurrentValue);
}
}, [issueTitleCurrentValue, localTitleValue]);
const debouncedTitleSave = useDebouncedCallback(async () => {
const debouncedFormSave = useDebouncedCallback(async () => {
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
}, 1500);
@ -105,18 +109,19 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
<Controller
name="name"
control={control}
render={({ field: { value, onChange } }) => (
render={({ field: { onChange } }) => (
<TextArea
id="name"
name="name"
value={value}
value={localTitleValue}
placeholder="Enter issue name"
onFocus={() => setCharacterLimit(true)}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setCharacterLimit(false);
setIsSubmitting("submitting");
debouncedTitleSave();
setLocalTitleValue(e.target.value);
onChange(e.target.value);
debouncedFormSave();
}}
required={true}
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary !p-0 focus:!px-3 focus:!py-2"
@ -139,20 +144,41 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
)}
</div>
<span>{errors.name ? errors.name.message : null}</span>
<div className="relative">
<Controller
name="description_html"
control={control}
render={({ field: { value, onChange } }) => (
<RichTextEditor
dragDropEnabled
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
deleteFile={fileService.deleteImage}
value={issue?.description_html}
debouncedUpdatesEnabled={false}
restoreFile={fileService.restoreImage}
value={value}
setShouldShowAlert={setShowAlert}
setIsSubmitting={setIsSubmitting}
dragDropEnabled
customClassName={isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"}
noBorder={!isAllowed}
onChange={(description: Object, description_html: string) => {
debouncedIssueDescription(description_html);
setShowAlert(true);
setIsSubmitting("submitting");
onChange(description_html);
debouncedFormSave();
}}
customClassName="mt-0"
mentionSuggestions={editorSuggestions.mentionSuggestions}
mentionHighlights={editorSuggestions.mentionHighlights}
/>
)}
/>
<div
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</div>
</div>
<IssueReaction
issueReactions={issueReactions}
user={user}

View File

@ -271,6 +271,7 @@ export const CreateUpdateBlockInline: FC<Props> = (props) => {
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
value={"<p></p>"}
debouncedUpdatesEnabled={false}
@ -293,6 +294,7 @@ export const CreateUpdateBlockInline: FC<Props> = (props) => {
cancelUploadImage={fileService.cancelUpload}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
value={
value && value !== "" && Object.keys(value).length > 0

View File

@ -267,6 +267,7 @@ const PageDetailsPage: NextPageWithLayout = observer(() => {
}}
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
deleteFile={fileService.deleteImage}
restoreFile={fileService.restoreImage}
ref={editorRef}
debouncedUpdatesEnabled={false}
setIsSubmitting={setIsSubmitting}

View File

@ -35,6 +35,7 @@ export class FileService extends APIService {
super(API_BASE_URL);
this.uploadFile = this.uploadFile.bind(this);
this.deleteImage = this.deleteImage.bind(this);
this.restoreImage = this.restoreImage.bind(this);
this.cancelUpload = this.cancelUpload.bind(this);
}
@ -81,6 +82,17 @@ export class FileService extends APIService {
});
}
async restoreImage(assetUrlWithWorkspaceId: string): Promise<any> {
return this.post(`/api/workspaces/file-assets/${assetUrlWithWorkspaceId}/restore/`, {
headers: this.getHeaders(),
"Content-Type": "application/json",
})
.then((response) => response?.status)
.catch((error) => {
throw error?.response?.data;
});
}
async deleteFile(workspaceId: string, assetUrl: string): Promise<any> {
const lastIndex = assetUrl.lastIndexOf("/");
const assetId = assetUrl.substring(lastIndex + 1);

View File

@ -23,6 +23,8 @@
/* Custom image styles */
.ProseMirror img {
min-width: 100px;
min-height: 100px;
transition: filter 0.1s ease-in-out;
&:hover {