[WEB-460] refactor: editors, chore: pages list improvement (#4090)

* fix: stroing the transactions in page

* fix: page details changes

* chore: page response change

* chore: removed duplicated endpoints

* chore: optimised the urls

* chore: removed archived and favorite pages

* chore: revamping pages store and components

* mentions loading state part done

* fixed mentions not showing in modals

* removed comments and cleaned up types

* removed unused types

* reset: head

* chore: pages store and component updates

* style: pages list item UI

* fix: improved colors and drag handle width

* fix: slash commands are no more shown in the code blocks

* fix: cleanup/hide drag handles post drop

* fix: hide/cleanup drag handles post drag start

* fix: aligning the drag handles better with the node post css changes of the length

* fix: juggling back and forth of drag handles in ordered and unordered lists

* chore: fix imports, ts errors and other things

* fix: clearing nodes to default node i.e paragraph before converting it to other types of nodes

For more reference on what this does, please refer https://tiptap.dev/docs/editor/api/commands/clear-nodes

* chore: clearNodes after delete in case of selections being present

* fix: hiding link selector in the bubble menu if inline code block is selected

* chore: filtering, ordering and searching implemented

* chore: updated pages store and updated UI

* chore: new core editor just for document editor created

* chore: removed setIsSubmitting prop in doc editor

* fix: fixed submitting state for image uploads

* refactor: setShouldShowAlert removed

* refactor: rerenderOnPropsChange prop removed

* chore: type inference magic in ref to expose an api for controlling editor menu items from outside

* fix: naming imports

* chore: change names of the exposed functions and removing old types

* refactor: remove debouncedUpdatesEnabled prop;

* refactor: editor heading markings now parsed using html

* chore: removed unrelated components from the document editor

* refactor: page details granular components

* fix: remove onActionCompleteHandler

* refactor: removed rerenderOnProps change prop

* feat: added getMarkDown function

* chore: update dropdown option actions

* fix: sidebar markings update logic

* chore: add image and to-do list actions to the toolbar

* fix: handling refs and populating them via callbacks

* feat: scroll to node api exposed

* cleaning up editor refs when the editor is destroyed

* feat: scrolling added to read only instance of the editor

* fix: markings logic

* fix: build errors with types

* fix: build erros

* fix: subscribing to transactions of editor via ref

* chore: remove debug statements

* fix: type errors

* fix: temporary different slash commands for document editor

* chore: inline code extension style

* chore: remove border from readOnly editor

* fix: editor bottom padding

* chore: pages improvements

* chore: handle Enter key on the page title

* feat: added loading indicator logic in mentions

* fix: mentions and slash commands now work well with multiple editors in one place

* refactor: page store structure, filtering logic

* feat: added better seperation in inline code blocks

* feat: list autojoining added

* fix: pages folder structure

* fix: image refocus from external parts

* working lists somewhat

* chore: implement page reactions

* fix: build errors

* fix: build errors

* fixed drag handles stuff

* task list item fixed

* working

* fix: working on multiple nested lists

* chore: remove debug statements

* fix: Tab key on first list item handled to not go out of editor focus

* feat: threshold auto scroll support added and multi nested list selection fixed

* fix: caret color bug with improved inline code blocks

* fix: node range error when bulk deleting with list

* fix: removed slash commands from working in code blocks

* chore: update typography margins

* chore: new field added in page model

* fix: better type inference in slash commands

* chore: code block UI

* feat: image insertion at correct position using ref added

* feat: added improved mentions support for space

* fix: type errors in mentions for comments in web app

* sync: core with document-core

* fix: build errors

* fix: fallback for appendTo not being able to find active container instantly

* fix: page store

* fix: page description

* fix: css quality issues

* chore: code cleanup

* chore: removed placeholder text in codeblocks

* chore: archived pages response change

* chore: archived pages response change

* fix: initial pages list fetch

* fix: pages list filters and ordering

* chore: add access change option in the quick actions dropdown

* fix: inline code block caret fixed

* regression: removing extra text

* chore: caret color removed

* feat: copy code button added in code blocks

* fix: initial load of page details

* fix: initial load of page details

* fix: image resizing weird behavior on click/expanding it too much fixed now

* chore: copy page response

* fix: todo list spacing

* chore: description html in the copy page

* chore: handle latest description on refetch

* fix: saner scroll behaviours

* fix: block menu positioning

* fix: updated empty string description

* feat: tab change sync support added

* fix: infinite rerendering with markings

* fix: block menu finally

* fix: intial load on reload bug fixed

* fix: nested lists alignment

* fix: editor padding

* fix: first level list items copyable

* chore: list spacing

* fix: title change

* fix: pages list block items interaction

* fix: saving chip position

* fix: delete action from block menu to focus properly

* fix: margin-bottom as 0 to avoid weird spacing when a paragraph node follows a list node

* style: table, chore: lite text editor toolbar

* fix: page description tab sync

* fix: lists spacing and alignment

* refactor: document editor props

* feat: rich text editor wrapper created and migrated core

* feat: created wrapper around lite text editor and merged core

* chore: add lite text editor toolbar

* fix: build errors

* fix: type errors and addead live updation of toolbar

* chore: pages migration

* fix: inbox issue

* refactor: remove redundant package

* refactor: unused files

* fix: add dompurify to space app

* fix: inline code margin

* fix: editor className props

* fix: build errors

* fix: traversing up the tree before assuming the parent is not a list item

* fix: drag handle positions for list items fixed

* fix: removed focus at end logic after deleting block

* fix: image wrapper overflow scroll fix with block menu's position

* fix: selection and deletion logic for nested lists fixed!!

* fix: hiding the block menu while scrolling in the document/app

* fix: merge conflicts resolved from develop

* fix: inbox issue description

* chore: move page title to the web app

* fix: handling edge cases for table selection

* chore: lint issues

* refactor: list item functions moved to same file

* refactor: use mention hook

* fix: added try catch blocks for mention suggestions

* chore: remove unused code

* fix: remove console logs

* fix: remove console logs

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com>
This commit is contained in:
Aaryan Khandelwal 2024-04-11 21:28:59 +05:30 committed by GitHub
parent 8b6035d315
commit 3e2355e223
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
248 changed files with 7602 additions and 5619 deletions

View file

@ -1,20 +0,0 @@
import { LucideIconType } from "@plane/editor-core";
interface IAlertLabelProps {
Icon?: LucideIconType;
backgroundColor: string;
textColor?: string;
label: string;
}
export const AlertLabel = (props: IAlertLabelProps) => {
const { Icon, backgroundColor, textColor, label } = props;
return (
<div
className={`flex h-7 items-center gap-2 rounded-full px-3 py-0.5 text-xs font-medium ${backgroundColor} ${textColor}`}
>
{Icon && <Icon className="h-3 w-3" />}
<span>{label}</span>
</div>
);
};

View file

@ -1,40 +0,0 @@
import { HeadingComp, HeadingThreeComp, SubheadingComp } from "src/ui/components/heading-component";
import { IMarking } from "src/types/editor-types";
import { Editor } from "@tiptap/react";
import { scrollSummary } from "src/utils/editor-summary-utils";
interface ContentBrowserProps {
editor: Editor;
markings: IMarking[];
setSidePeekVisible?: (sidePeekState: boolean) => void;
}
export const ContentBrowser = (props: ContentBrowserProps) => {
const { editor, markings, setSidePeekVisible } = props;
const handleOnClick = (marking: IMarking) => {
scrollSummary(editor, marking);
if (setSidePeekVisible) setSidePeekVisible(false);
};
return (
<div className="flex h-full flex-col overflow-hidden">
<h2 className="font-medium">Outline</h2>
<div className="h-full overflow-y-auto">
{markings.length !== 0 ? (
markings.map((marking) =>
marking.level === 1 ? (
<HeadingComp onClick={() => handleOnClick(marking)} heading={marking.text} />
) : marking.level === 2 ? (
<SubheadingComp onClick={() => handleOnClick(marking)} subHeading={marking.text} />
) : (
<HeadingThreeComp heading={marking.text} onClick={() => handleOnClick(marking)} />
)
)
) : (
<p className="mt-3 text-xs text-custom-text-400">Headings will be displayed here for navigation</p>
)}
</div>
</div>
);
};

View file

@ -1,99 +0,0 @@
import { Editor } from "@tiptap/react";
import { Archive, RefreshCw, Lock } from "lucide-react";
import { IMarking, DocumentDetails } from "src/types/editor-types";
import { FixedMenu } from "src/ui/menu";
import { UploadImage } from "@plane/editor-core";
import { AlertLabel } from "src/ui/components/alert-label";
import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "src/ui/components/vertical-dropdown-menu";
import { SummaryPopover } from "src/ui/components/summary-popover";
import { InfoPopover } from "src/ui/components/info-popover";
import { getDate } from "src/utils/date-utils";
interface IEditorHeader {
editor: Editor;
KanbanMenuOptions: IVerticalDropdownItemProps[];
sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void;
markings: IMarking[];
isLocked: boolean;
isArchived: boolean;
archivedAt?: Date;
readonly: boolean;
uploadFile?: UploadImage;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
documentDetails: DocumentDetails;
isSubmitting?: "submitting" | "submitted" | "saved";
}
export const EditorHeader = (props: IEditorHeader) => {
const {
documentDetails,
archivedAt,
editor,
sidePeekVisible,
readonly,
setSidePeekVisible,
markings,
uploadFile,
setIsSubmitting,
KanbanMenuOptions,
isArchived,
isLocked,
isSubmitting,
} = props;
return (
<div className="flex items-center border-b border-custom-border-200 md:px-5 px-3 py-2">
<div className="md:w-56 flex-shrink-0 lg:w-72 w-fit">
<SummaryPopover
editor={editor}
markings={markings}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
<div className="flex-shrink-0 hidden md:flex">
{!readonly && uploadFile && (
<FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />
)}
</div>
<div className="flex flex-grow items-center justify-end gap-3">
{isLocked && (
<AlertLabel
Icon={Lock}
backgroundColor="bg-custom-background-80"
textColor="text-custom-text-300"
label="Locked"
/>
)}
{isArchived && archivedAt && (
<AlertLabel
Icon={Archive}
backgroundColor="bg-blue-500/20"
textColor="text-blue-500"
label={`Archived at ${getDate(archivedAt)?.toLocaleString()}`}
/>
)}
{!isLocked && !isArchived ? (
<div
className={`absolute right-[120px] flex items-center gap-x-2 transition-all duration-300 ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting !== "submitted" && isSubmitting !== "saved" && (
<RefreshCw className="h-4 w-4 stroke-custom-text-300" />
)}
<span className="text-sm text-custom-text-300">
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</span>
</div>
) : null}
{!isArchived && <InfoPopover documentDetails={documentDetails} />}
<VerticalDropdownMenu items={KanbanMenuOptions} />
</div>
</div>
);
};

View file

@ -1,47 +0,0 @@
export const HeadingComp = ({
heading,
onClick,
}: {
heading: string;
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
}) => (
<h3
onClick={onClick}
className="ml-4 mt-3 cursor-pointer text-sm font-medium leading-[125%] tracking-tight hover:text-custom-primary max-md:ml-2.5"
role="button"
>
{heading}
</h3>
);
export const SubheadingComp = ({
subHeading,
onClick,
}: {
subHeading: string;
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
}) => (
<p
onClick={onClick}
className="ml-6 mt-2 cursor-pointer text-xs font-medium tracking-tight text-gray-400 hover:text-custom-primary"
role="button"
>
{subHeading}
</p>
);
export const HeadingThreeComp = ({
heading,
onClick,
}: {
heading: string;
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
}) => (
<p
onClick={onClick}
className="ml-8 mt-2 cursor-pointer text-xs font-medium tracking-tight text-gray-400 hover:text-custom-primary"
role="button"
>
{heading}
</p>
);

View file

@ -1,9 +1 @@
export * from "./alert-label";
export * from "./content-browser";
export * from "./editor-header";
export * from "./heading-component";
export * from "./info-popover";
export * from "./page-renderer";
export * from "./summary-popover";
export * from "./summary-side-bar";
export * from "./vertical-dropdown-menu";

View file

@ -1,71 +0,0 @@
import { useState } from "react";
import { usePopper } from "react-popper";
import { Calendar, History, Info } from "lucide-react";
// types
import { DocumentDetails } from "src/types/editor-types";
//utils
import { getDate } from "src/utils/date-utils";
type Props = {
documentDetails: DocumentDetails;
};
// function to render a Date in the format- 25 May 2023 at 2:53PM
const renderDate = (date: Date | undefined): string => {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "long",
year: "numeric",
hour: "numeric",
minute: "numeric",
hour12: true,
};
const formattedDate: string = new Intl.DateTimeFormat("en-US", options).format(date);
return formattedDate;
};
export const InfoPopover: React.FC<Props> = (props) => {
const { documentDetails } = props;
const [isPopoverOpen, setIsPopoverOpen] = useState<boolean>(false);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles: infoPopoverStyles, attributes: infoPopoverAttributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
});
return (
<div onMouseEnter={() => setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}>
<button type="button" ref={setReferenceElement} className="block">
<Info className="h-3.5 w-3.5" />
</button>
{isPopoverOpen && (
<div
className="z-10 w-64 space-y-2.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg"
ref={setPopperElement}
style={infoPopoverStyles.popper}
{...infoPopoverAttributes.popper}
>
<div className="space-y-1.5">
<h6 className="text-xs text-custom-text-400">Last updated on</h6>
<h5 className="flex items-center gap-1 text-sm">
<History className="h-3 w-3" />
{renderDate(getDate(documentDetails?.last_updated_at))}
</h5>
</div>
<div className="space-y-1.5">
<h6 className="text-xs text-custom-text-400">Created on</h6>
<h5 className="flex items-center gap-1 text-sm">
<Calendar className="h-3 w-3" />
{renderDate(getDate(documentDetails?.created_on))}
</h5>
</div>
</div>
)}
</div>
);
};

View file

@ -115,11 +115,6 @@ export const LinkEditView = ({
const removeLink = () => {
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
linkRemoved.current = true;
viewProps.onActionCompleteHandler({
title: "Link successfully removed",
message: "The link was removed from the text.",
type: "success",
});
viewProps.closeLinkView();
};

View file

@ -12,21 +12,11 @@ export const LinkPreview = ({
const removeLink = () => {
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
viewProps.onActionCompleteHandler({
title: "Link successfully removed",
message: "The link was removed from the text.",
type: "success",
});
viewProps.closeLinkView();
};
const copyLinkToClipboard = () => {
navigator.clipboard.writeText(url);
viewProps.onActionCompleteHandler({
title: "Link successfully copied",
message: "The link was copied to the clipboard.",
type: "success",
});
viewProps.closeLinkView();
};

View file

@ -11,11 +11,6 @@ export interface LinkViewProps {
to: number;
url: string;
closeLinkView: () => void;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
}
export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {

View file

@ -1,9 +1,8 @@
import { useCallback, useRef, useState } from "react";
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
import { Node } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { Editor, ReactRenderer } from "@tiptap/react";
import { useCallback, useRef, useState } from "react";
import { DocumentDetails } from "src/types/editor-types";
import { LinkView, LinkViewProps } from "./links/link-view";
import {
autoUpdate,
@ -15,40 +14,22 @@ import {
useFloating,
useInteractions,
} from "@floating-ui/react";
import BlockMenu from "../menu//block-menu";
type IPageRenderer = {
documentDetails: DocumentDetails;
updatePageTitle: (title: string) => void;
editor: Editor;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
editorClassNames: string;
editorContentCustomClassNames?: string;
editorContainerClassName: string;
hideDragHandle?: () => void;
readonly: boolean;
tabIndex?: number;
};
export const PageRenderer = (props: IPageRenderer) => {
const {
documentDetails,
tabIndex,
editor,
editorClassNames,
editorContentCustomClassNames,
updatePageTitle,
readonly,
hideDragHandle,
} = props;
const [pageTitle, setPagetitle] = useState(documentDetails.title);
const { tabIndex, editor, hideDragHandle, editorContainerClassName } = props;
// states
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
const [cleanup, setCleanup] = useState(() => () => {});
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
@ -63,18 +44,9 @@ export const PageRenderer = (props: IPageRenderer) => {
const { getFloatingProps } = useInteractions([dismiss]);
const handlePageTitleChange = (title: string) => {
setPagetitle(title);
updatePageTitle(title);
};
const [cleanup, setcleanup] = useState(() => () => {});
const floatingElementRef = useRef<HTMLElement | null>(null);
const closeLinkView = () => {
setIsOpen(false);
};
const closeLinkView = () => setIsOpen(false);
const handleLinkHover = useCallback(
(event: React.MouseEvent) => {
@ -137,7 +109,6 @@ export const PageRenderer = (props: IPageRenderer) => {
setCoordinates({ x: x - 300, y: y - 50 });
setIsOpen(true);
setLinkViewProps({
onActionCompleteHandler: props.onActionCompleteHandler,
closeLinkView: closeLinkView,
view: "LinkPreview",
url: href,
@ -148,45 +119,32 @@ export const PageRenderer = (props: IPageRenderer) => {
});
});
setcleanup(cleanupFunc);
setCleanup(cleanupFunc);
},
[editor, cleanup]
);
return (
<div className="w-full h-full pb-20 pl-7 pt-5 page-renderer">
{!readonly ? (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
className="-mt-2 w-full break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none"
value={pageTitle}
/>
) : (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
className="-mt-2 w-full overflow-x-clip break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none"
value={pageTitle}
disabled
/>
)}
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
<EditorContainer
editor={editor}
hideDragHandle={hideDragHandle}
editorContainerClassName={editorContainerClassName}
>
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
{editor && editor.isEditable && <BlockMenu editor={editor} />}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
<div
style={{ ...floatingStyles, left: `${coordinates.x}px`, top: `${coordinates.y}px` }}
className={`absolute`}
className="absolute"
ref={refs.setFloating}
>
<LinkView {...linkViewProps} style={floatingStyles} {...getFloatingProps()} />
</div>
)}
</div>
</>
);
};

View file

@ -1,69 +0,0 @@
import { useState } from "react";
import { Editor } from "@tiptap/react";
import { usePopper } from "react-popper";
import { List } from "lucide-react";
// components
import { ContentBrowser } from "src/ui/components/content-browser";
// types
import { IMarking } from "src/types/editor-types";
type Props = {
editor: Editor;
markings: IMarking[];
sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void;
};
export const SummaryPopover: React.FC<Props> = (props) => {
const { editor, markings, sidePeekVisible, setSidePeekVisible } = props;
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
const { styles: summaryPopoverStyles, attributes: summaryPopoverAttributes } = usePopper(
referenceElement,
popperElement,
{
placement: "bottom-start",
}
);
return (
<div className="group/summary-popover w-min whitespace-nowrap">
<button
type="button"
ref={setReferenceElement}
className={`grid h-7 w-7 place-items-center rounded ${
sidePeekVisible ? "bg-custom-primary-100/20 text-custom-primary-100" : "text-custom-text-300"
}`}
onClick={() => setSidePeekVisible(!sidePeekVisible)}
>
<List className="h-4 w-4" />
</button>
<div className="md:hidden block">
{sidePeekVisible && (
<div
className="z-10 max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<ContentBrowser setSidePeekVisible={setSidePeekVisible} editor={editor} markings={markings} />
</div>
)}
</div>
<div className="hidden md:block">
{!sidePeekVisible && (
<div
className="z-10 hidden max-h-80 w-64 overflow-y-auto rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 p-3 shadow-custom-shadow-rg group-hover/summary-popover:block"
ref={setPopperElement}
style={summaryPopoverStyles.popper}
{...summaryPopoverAttributes.popper}
>
<ContentBrowser editor={editor} markings={markings} />
</div>
)}
</div>
</div>
);
};

View file

@ -1,19 +0,0 @@
import { Editor } from "@tiptap/react";
import { IMarking } from "src/types/editor-types";
import { ContentBrowser } from "src/ui/components/content-browser";
interface ISummarySideBarProps {
editor: Editor;
markings: IMarking[];
sidePeekVisible: boolean;
}
export const SummarySideBar = ({ editor, markings, sidePeekVisible }: ISummarySideBarProps) => (
<div
className={`h-full transform overflow-hidden p-5 transition-all duration-200 ${
sidePeekVisible ? "translate-x-0" : "-translate-x-full"
}`}
>
<ContentBrowser editor={editor} markings={markings} />
</div>
);

View file

@ -1,46 +0,0 @@
import { LucideIconType } from "@plane/editor-core";
import { CustomMenu } from "@plane/ui";
import { MoreVertical } from "lucide-react";
type TMenuItems =
| "archive_page"
| "unarchive_page"
| "lock_page"
| "unlock_page"
| "copy_markdown"
| "close_page"
| "copy_page_link"
| "duplicate_page";
export interface IVerticalDropdownItemProps {
key: number;
type: TMenuItems;
Icon: LucideIconType;
label: string;
action: () => Promise<void> | void;
}
export interface IVerticalDropdownMenuProps {
items: IVerticalDropdownItemProps[];
}
const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => (
<CustomMenu.MenuItem onClick={action} className="flex items-center gap-2">
<Icon className="h-3 w-3" />
<div className="text-custom-text-300">{label}</div>
</CustomMenu.MenuItem>
);
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => (
<CustomMenu
maxHeight={"md"}
className={"h-4.5 mt-1"}
placement={"bottom-start"}
optionsClassName={"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "}
customButton={<MoreVertical size={14} />}
>
{items.map((item) => (
<VerticalDropdownItem key={item.key} type={item.type} Icon={item.Icon} label={item.label} action={item.action} />
))}
</CustomMenu>
);

View file

@ -6,17 +6,17 @@ import { UploadImage } from "@plane/editor-core";
export const DocumentEditorExtensions = (
uploadFile: UploadImage,
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void
) => [
SlashCommand(uploadFile, setIsSubmitting),
SlashCommand(uploadFile),
DragAndDrop(setHideDragHandle),
Placeholder.configure({
placeholder: ({ node }) => {
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
if (editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image")) {
return "";
}

View file

@ -1,187 +1,97 @@
"use client";
import React, { useState } from "react";
import { UploadImage, DeleteImage, RestoreImage, getEditorClassNames, useEditor } from "@plane/editor-core";
import {
UploadImage,
DeleteImage,
RestoreImage,
getEditorClassNames,
useEditor,
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
} from "@plane/editor-core";
import { DocumentEditorExtensions } from "src/ui/extensions";
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions";
import { EditorHeader } from "src/ui/components/editor-header";
import { useEditorMarkings } from "src/hooks/use-editor-markings";
import { SummarySideBar } from "src/ui/components/summary-side-bar";
import { DocumentDetails } from "src/types/editor-types";
import { PageRenderer } from "src/ui/components/page-renderer";
import { getMenuOptions } from "src/utils/menu-options";
import { useRouter } from "next/router";
import { FixedMenu } from "src";
interface IDocumentEditor {
// document info
documentDetails: DocumentDetails;
value: string;
rerenderOnPropsChange?: {
id: string;
description_html: string;
initialValue: string;
value?: string;
fileHandler: {
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
handleEditorReady?: (value: boolean) => void;
containerClassName?: string;
editorClassName?: string;
onChange: (json: object, html: string) => void;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions: () => Promise<IMentionSuggestion[]>;
};
// file operations
uploadFile: UploadImage;
deleteFile: DeleteImage;
restoreFile: RestoreImage;
cancelUploadImage: () => any;
// editor state managers
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
updatePageTitle: (title: string) => void;
debouncedUpdatesEnabled?: boolean;
isSubmitting: "submitting" | "submitted" | "saved";
// embed configuration
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
tabIndex?: number;
}
interface DocumentEditorProps extends IDocumentEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
interface EditorHandle {
clearEditor: () => void;
setEditorValue: (content: string) => void;
setEditorValueAtCursorPosition: (content: string) => void;
}
const DocumentEditor = ({
documentDetails,
onChange,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
editorContentCustomClassNames,
value,
uploadFile,
deleteFile,
restoreFile,
isSubmitting,
customClassName,
forwardedRef,
duplicationConfig,
pageLockConfig,
pageArchiveConfig,
updatePageTitle,
cancelUploadImage,
onActionCompleteHandler,
rerenderOnPropsChange,
tabIndex,
}: IDocumentEditor) => {
const { markings, updateMarkings } = useEditorMarkings();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
const router = useRouter();
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
const DocumentEditor = (props: IDocumentEditor) => {
const {
onChange,
initialValue,
value,
fileHandler,
containerClassName,
editorClassName = "",
mentionHandler,
handleEditorReady,
forwardedRef,
tabIndex,
} = props;
// states
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
// use editor
const editor = useEditor({
onChange(json, html) {
updateMarkings(json);
onChange(json, html);
},
onStart(json) {
updateMarkings(json);
},
debouncedUpdatesEnabled,
restoreFile,
setIsSubmitting,
setShouldShowAlert,
editorClassName,
restoreFile: fileHandler.restore,
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
cancelUploadImage: fileHandler.cancel,
initialValue,
value,
uploadFile,
deleteFile,
cancelUploadImage,
rerenderOnPropsChange,
handleEditorReady,
forwardedRef,
extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting),
mentionHandler,
extensions: DocumentEditorExtensions(fileHandler.upload, setHideDragHandleFunction),
});
if (!editor) {
return null;
}
const KanbanMenuOptions = getMenuOptions({
editor: editor,
router: router,
duplicationConfig: duplicationConfig,
pageLockConfig: pageLockConfig,
pageArchiveConfig: pageArchiveConfig,
onActionCompleteHandler,
});
const editorClassNames = getEditorClassNames({
const editorContainerClassNames = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
customClassName,
containerClassName,
});
if (!editor) return null;
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<EditorHeader
readonly={false}
KanbanMenuOptions={KanbanMenuOptions}
editor={editor}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={(val) => setSidePeekVisible(val)}
markings={markings}
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
documentDetails={documentDetails}
isSubmitting={isSubmitting}
/>
<div className="flex-shrink-0 md:hidden border-b border-custom-border-200 pl-3 py-2">
{uploadFile && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
</div>
<div className="flex h-full w-full overflow-y-auto frame-renderer">
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72 hidden md:block">
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
</div>
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer
tabIndex={tabIndex}
onActionCompleteHandler={onActionCompleteHandler}
hideDragHandle={hideDragHandleOnMouseLeave}
readonly={false}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
editorClassNames={editorClassNames}
documentDetails={documentDetails}
updatePageTitle={updatePageTitle}
/>
</div>
<div className="hidden w-56 flex-shrink-0 lg:block lg:w-72" />
</div>
</div>
<PageRenderer
tabIndex={tabIndex}
editor={editor}
editorContainerClassName={editorContainerClassNames}
hideDragHandle={hideDragHandleOnMouseLeave}
/>
);
};
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>((props, ref) => (
<DocumentEditor {...props} forwardedRef={ref} />
const DocumentEditorWithRef = React.forwardRef<EditorRefApi, IDocumentEditor>((props, ref) => (
<DocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";

View file

@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef } from "react";
import tippy, { Instance } from "tippy.js";
import { Copy, LucideIcon, Trash2 } from "lucide-react";
import { Editor } from "@tiptap/react";
interface BlockMenuProps {
editor: Editor;
}
export default function BlockMenu(props: BlockMenuProps) {
const { editor } = props;
const menuRef = useRef<HTMLDivElement>(null);
const popup = useRef<Instance | null>(null);
const handleClickDragHandle = useCallback((event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.matches(".drag-handle-dots") || target.matches(".drag-handle-dot")) {
event.preventDefault();
popup.current?.setProps({
getReferenceClientRect: () => target.getBoundingClientRect(),
});
popup.current?.show();
return;
}
popup.current?.hide();
return;
}, []);
useEffect(() => {
if (menuRef.current) {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
appendTo: () => document.querySelector(".frame-renderer"),
trigger: "manual",
interactive: true,
arrow: false,
placement: "left-start",
animation: "shift-away",
maxWidth: 500,
hideOnClick: true,
onShown: () => {
menuRef.current?.focus();
},
});
}
return () => {
popup.current?.destroy();
popup.current = null;
};
}, []);
useEffect(() => {
const handleKeyDown = () => {
popup.current?.hide();
};
const handleScroll = () => {
popup.current?.hide();
};
document.addEventListener("click", handleClickDragHandle);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("scroll", handleScroll, true); // Using capture phase
return () => {
document.removeEventListener("click", handleClickDragHandle);
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("scroll", handleScroll, true);
};
}, [handleClickDragHandle]);
const MENU_ITEMS: {
icon: LucideIcon;
key: string;
label: string;
onClick: (e: React.MouseEvent) => void;
isDisabled?: boolean;
}[] = [
{
icon: Trash2,
key: "delete",
label: "Delete",
onClick: (e) => {
editor.chain().deleteSelection().focus().run();
popup.current?.hide();
e.preventDefault();
e.stopPropagation();
},
},
{
icon: Copy,
key: "duplicate",
label: "Duplicate",
isDisabled: editor.state.selection.content().content.firstChild?.type.name === "image",
onClick: (e) => {
const { view } = editor;
const { state } = view;
const { selection } = state;
editor
.chain()
.insertContentAt(selection.to, selection.content().content.firstChild!.toJSON(), {
updateSelection: true,
})
.focus(selection.to + 1, { scrollIntoView: false })
.run();
popup.current?.hide();
e.preventDefault();
e.stopPropagation();
},
},
];
return (
<div
ref={menuRef}
className="z-10 max-h-60 min-w-[7rem] overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
>
{MENU_ITEMS.map((item) => {
// Skip rendering the button if it should be disabled
if (item.isDisabled && item.key === "duplicate") {
return null;
}
return (
<button
key={item.key}
type="button"
className="flex w-full items-center gap-2 truncate rounded px-1 py-1.5 text-xs text-custom-text-200 hover:bg-custom-background-80"
onClick={item.onClick}
disabled={item.isDisabled}
>
<item.icon className="h-3 w-3" />
{item.label}
</button>
);
})}
</div>
);
}

View file

@ -1,141 +0,0 @@
import { Editor } from "@tiptap/react";
import {
BoldItem,
BulletListItem,
isCellSelection,
cn,
CodeItem,
ImageItem,
ItalicItem,
NumberedListItem,
QuoteItem,
StrikeThroughItem,
TableItem,
UnderLineItem,
HeadingOneItem,
HeadingTwoItem,
HeadingThreeItem,
findTableAncestor,
EditorMenuItem,
UploadImage,
} from "@plane/editor-core";
export type BubbleMenuItem = EditorMenuItem;
type EditorBubbleMenuProps = {
editor: Editor;
uploadFile: UploadImage;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
};
export const FixedMenu = (props: EditorBubbleMenuProps) => {
const { editor, uploadFile, setIsSubmitting } = props;
const basicMarkItems: BubbleMenuItem[] = [
HeadingOneItem(editor),
HeadingTwoItem(editor),
HeadingThreeItem(editor),
BoldItem(editor),
ItalicItem(editor),
UnderLineItem(editor),
StrikeThroughItem(editor),
];
const listItems: BubbleMenuItem[] = [BulletListItem(editor), NumberedListItem(editor)];
const userActionItems: BubbleMenuItem[] = [QuoteItem(editor), CodeItem(editor)];
function getComplexItems(): BubbleMenuItem[] {
const items: BubbleMenuItem[] = [TableItem(editor)];
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
return items;
}
const complexItems: BubbleMenuItem[] = getComplexItems();
return (
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
<div className="flex items-center gap-0.5 pr-2">
{basicMarkItems.map((item) => (
<button
key={item.name}
type="button"
onClick={item.command}
className={cn(
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon className="h-4 w-4" />
</button>
))}
</div>
<div className="flex items-center gap-0.5 px-2">
{listItems.map((item) => (
<button
key={item.name}
type="button"
onClick={item.command}
className={cn(
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
<div className="flex items-center gap-0.5 px-2">
{userActionItems.map((item) => (
<button
key={item.name}
type="button"
onClick={item.command}
className={cn(
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
<div className="flex items-center gap-0.5 pl-2">
{complexItems.map((item) => (
<button
key={item.name}
type="button"
onClick={item.command}
className={cn(
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
</div>
);
};

View file

@ -1 +0,0 @@
export { FixedMenu } from "./fixed-menu";

View file

@ -1,132 +1,53 @@
import { getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
import { useRouter } from "next/router";
import { useState, forwardRef, useEffect } from "react";
import { EditorHeader } from "src/ui/components/editor-header";
import { forwardRef, MutableRefObject } from "react";
import { EditorReadOnlyRefApi, getEditorClassNames, IMentionHighlight, useReadOnlyEditor } from "@plane/editor-core";
// components
import { PageRenderer } from "src/ui/components/page-renderer";
import { SummarySideBar } from "src/ui/components/summary-side-bar";
import { useEditorMarkings } from "src/hooks/use-editor-markings";
import { DocumentDetails } from "src/types/editor-types";
import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "src/types/menu-actions";
import { getMenuOptions } from "src/utils/menu-options";
import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget";
interface IDocumentReadOnlyEditor {
value: string;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
noBorder: boolean;
borderOnFocus: boolean;
customClassName: string;
documentDetails: DocumentDetails;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
pageDuplicationConfig?: IDuplicationConfig;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
initialValue: string;
containerClassName: string;
editorClassName?: string;
tabIndex?: number;
handleEditorReady?: (value: boolean) => void;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
};
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
}
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
interface EditorHandle {
clearEditor: () => void;
setEditorValue: (content: string) => void;
}
const DocumentReadOnlyEditor = ({
noBorder,
borderOnFocus,
customClassName,
value,
documentDetails,
forwardedRef,
pageDuplicationConfig,
pageLockConfig,
pageArchiveConfig,
rerenderOnPropsChange,
onActionCompleteHandler,
tabIndex,
}: DocumentReadOnlyEditorProps) => {
const router = useRouter();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
const { markings, updateMarkings } = useEditorMarkings();
const editor = useReadOnlyEditor({
value,
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const {
containerClassName,
editorClassName = "",
initialValue,
forwardedRef,
rerenderOnPropsChange,
tabIndex,
handleEditorReady,
mentionHandler,
} = props;
const editor = useReadOnlyEditor({
initialValue,
editorClassName,
mentionHandler,
forwardedRef,
handleEditorReady,
extensions: [IssueWidgetPlaceholder()],
});
useEffect(() => {
if (editor) {
updateMarkings(editor.getJSON());
}
}, [editor]);
if (!editor) {
return null;
}
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
const editorContainerClassName = getEditorClassNames({
containerClassName,
});
const KanbanMenuOptions = getMenuOptions({
editor: editor,
router: router,
pageArchiveConfig: pageArchiveConfig,
pageLockConfig: pageLockConfig,
duplicationConfig: pageDuplicationConfig,
onActionCompleteHandler,
});
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<EditorHeader
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
readonly
editor={editor}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
KanbanMenuOptions={KanbanMenuOptions}
markings={markings}
documentDetails={documentDetails}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
/>
<div className="flex h-full w-full overflow-y-auto frame-renderer">
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-80">
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
</div>
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer
tabIndex={tabIndex}
onActionCompleteHandler={onActionCompleteHandler}
updatePageTitle={() => Promise.resolve()}
readonly
editor={editor}
editorClassNames={editorClassNames}
documentDetails={documentDetails}
/>
</div>
<div className="hidden w-56 flex-shrink-0 lg:block lg:w-80" />
</div>
</div>
);
return <PageRenderer tabIndex={tabIndex} editor={editor} editorContainerClassName={editorContainerClassName} />;
};
const DocumentReadOnlyEditorWithRef = forwardRef<EditorHandle, IDocumentReadOnlyEditor>((props, ref) => (
<DocumentReadOnlyEditor {...props} forwardedRef={ref} />
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
));
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";